异步编程基础:Async, Await, Futures 和 Streams

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

让我们考虑一个例子。假设您正在导出您创建的家庭庆典视频,这个操作可能需要几分钟到几小时不等。视频导出将尽可能多地使用 CPU 和 GPU 功率。如果您的计算机只有一个 CPU 核心,并且您的操作系统不会暂停导出直到完成——也就是说,如果它同步执行导出——那么在该任务运行时,您将无法在计算机上执行任何其他操作。那将是非常令人沮丧的体验。幸运的是,您的计算机操作系统可以并且确实会不可见地中断导出,频率足以让您同时完成其他工作。

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

视频导出是 CPU 密集型或计算密集型操作的一个例子。它受到计算机 CPU 或 GPU 内潜在数据处理速度以及它可以分配给该操作的速度的限制。视频下载是 IO 密集型操作的一个例子,因为它受到计算机输入和输出速度的限制;它只能以数据通过网络发送的速度运行。

在这两个例子中,操作系统的不可见中断提供了一种并发形式。然而,这种并发仅发生在整个程序的级别:操作系统中断一个程序以让其他程序完成工作。在许多情况下,因为我们比操作系统更精细地理解我们的程序,所以我们可以发现操作系统看不到的并发机会。

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

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

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

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

这正是 Rust 的 async(异步的缩写)抽象为我们提供的。在本章中,您将学习关于 async 的所有内容,我们将涵盖以下主题

  • 如何使用 Rust 的 asyncawait 语法
  • 如何使用 async 模型来解决我们在第 16 章中看到的一些相同挑战
  • 多线程和 async 如何提供互补的解决方案,您可以在许多情况下将它们结合起来

然而,在我们了解 async 在实践中如何工作之前,我们需要稍微绕道讨论一下并行和并发之间的区别。

并行和并发

到目前为止,我们已经将并行和并发视为基本可以互换的。现在我们需要更精确地区分它们,因为当我们开始工作时,差异将会显现出来。

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

当一个人在完成任何一项任务之前处理多个不同的任务时,这就是并发。也许您的计算机上检出了两个不同的项目,当您对一个项目感到厌烦或卡住时,您会切换到另一个项目。您只是一个人,所以您无法在完全相同的时间在两个任务上取得进展,但您可以多任务处理,通过在它们之间切换来一次在一个任务上取得进展(参见图 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 一次只能执行一个操作,但它仍然可以并发工作。使用线程、进程和 async 等工具,计算机可以暂停一项活动并切换到其他活动,然后再循环回到第一个活动。在具有多个 CPU 核心的机器上,它也可以并行工作。一个核心可以执行一个任务,而另一个核心执行一个完全不相关的任务,并且这些操作实际上同时发生。

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

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