第17章 异步编程基础:Async、Await、Future 与 Stream

介绍 Rust 异步编程:async/await、Future、Stream,以及与并行、并发的关系

译文 · 基于 The Rust Programming Language(rustc 1.97.1)

异步编程基础:Async、Await、Future 与 Stream

原文链接: https://doc.rust-lang.org/stable/book/ch17-00-async-await.html

  我们让计算机执行的许多操作都可能耗时较长。若能在等待这些长时间任务完成时去做别的事,就再好不过了。现代计算机提供了两种同时推进多项工作的技术:并行(parallelism)与并发(concurrency)。然而,程序的逻辑大多仍按线性方式书写。我们希望能描述程序应执行哪些操作,以及函数可以在何处暂停、转而运行程序的其他部分,而不必事先精确规定每一段代码的运行顺序与方式。异步编程(asynchronous programming)正是这样一种抽象:用可能的暂停点与最终结果来表达代码,并把协调细节交给底层去处理。

  本章在第 16 章用线程实现并行与并发的基础上,介绍另一种写法:Rust 的 Future、Stream,以及用于表达异步操作的 async 与 await 语法,还有实现异步运行时(runtime)的第三方 crate——运行时负责管理与协调异步操作的执行。

  先看一个例子。假设你正在导出一段家庭庆祝活动的视频,这一过程可能耗时数分钟到数小时。视频导出会尽可能吃满 CPU 与 GPU。若只有一个 CPU 核心,且操作系统在导出完成前从不打断它——也就是说,导出是同步(synchronously)执行的——那么在这项任务运行期间你就无法在电脑上做任何别的事。那会非常令人沮丧。幸好,操作系统会频繁、无形地打断导出,好让你同时完成其他工作。

  再假设你在下载别人分享的视频:同样可能较慢,但占用的 CPU 时间并不多。此时 CPU 需要等待网络数据到达。数据一开始到达就可以开始读取,但全部到齐可能还要一段时间。即便数据都齐了,若视频很大,完整加载也可能需要一两秒。听起来不长,但对每秒能完成数十亿次运算的现代处理器来说,这已经非常久了。同样,操作系统会无形地打断你的程序,好让 CPU 在等待网络调用完成时去做别的事。

  视频导出是CPU 密集型(CPU-bound)或计算密集型(compute-bound)操作的例子:受限于 CPU/GPU 的数据处理能力,以及能分给该任务的份额。视频下载则是I/O 密集型(I/O-bound)操作:受限于计算机输入与输出的速度,只能和网络传数据一样快。

  在这两个例子里,操作系统的无形打断提供了一种并发。不过这种并发只发生在整个程序这一层:操作系统打断一个程序,好让别的程序干活。很多时候,我们对程序的理解比操作系统更细粒度,因此能发现操作系统看不到的并发机会。

  例如,若我们在做下载管理工具,就应写成启动一次下载不会卡住界面,且用户可以同时开始多次下载。但许多与网络交互的操作系统 API 是阻塞(blocking)的:也就是说,在它们处理的数据完全就绪之前,它们会挡住程序继续前进。

注意:细想之下,大多数函数调用其实都是这样工作的。不过,阻塞一词通常留给与文件、网络或计算机上其他资源交互的调用,因为正是在这些场景下,单个程序会从非阻塞操作中受益。

  我们可以通过为每个文件派生专用线程来避免阻塞主线程。但那些线程占用的系统资源开销最终会成为问题。更理想的情况是调用本身就不阻塞,而是定义一批希望程序完成的任务,让运行时选择最佳的执行顺序与方式。

  这正是 Rust 的 async(asynchronous 的缩写)抽象所提供的。本章将围绕以下主题介绍 async:

  • 如何使用 Rust 的 async 与 await 语法,并用运行时执行异步函数
  • 如何用异步模型解决第 16 章中讨论过的同类挑战
  • 多线程与 async 如何提供互补方案,并在许多场景下可以组合使用

  在实际看到 async 如何工作之前,我们需要先简短区分并行与并发。

并行与并发

  到目前为止,我们大体把并行与并发当作可互换的概念。现在需要更精确地区分它们,因为开始动手后差异就会显现出来。

  设想一个团队如何拆分软件项目中的工作。你可以让一个人同时领多项任务,也可以每人一项,或两者混用。

  当一个人在任一任务完成前就在多项不同任务之间切换推进时,这就是并发。一种实现并发的方式,好比电脑上同时检出了两个项目:对一个项目感到无聊或卡住时,就切到另一个。你只有一个人,无法在同一瞬间同时推进两件事,但可以多任务切换,一次推进一项(见图 17-1)。

堆叠的方框分别标为任务 A 与任务 B,其中的菱形表示子任务。箭头从 A1 指向 B1、B1 指向 A2、A2 指向 B2、B2 指向 A3、A3 指向 A4、A4 指向 B3。子任务之间的箭头跨越任务 A 与任务 B 的方框。
图 17-1:并发工作流,在任务 A 与任务 B 之间切换

  当团队把一组任务拆开,让每人单独负责一项时,这就是并行。团队中每个人可以在同一瞬间推进工作(见图 17-2)。

堆叠的方框分别标为任务 A 与任务 B,其中的菱形表示子任务。箭头从 A1 指向 A2、A2 指向 A3、A3 指向 A4,以及 B1 指向 B2、B2 指向 B3。任务 A 与任务 B 的方框之间没有交叉箭头。
图 17-2:并行工作流,任务 A 与任务 B 各自独立推进

  在这两种工作流里,你都可能需要在不同任务之间协调。也许你以为分给某人的任务与其他人完全无关,实际上却要等另一人先完成。有些工作可以并行,有些其实是串行(serial)的:只能一个接一个进行,如图 17-3。

堆叠的方框分别标为任务 A 与任务 B,其中的菱形表示子任务。在任务 A 中,箭头从 A1 指向 A2,从 A2 指向一对粗竖线(类似“暂停”符号),再从该符号指向 A3。在任务 B 中,箭头从 B1 指向 B2、B2 指向 B3、B3 指向 A3,以及 B3 指向 B4。
图 17-3:部分并行的工作流:任务 A 与任务 B 各自独立推进,直到 A3 被 B3 的结果挡住。

  同样,你也可能发现自己的某项任务依赖另一项任务。于是你的并发工作也变成了串行。

  并行与并发还会彼此交织。若你得知同事卡在等你完成某项任务,你多半会全力投入该项任务以便“解封”对方。此时你们无法再并行工作,你也无法再并发地推进自己的其他任务。

  软件与硬件中也有同样的基本动态。在只有单个 CPU 核心的机器上,CPU 一次只能执行一个操作,但仍然可以并发工作。借助线程、进程与 async 等工具,计算机可以暂停一项活动、切换到其他活动,最终再回到第一项。在多核机器上,还可以真正并行:一个核心做一件事,另一个核心做完全无关的另一件事,这些操作实际同时发生。

  在 Rust 中运行 async 代码通常是并发的。取决于硬件、操作系统以及所用的异步运行时(稍后详述),这种并发在底层也可能用到并行。

  接下来,让我们深入看看 Rust 中的异步编程究竟如何运作。


17.1 Future 与 async 语法

讲解 Future、async/await 语法,并用运行时编写第一个异步程序

17.2 用 async 实现并发

用 async 任务、join 与消息传递解决与线程类似的并发问题

17.3 处理任意数量的 Future

向运行时让出控制权,并组合 Future 构建 timeout 等异步抽象

17.4 Stream:序列中的 Future

介绍 Stream:随时间产生一系列值的异步序列,及其与迭代器的关系

17.5 深入了解异步相关 Trait

深入 Future、Pin、Unpin 与 Stream trait,理解轮询与自引用

17.6 Future、任务与线程

综合比较 Future、任务与线程,说明何时选用以及如何组合

最后修改 August 11, 2026: 更新 (70a5af133)