键盘快捷键

按 ← 或 → 在章节之间导航

按 S 或 / 在书中搜索

按 ? 显示此帮助

按 Esc 隐藏此帮助

异步编程基础:Async、Await、Futures 和 Streams

我们让计算机执行的许多操作都需要一段时间才能完成。如果能在等待这些 长时间运行的进程完成的同时做点别的事情就好了。现代计算机提供了两种 同时处理多个操作的技术:并行(parallelism)和并发(concurrency)。然而, 我们程序的逻辑大多是以线性方式编写的。我们希望能够指定程序应执行的 操作,以及函数可以暂停、程序其他部分可以转而执行的时点, 而无需预先明确指定每段代码应以何种顺序和方式运行。 异步编程(asynchronous programming)就是一种抽象, 它让我们能够以潜在的暂停点和最终结果来表达代码, 并替我们处理协调工作的细节。

本章在第 16 章使用线程实现并行和并发的基础上, 介绍一种编写代码的替代方案:Rust 的 futures、streams, 以及让我们能够表达操作如何异步执行的 async 和 await 语法, 还有实现异步运行时的第三方 crate——即管理和协调异步操作执行的 代码。

让我们考虑一个例子。假设你正在导出一个自己制作的家庭庆祝视频, 这个操作可能需要几分钟到几小时不等。视频导出会尽可能多地占用 CPU 和 GPU 算力。如果你只有一个 CPU 核心,并且操作系统在导出完成前 不会暂停它——也就是说,如果它同步地(synchronously)执行导出—— 那么在该任务运行期间,你无法在计算机上做任何其他事情。 那会是非常令人沮丧的体验。幸运的是,你的计算机操作系统 可以、也确实会足够频繁地隐式中断导出,让你能同时完成其他工作。

现在假设你正在下载别人分享的视频,这同样需要一段时间, 但不会占用那么多 CPU 时间。在这种情况下,CPU 必须等待数据 从网络到达。虽然数据一旦开始到达你就可以开始读取, 但全部数据可能需要一段时间才能到齐。即使数据全部就绪, 如果视频很大,也可能至少需要一两秒才能将其全部加载完毕。 这听起来可能不算久,但对于每秒可以执行数十亿次操作的 现代处理器来说,这已经是非常长的时间了。同样,你的操作系统 会隐式中断你的程序,让 CPU 在等待网络调用完成的同时 执行其他工作。

视频导出是一个 CPU 密集型(CPU-bound)或计算密集型 (compute-bound)操作的例子。它受限于计算机在 CPU 或 GPU 内部 潜在的数据处理速度,以及能为该操作投入多少这种速度。 视频下载则是一个 I/O 密集型(I/O-bound)操作的例子, 因为它受限于计算机输入和输出(input and output)的速度; 它只能与数据在网络上传输的速度一样快。

在这两个例子中,操作系统的隐式中断提供了一种并发形式。 不过,这种并发只发生在整个程序的层面:操作系统中断一个程序, 让其他程序得以完成工作。在许多情况下,由于我们比操作系统 更细粒度地理解我们的程序,因此我们能够发现操作系统 看不到的并发机会。

例如,如果我们要构建一个管理文件下载的工具,我们应该能够 这样编写程序:开始一个下载不会锁定 UI,并且用户应该能够 同时开始多个下载。然而,许多用于与网络交互的操作系统 API 是 阻塞的(blocking);也就是说,它们会阻塞程序的进度, 直到其处理的数据完全就绪。

注意:如果你仔细想想,大多数函数调用都是这样工作的。然而, 阻塞一词通常专指那些与文件、网络或计算机上其他资源交互的 函数调用,因为在这些场景下,单个程序会从操作的非阻塞中 受益。

我们可以通过生成一个专用线程来下载每个文件,从而避免阻塞主线程。 然而,这些线程所使用的系统资源的开销最终会成为问题。如果调用 一开始就不阻塞,那就更可取了——取而代之的是,我们可以定义 若干希望程序完成的任务,并让运行时选择以最佳顺序和方式来 运行它们。

这正是 Rust 的 async(asynchronous 的缩写)抽象为我们提供的。 在本章中,你将全面了解 async,我们将涵盖以下主题:

  • 如何使用 Rust 的 async 和 await 语法,并通过运行时执行 异步函数
  • 如何使用异步模型解决我们在第 16 章中看过的一些同样的挑战
  • 多线程和异步如何提供互补的解决方案,在许多情况下你可以将 它们结合起来

不过,在我们了解异步在实践中如何工作之前,需要先简短地 绕个弯,讨论并行和并发之间的区别。

并行与并发

到目前为止,我们大多将并行和并发视为基本可以互换的概念。现在 我们需要更精确地区分它们,因为随着我们开始实践,这些差异 会显现出来。

考虑一个团队在一个软件项目上分配工作的不同方式。你可以给单个 成员分配多个任务,给每个成员分配一个任务,或者混合使用这两种 方法。

当一个人在任何任务完成之前同时处理几个不同的任务时,这就是 并发(concurrency)。实现并发的一种方式类似于在你的计算机上 检出了两个不同的项目,当你对其中一个项目感到厌倦或卡住时, 就切换到另一个。你只是一个人,所以无法在完全相同的时刻 同时在两个任务上取得进展,但你可以多任务处理,通过在它们之间 切换,一次在一个任务上取得进展(见图 17-1)。

A diagram with stacked boxes labeled Task A and Task B, with diamonds in them representing subtasks. Arrows point from A1 to B1, B1 to A2, A2 to B2, B2 to A3, A3 to A4, and A4 to B3. The arrows between the subtasks cross the boxes between Task A and Task B.
图 17-1:一个在任务 A 和任务 B 之间切换的并发工作流

当团队通过让每个成员各自承担一个任务并独立完成来拆分一组任务时, 这就是并行(parallelism)。团队中的每个人都可以在完全相同的 时刻取得进展(见图 17-2)。

A diagram with stacked boxes labeled Task A and Task B, with diamonds in them representing subtasks. Arrows point from A1 to A2, A2 to A3, A3 to A4, B1 to B2, and B2 to B3. No arrows cross between the boxes for Task A and Task B.
图 17-2:一个并行工作流,任务 A 和任务 B 上的工作独立进行

在这两种工作流中,你可能都必须在不同的任务之间进行协调。 也许你本以为分配给一个人的任务与其他所有人的工作完全独立, 但它实际上需要团队中的另一个人先完成他们的任务。其中一些工作 可以并行完成,但其中一些实际上是串行的(serial):它只能 一个接一个地按顺序发生,如图 17-3 所示。

A diagram with stacked boxes labeled Task A and Task B, with diamonds in them representing subtasks. In Task A, arrows point from A1 to A2, from A2 to a pair of thick vertical lines like a “pause” symbol, and from that symbol to A3. In task B, arrows point from B1 to B2, from B2 to B3, from B3 to A3, and from B3 to B4.
图 17-3:一个部分并行的工作流,任务 A 和任务 B 上的工作独立进行,直到任务 A3 因等待任务 B3 的结果而受阻。

同样,你可能会意识到自己的某个任务依赖于你的另一个任务。 现在你的并发工作也变成了串行。

并行和并发也可以相互交叉。如果你得知一位同事在你完成自己的 某个任务之前一直受阻,你可能会集中所有精力完成那个任务, 以“解除”同事的阻塞。你和你的同事不再能够并行工作, 你也不再能够在自己的任务上并发工作。

同样的基本动态也适用于软件和硬件。在只有一个 CPU 核心的机器上, CPU 一次只能执行一个操作,但它仍然可以并发工作。借助线程、 进程和异步等工具,计算机可以暂停一项活动并切换到其他活动, 最终再循环回到最初的那项活动。在拥有多个 CPU 核心的机器上, 它还可以并行工作。一个核心可以在执行一项任务的同时,另一个核心 执行一项完全无关的任务,而这些操作实际上是在同一时刻发生的。

在 Rust 中运行异步代码通常是并发进行的。取决于硬件、操作系统 以及我们所使用的异步运行时(稍后会详细介绍异步运行时), 这种并发在底层也可能利用并行。

现在,让我们深入探讨 Rust 中的异步编程实际上是如何工作的。