2.1 Future
6 分钟阅读
Rust 的一个显著特点是无畏并发。也就是说,你应该有能力做并发的事情,而不必放弃安全性。此外,Rust 作为一门底层语言,它关乎无畏并发而不选定特定的实现策略。这意味着我们必须对策略进行抽象,以便日后允许选择——如果我们希望在不同策略的用户之间以任何方式共享代码的话。
Future 对计算进行抽象。它们描述的是「做什么」,而与「在哪里」和「何时」无关。为此,它们旨在将代码拆分为小的、可组合的操作,然后由系统的一部分来执行。让我们通过计算的含义来梳理,找出可以抽象的地方。
Send 与 Sync
幸运的是,并发 Rust 已有两个广为人知且有效的概念,用于抽象程序并发部分之间的共享:Send 和 Sync。值得注意的是,Send 和 Sync trait 都对并发工作的策略进行抽象,组合良好,且不规定具体实现。
简要总结如下:
Send抽象的是在计算中将数据传递给另一个并发计算(我们称之为接收方),并在发送方一侧失去对数据的访问。在许多编程语言中,这种策略通常有实现,但语言层面缺少支持,需要你自己强制执行「失去访问权」的行为。这是 bug 的常见来源:发送方在发送后仍保留对已发送内容的句柄,甚至可能在发送后继续操作它们。Rust 通过让这种行为显式化来缓解这一问题。类型可以是Send或非Send(通过实现相应的标记 trait),从而允许或禁止传递它们,所有权和借用规则则防止后续访问。Sync关乎程序两个并发部分之间的数据共享。这是另一种常见模式:向内存位置写入,或在另一方写入时读取,本质上是不安全的,因此这种访问需要通过同步来协调。1 双方就不同同时使用内存同一部分达成一致,有许多常见方式,例如互斥锁和自旋锁。同样,Rust 让你可以(安全地!)不必关心细节。Rust 让你能够表达某物需要同步,而不具体说明如何同步。
请注意,我们避免了任何像*「线程」这样的词,而是选择了「计算」。Send 和 Sync 的全部威力在于,它们让你不必承担了解什么*在共享的负担。在实现时,你只需要知道对于手头的类型,哪种共享方式合适。这使推理保持局部性,不受该类型用户日后使用何种实现的影响。
Send 和 Sync 可以以有趣的方式组合,但这超出了本文范围。你可以在 Rust 程序设计语言 中找到示例。
总结:Rust 让我们能够安全地抽象并发程序的重要属性——数据共享。它以非常轻量的方式做到这一点;语言本身只知道两个标记 Send 和 Sync,并在可能时帮我们自动派生它们。其余是库层面的问题。
计算的简易视角
虽然计算本身足以写一整本书,但对我们来说,一个非常简化的视角就足够了:一系列可组合的操作,可以根据决策分支,顺序执行并产生结果或产生错误。
延迟计算
如上所述,Send 和 Sync 关乎数据。但程序不仅关乎数据,还关乎计算数据。而这就是 Future 所做的。我们将在下一章仔细看看它是如何工作的。先来看看 Future 让我们能表达什么,用自然语言来说。Future 将这样的计划:
- 做 X
- 如果 X 成功,做 Y
转变为:
- 开始做 X
- 一旦 X 成功,开始做 Y
还记得引言中关于「延迟计算」的讨论吗?就是这样。你不是告诉计算机现在要执行什么并据此做决定,而是告诉它要开始做什么,以及如何在……嗯……Future 中应对潜在事件。
面向初学者的视角
让我们看一个简单的函数,特别是它的返回值:
| |
你可以随时调用它,因此你完全控制何时调用。但问题在于:一旦你调用它,你就将控制权转移给被调用的函数,直到它最终返回一个值。 请注意,这个返回值谈论的是过去。过去有一个缺点:所有决定都已做出。它有一个优点:结果可见。我们可以解开程序过去计算的结果,然后决定如何处理它。
但我们希望对计算进行抽象,让别人选择如何运行它。这与总是查看先前计算的结果根本不相容。所以,让我们找一个描述计算而不运行它的类型。让我们再看这个函数:
| |
从时间的角度说,我们只能在调用函数之前或函数返回之后采取行动。这并不理想,因为它剥夺了我们在其运行期间做某事的能力。在处理并行代码时,这会剥夺我们在第一个任务运行时启动并行任务的能力(因为我们交出了控制权)。
此时我们可以求助于线程。但线程是一种非常具体的并发原语,而我们说过我们在寻找一种抽象。
我们要找的是某种代表朝着未来某个结果进行中的工作的东西。在 Rust 中,每当我们说「某种东西」,几乎总是指 trait。让我们从 Future trait 的一个不完整定义开始:
| |
仔细看看,我们会发现:
- 它对
Output泛型化。 - 它提供一个名为
poll的函数,让我们可以检查当前计算的状态。 - (暂时忽略
Pin和Context,高层理解不需要它们。)
每次调用 poll() 可能产生以下两种情况之一:
- 计算已完成,
poll将返回Poll::Ready - 计算尚未执行完毕,将返回
Poll::Pending
这让我们可以从外部检查 Future 是否仍有未完成的工作,或者是否终于完成并可以给出值。最简单(但效率不高)的方式是在循环中不断轮询 future。可以进行优化,而优秀的运行时正是为你做这件事。
请注意,在情况 1 发生之后再次调用 poll 可能导致令人困惑的行为。详见 futures 文档。
异步
虽然 Future trait 在 Rust 中存在已有一段时间,但构建和描述它们并不方便。为此,Rust 现在有特殊语法:async。上面的例子用 async-std 实现后如下所示:
| |
差异非常小,对吧?我们所做的只是将函数标记为 async,并插入 2 个特殊命令:.await。
这个 async 函数建立了一个延迟计算。当调用此函数时,它将产生 Future<Output = io::Result<String>>,而不是立即返回 io::Result<String>。(或者更准确地说,为你生成一个实现 Future<Output = io::Result<String>> 的类型。)
.await 做什么?
.await 后缀名副其实:你使用它的那一刻,代码将等待所请求的操作(例如打开文件或读取其中所有数据)完成。.await? 并不特殊,它只是将 ? 运算符应用于 .await 的结果。那么,与最初的代码示例相比,我们得到了什么?我们得到 future 然后立即等待它们?
.await 点充当标记。在这里,代码将等待 Future 产生其值。future 如何完成?你不必关心!这个标记让负责执行这段代码的组件(通常称为「运行时」)在它完成计算的同时处理所有其他必须做的事情。当你正在后台进行的操作完成时,它会回到这一点。这就是为什么这种编程风格也称为事件驱动编程。我们等待事情发生(例如文件被打开),然后做出反应(开始读取)。
当同时执行 2 个或更多这样的函数时,我们的运行时系统就能用处理所有其他正在进行的事件来填充等待时间。
结论
从值出发,我们寻找能表达朝着稍后可用值进行工作的东西。由此,我们讨论了轮询的概念。
Future 是任何不代表值、而是代表在未来某时刻产生值的能力的数据类型。其实现因用例而异且细节丰富,但接口很简单。
接下来,我们将介绍 task,我们将用它来实际运行 Future。
在保证无人写入的情况下,双方同时读取始终是安全的。 ↩︎