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

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

本章在第 16 章使用线程实现并行和并发的基础上,引入了另一种编写代码的方式:Rust 的 future、stream,以及 asyncawait 语法。这些特性让我们能够表达操作如何异步执行,而第三方 crate 则实现了异步运行时——管理和协调异步操作执行的代码。

我们来看一个例子。假设你正在导出一个家庭聚会的视频,这个操作可能需要几分钟到几小时不等。视频导出会尽可能多地占用 CPU 和 GPU 资源。如果你只有一个 CPU 核心,而且操作系统在导出完成之前不会暂停它——也就是说,它_同步地_执行导出——那么在任务运行期间你就无法在电脑上做任何其他事情。这将是一种非常令人沮丧的体验。幸运的是,你的计算机操作系统能够(而且确实会)以不可见的方式频繁中断导出操作,让你同时完成其他工作。

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

视频导出是 CPU 密集型(CPU-bound)或_计算密集型_(compute-bound)操作的一个例子。它受限于 CPU 或 GPU 的数据处理速度,以及能分配给该操作多少处理能力。视频下载则是 I/O 密集型(I/O-bound)操作的一个例子,因为它受限于计算机_输入和输出_的速度——数据只能以网络传输的速度到达。

在这两个例子中,操作系统的不可见中断提供了一种并发形式。不过,这种并发只发生在整个程序的层面:操作系统中断一个程序来让其他程序完成工作。在很多情况下,由于我们对自己的程序比操作系统理解得更细致,我们能发现操作系统看不到的并发机会。

例如,如果我们正在构建一个管理文件下载的工具,应该能够让程序在启动一个下载时不会锁住 UI,用户也应该能够同时启动多个下载。然而,许多用于网络交互的操作系统 API 是_阻塞的_(blocking)——也就是说,它们会阻塞程序的执行,直到处理的数据完全就绪。

注意:如果你仔细想想,_大多数_函数调用其实都是这样工作的。不过,“阻塞“这个术语通常专门用于那些与文件、网络或计算机上其他资源交互的函数调用,因为在这些场景下,程序确实能从_非阻塞_操作中获益。

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

这正是 Rust 的 async(_异步_的缩写)抽象所提供的能力。在本章中,你将学习 async 的方方面面,我们将涵盖以下主题:

  • 如何使用 Rust 的 asyncawait 语法,以及如何通过运行时执行异步函数
  • 如何使用异步模型来解决我们在第 16 章中遇到的一些相同挑战
  • 多线程和异步如何提供互补的解决方案,在许多情况下可以结合使用

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

并行与并发

到目前为止,我们基本上把并行和并发当作可以互换的概念。现在我们需要更精确地区分它们,因为随着我们开始工作,这些差异会变得重要。

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

当一个人在多个不同任务之间切换,在任何一个任务完成之前就开始处理其他任务,这就是_并发_。一种实现并发的方式类似于在电脑上同时检出两个不同的项目,当你对一个项目感到厌倦或卡住时,就切换到另一个项目。你只是一个人,所以不可能在完全相同的时刻同时推进两个任务,但你可以通过在它们之间切换来多任务处理,一次推进一个任务(见图 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 之间切换

当团队将一组任务分配给每个成员各自独立完成一个任务时,这就是_并行_。团队中的每个人都可以在完全相同的时刻取得进展(见图 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 所示。

<img src=“img/trpl17-03.svg” class=“center” alt=“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 一次只能执行一个操作,但它仍然可以并发工作。通过使用线程、进程和 async 等工具,计算机可以暂停一个活动并切换到其他活动,最终再切换回最初的活动。在拥有多个 CPU 核心的机器上,它还可以并行工作。一个核心可以执行一个任务,而另一个核心执行一个完全无关的任务,这些操作实际上是在同一时刻发生的。

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

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