Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

ch17-00-async-await.md

很多我们要求计算机执行的操作都需要一段时间才能完成。如果在等待这些长时间运行的过程结束时,我们还能做点别的事情,就再好不过了。现代计算机提供了两种同时处理多个操作的技术:并行和并发。然而,我们的程序逻辑通常是以近乎线性的方式编写的。我们希望能够描述程序应执行的操作,以及函数在哪些点可以暂停并让程序的其他部分转而运行,而不需要预先精确指定每段代码究竟该以什么顺序、用什么方式运行。异步编程 正是一种抽象,它让我们能够用“可能暂停的点”和“最终结果”来表达代码,而协调执行的细节则由它来替我们处理。

本章以前一章通过线程实现并行和并发为基础,引入另一种编写代码的方式:Rust 的 futures、streams,以及 asyncawait 语法。它们让我们能够表达“操作可以是异步的”这一点;此外,还有由第三方 crate 提供的异步运行时,用来管理和协调这些异步操作的执行。

来看一个例子。假设你正在导出一个家庭聚会的视频,这个操作可能要花上几分钟甚至几小时。视频导出会尽可能多地占用 CPU 和 GPU 资源。如果你只有一个 CPU 核,而操作系统又不会在导出完成前暂停这个任务,也就是说它会以同步方式执行导出,那么在这个任务运行期间你就什么别的都做不了。这会是相当糟糕的体验。幸运的是,你的操作系统可以,而且确实会,足够频繁地打断导出任务,让你同时完成其他工作。

再假设你正在下载别人分享给你的视频。这个操作也可能耗时很长,但不会占用太多 CPU 时间。此时 CPU 主要是在等待网络数据到达。虽然数据一开始到达时你就可以开始读取,但全部数据到齐仍然可能需要一段时间。即便数据已经全部到达,如果视频文件很大,完整载入它也至少可能需要一两秒。听起来不算长,但对于每秒能执行数十亿次操作的现代处理器来说,这已经是很长的时间了。和前面的例子一样,操作系统也会在等待网络调用完成时悄悄打断程序,让 CPU 去处理其他工作。

视频导出属于 CPU 密集型CPU-bound)或 计算密集型compute-bound)操作:它受限于 CPU 或 GPU 处理数据的速度,以及操作能分配到多少计算能力。视频下载则属于 I/O-bound 操作,因为它受限于计算机的 输入输出input and output)速度;它的速度最多只能和网络传输数据的速度一样快。

在上述两个例子中,操作系统的隐式中断提供了一种形式的并发。不过这种并发仅限于整个程序的级别:操作系统中断一个程序并让其它程序得以执行。在很多场景中,由于我们能比操作系统在更细粒度上理解我们的程序,因此我们可以观察到很多操作系统无法察觉的并发机会。

例如,如果我们正在构建一个管理文件下载的工具,程序就应该能做到:启动一个下载任务不会让 UI 卡死,而且用户还能够同时启动多个下载任务。不过,许多操作系统中与网络交互的 API 都是 blocking 的;也就是说,在它们所处理的数据完全就绪之前,会阻止程序继续向前执行。

注意:如果仔细想想,这其实也是大多数函数调用的工作方式。不过,blocking 这个术语通常保留给那些与文件、网络或计算机上其他资源交互的函数调用,因为正是在这些场景中,单个程序才会从 non-blocking 操作中明显受益。

我们可以为每个文件单独创建一个线程来避免阻塞主线程。然而,这些线程所消耗的系统资源最终会成为问题。更理想的情况是:这些调用从一开始就不是阻塞的,并且我们只需定义程序想完成的一组任务,然后让运行时自行选择最佳的执行顺序和方式。

这正是 Rust 的 asyncasynchronous 的缩写)抽象所提供的能力。本章会介绍以下内容:

  • 如何使用 Rust 的 asyncawait 语法,并借助运行时执行异步函数
  • 如何用异步模型解决一些我们在第十六章中已经遇到过的挑战
  • 多线程和异步如何提供互补的解决方案,以及它们在许多场景下如何组合使用

不过在看到 async 的实际工作方式之前,我们需要先稍微绕个远路,讨论一下并行(parallelism)和并发(concurrency)的区别。

并行与并发

在上一章中,我们大致将并行和并发视为可以互换的概念。但现在我们需要更加精确地区分它们,因为它们的区别将在实际工作中显现出来。

思考一下不同的团队分割方法来开发一个软件项目。我们可以分配给一个个人多个任务,也可以每个团队成员各自负责一个任务,或者可以采用这两种方法的组合。

当一个人在任何一个任务都还没完成之前,就在多个不同任务之间切换工作,这就是 并发。一种实现并发的方式,很像你在电脑上同时 checkout 了两个不同项目;当你对其中一个项目感到厌倦,或是在上面卡住时,就切到另一个。你只有一个人,所以不可能在完全相同的时刻同时推进两个任务,但你可以通过在它们之间切换来多任务处理,一次推进一个任务。

并发工作流
图 17-1:一个并发工作流,在任务 A 和任务 B 之间切换

当团队把一组任务拆开,让每个成员各自负责一个任务并单独推进时,这就是 并行。团队中的每个人都可以在完全相同的时间取得进展。

并行工作流
图 17-2:一个并行流,其中任务 A 和任务 B 的工作同时独立进行

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

部分并行工作流
图 17-3:一个部分并行的工作流,其中任务 A 和任务 B 的工作相互独立,直到任务 A3 阻塞在等待任务 B3 的结果

同样,你也可能意识到自己的一个任务依赖于另一个任务。那么你原本的并发工作也变成了串行。

并行和并发之间也可能彼此交叉。如果你得知某位同事正在卡着,必须等你先完成某个任务,那你很可能会把全部精力都集中到那个任务上,好“解除阻塞”。这时你和同事就不能再并行工作了,而你自己也不能再并发地推进其他任务。

同样的基础动态也作用于软件与硬件。在一个单核的机器上,CPU 一次只能执行一个操作,不过它仍然可以并发工作。借助像线程、进程和异步(async)等工具,计算机可以暂停一个活动,并在最终切换回第一个活动之前切换到其它活动。在一个有多个 CPU 核心的机器上,它也可以并行工作。一个核心可以做一件工作的同时另一个核心可以做一些完全不相关的工作,而且这些工作实际上是同时发生的。

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

现在,让我们深入看看 Rust 中的异步编程究竟是如何工作的。