# 异步编程基础:Async、Await、Futures 和 Streams
我们要求计算机执行的许多操作可能需要一段时间才能完成。如果能在等待这些长时间运行的进程完成时做些其他事情,那将是很好的。现代计算机提供了两种同时处理多个操作的技术:并行和并发。然而,一旦我们开始编写涉及并行或并发操作的程序,我们很快就会遇到异步编程固有的新挑战,即操作可能不会按其开始的顺序顺序完成。本章在第 16 章使用线程实现并行和并发的基础上,引入了一种异步编程的替代方法:Rust 的 Futures、Streams、支持它们的 async
和 await
语法,以及管理和协调异步操作的工具。
让我们考虑一个例子。假设你正在导出你制作的家庭庆祝视频,这个操作可能需要几分钟到几小时。视频导出将尽可能多地利用 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 的
async
和await
语法 - 如何使用异步模型解决我们在第 16 章中遇到的一些相同挑战
- 多线程和异步如何提供互补的解决方案,你可以在许多情况下将它们结合起来
然而,在我们看到 async 在实践中如何工作之前,我们需要稍微绕道讨论并行和并发之间的区别。
# 并行与并发
到目前为止,我们一直将并行和并发视为基本可互换的。现在我们需要更精确地区分它们,因为这些区别将在我们开始工作时显现出来。
考虑一个团队在软件项目上分工的不同方式。你可以为单个成员分配多个任务,为每个成员分配一个任务,或者混合使用这两种方法。
当一个人在任何任务完成之前处理几个不同的任务时,这就是并发。也许你的计算机上签出了两个不同的项目,当你对一个项目感到厌倦或卡住时,你会切换到另一个项目。你只是一个人,所以你不能同时在两个任务上取得进展,但你可以多任务处理,通过在它们之间切换来一次在一个任务上取得进展(参见图 17-1)。
图 17-1:并发工作流,在任务 A 和任务 B 之间切换
当团队通过让每个成员承担一个任务并独自完成任务来分解一组任务时,这就是并行。团队中的每个人都可以同时取得进展(参见图 17-2)。
图 17-2:并行工作流,任务 A 和任务 B 独立进行
在这两种工作流中,你可能需要在不同任务之间进行协调。也许你认为分配给一个人的任务与所有其他人的工作完全无关,但它实际上需要团队中的另一个人先完成他们的任务。一些工作可以并行完成,但其中一些实际上是串行的:它只能按顺序发生,一个任务接一个任务,如图 17-3 所示。
图 17-3:部分并行工作流,任务 A 和任务 B 独立进行,直到任务 A3 被任务 B3 的结果阻塞。
同样,你可能会意识到自己的一个任务依赖于你的另一个任务。现在你的并发工作也变得串行了。
并行和并发也可以相互交叉。如果你得知一位同事被卡住,直到你完成你的一个任务,你可能会将所有精力集中在该任务上,以“解除阻塞”你的同事。你和你的同事将无法再并行工作,你也无法再并发地处理自己的任务。
相同的基本动态也适用于软件和硬件。在只有一个 CPU 核心的机器上,CPU 一次只能执行一个操作,但它仍然可以并发工作。使用线程、进程和 async 等工具,计算机可以暂停一个活动并切换到其他活动,然后最终再次循环回到第一个活动。在具有多个 CPU 核心的机器上,它还可以并行工作。一个核心可以执行一个任务,而另一个核心执行一个完全不相关的任务,并且这些操作实际上是同时发生的。
在 Rust 中使用 async 时,我们总是处理并发。根据硬件、操作系统和我们正在使用的 async 运行时(稍后会详细介绍 async 运行时),这种并发也可能在底层使用并行。
现在,让我们深入了解 Rust 中 async 编程的实际工作方式。