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

我们让计算机执行的许多操作都需要一段时间才能完成。如果我们可以在等待这些长时间运行的进程完成的同时做一些其他事情,那将是很不错的。现代计算机提供了两种技术来同时处理多个操作:并行性(parallelism)和并发性(concurrency)。然而,一旦我们开始编写涉及并行或并发操作的程序,我们很快就会遇到异步编程固有的新挑战,即操作可能不会按照启动的顺序依次完成。本章基于第 16 章使用线程实现并行和并发的基础上,引入了一种替代的异步编程方法:Rust 的 Future、Stream、支持它们的 asyncawait 语法,以及管理和协调异步操作的工具。

让我们考虑一个例子。假设你正在导出你创建的家庭庆祝活动的视频,这个操作可能需要几分钟到几个小时。视频导出将尽可能多地使用 CPU 和 GPU 的处理能力。如果你只有一个 CPU 核,并且你的操作系统在导出完成之前不会暂停该导出——也就是说,如果它同步地执行导出——那么在该任务运行时,你将无法在计算机上做任何其他事情。那将是非常令人沮丧的体验。幸运的是,你计算机的操作系统可以而且确实会在导出过程中经常进行不可见的中断,以便你可以同时完成其他工作。

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

视频导出是一个CPU 密集型计算密集型操作的例子。它受限于计算机在 CPU 或 GPU 中潜在的数据处理速度,以及它可以将多少速度用于该操作。视频下载是一个IO 密集型操作的例子,因为它受限于计算机的输入和输出速度;它只能达到数据通过网络传输的速度。

在这两个例子中,操作系统的不可见中断提供了一种形式的并发性。然而,这种并发性只发生在整个程序层面:操作系统中断一个程序,让其他程序完成工作。在许多情况下,因为我们对自己的程序有比操作系统更细粒度的理解,所以我们可以发现操作系统无法看到的并发机会。

例如,如果我们正在构建一个管理文件下载的工具,我们应该能够编写程序,使得启动一个下载不会锁定 UI,并且用户应该能够同时启动多个下载。然而,许多与网络交互的操作系统 API 是阻塞的;也就是说,它们会阻塞程序的进程,直到它们正在处理的数据完全准备好。

注意:仔细想想,这其实是大多数函数调用的工作方式。然而,术语阻塞通常用于与文件、网络或计算机上其他资源交互的函数调用,因为在这些情况下,单个程序会从操作是阻塞中受益。

我们可以通过为每个文件下载生成一个专门的线程来避免阻塞主线程。然而,这些线程的开销最终会成为问题。如果调用本身一开始就不阻塞,那会更好。如果我们能够像在阻塞代码中那样使用相同的直接风格编写代码,那也会更好,类似于这样:

let data = fetch_data_from(url).await;
println!("{data}");

这正是 Rust 的异步(async,asynchronous 的缩写)抽象带给我们的。在本章中,我们将全面学习异步,内容涵盖以下主题:

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

然而,在我们了解异步在实践中如何工作之前,我们需要稍作绕道,讨论一下并行性(parallelism)和并发性(concurrency)之间的区别。

并行性与并发性

到目前为止,我们对并行性和并发性基本上是互换使用的。现在我们需要更精确地区分它们,因为这些区别将在我们开始工作时显现出来。

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

当一个人在完成任何任务之前同时处理几个不同的任务时,这就是并发性。也许你在计算机上签出了两个不同的项目,当你对一个项目感到厌烦或卡住时,你就切换到另一个项目。你只有一个人,所以你不可能在完全相同的时间在两个任务上都取得进展,但你可以多任务处理,通过在它们之间切换来一次在一个任务上取得进展(参见图 17-1)。

A diagram with boxes labeled Task A and Task B, with diamonds in them representing subtasks. There are arrows pointing 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 boxes labeled Task A and Task B, with diamonds in them representing subtasks. There are arrows pointing 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 独立进行

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

A diagram with boxes labeled Task A and Task B, with diamonds in them representing subtasks. There are arrows pointing from A1 to A2, A2 to a pair of thick vertical lines like a “pause” symbol, from that symbol to A3, B1 to B2, B2 to B3, which is below that symbol, B3 to A3, and B3 to B4.
图 17-3:部分并行工作流程,任务 A 和任务 B 独立进行,直到任务 A3 被任务 B3 的结果阻塞。

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

并行性与并发性也可以相互交叉。如果你得知一位同事因为你未完成一项任务而卡住了,你可能会将所有精力集中在那项任务上以“解除阻塞”你的同事。你和你的同事将无法再并行工作,你也将无法再并发处理你自己的任务。

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

在 Rust 中使用异步时,我们始终处理的是并发性。根据硬件、操作系统和我们使用的异步运行时(稍后会详细介绍异步运行时),这种并发性在底层可能也会使用并行性。

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