异步编程基础:Async、Await、Future 和 Stream
我们要求计算机执行的许多操作都需要一定时间才能完成。如果能在等待这些长时间运行的进程完成时做些别的事情,那就太好了。现代计算机提供了两种同时处理多个操作的技术:并行(parallelism)和并发(concurrency)。然而,我们的程序逻辑大多是以线性方式编写的。我们希望能够指定程序应执行的操作以及函数可以暂停、让程序其他部分运行的时机,而无需预先精确指定每段代码的运行顺序和方式。异步编程(asynchronous programming)就是这样一种抽象,它让我们能够用潜在的暂停点和最终结果来表达代码,并为我们处理协调的细节。
本章在第 16 章使用线程实现并行和并发的基础上,引入了另一种编写代码的方式:Rust 的 future、stream,以及 async 和 await 语法。这些特性让我们能够表达操作如何异步执行,而第三方 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 的
async和await语法,以及如何通过运行时执行异步函数 - 如何使用异步模型来解决我们在第 16 章中遇到的一些相同挑战
- 多线程和异步如何提供互补的解决方案,在许多情况下可以结合使用
不过,在我们了解异步在实践中如何工作之前,需要先简短地讨论一下并行和并发之间的区别。
并行与并发
到目前为止,我们基本上把并行和并发当作可以互换的概念。现在我们需要更精确地区分它们,因为随着我们开始工作,这些差异会变得重要。
考虑一下团队在软件项目中分配工作的不同方式。你可以给一个成员分配多个任务,也可以给每个成员分配一个任务,或者混合使用这两种方式。
当一个人在多个不同任务之间切换,在任何一个任务完成之前就开始处理其他任务,这就是_并发_。一种实现并发的方式类似于在电脑上同时检出两个不同的项目,当你对一个项目感到厌倦或卡住时,就切换到另一个项目。你只是一个人,所以不可能在完全相同的时刻同时推进两个任务,但你可以通过在它们之间切换来多任务处理,一次推进一个任务(见图 17-1)。
当团队将一组任务分配给每个成员各自独立完成一个任务时,这就是_并行_。团队中的每个人都可以在完全相同的时刻取得进展(见图 17-2)。
在这两种工作流中,你可能都需要在不同任务之间进行协调。也许你以为分配给某个人的任务与其他人的工作完全独立,但实际上它需要团队中另一个人先完成他们的任务。有些工作可以并行完成,但有些实际上是_串行的_(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.“ />
同样,你可能会发现自己的某个任务依赖于自己的另一个任务。这样你的并发工作也变成了串行的。
并行和并发也可以相互交叉。如果你得知一位同事在等你完成你的某个任务后才能继续,你可能会把所有精力集中在那个任务上来“解除“同事的阻塞。这时你和你的同事不再能并行工作,而你自己也不再能并发地处理自己的多个任务了。
同样的基本动态也适用于软件和硬件。在只有单个 CPU 核心的机器上,CPU 一次只能执行一个操作,但它仍然可以并发工作。通过使用线程、进程和 async 等工具,计算机可以暂停一个活动并切换到其他活动,最终再切换回最初的活动。在拥有多个 CPU 核心的机器上,它还可以并行工作。一个核心可以执行一个任务,而另一个核心执行一个完全无关的任务,这些操作实际上是在同一时刻发生的。
在 Rust 中运行异步代码通常是以并发方式进行的。根据硬件、操作系统和我们使用的异步运行时(稍后会详细介绍异步运行时),这种并发在底层也可能使用并行。
现在,让我们深入了解 Rust 中的异步编程实际上是如何工作的。