Async 和 Await

我们要求计算机执行的许多操作可能需要一段时间才能完成。 例如,如果您使用视频编辑器创建家庭庆祝活动的视频,则导出它可能需要几分钟到几小时。 同样,下载家人分享的视频可能需要很长时间。 如果我们能够在等待这些长时间运行的进程完成时做其他事情,那就太好了。

视频导出将尽可能多地使用 CPU 和 GPU 算力。 如果您只有一个 CPU 核心,并且您的操作系统在导出完成之前从不暂停导出,那么在导出运行时您将无法在计算机上执行任何其他操作。 这将是非常令人沮丧的体验。 但是,您的计算机操作系统可以——而且确实会!——经常无形地中断导出,以便您在此过程中完成其他工作。

文件下载则不同。 它不会占用太多 CPU 时间。 相反,CPU 需要等待数据从网络到达。 虽然您可以在部分数据出现后开始读取数据,但其余数据可能需要一段时间才能出现。 即使数据都已存在,视频也可能非常大,因此加载所有数据可能需要一些时间。 也许只需要一两秒钟——但这对于现代处理器来说是很长的时间,现代处理器每秒可以执行数十亿次操作。 如果能够在等待网络调用完成时将 CPU 用于其他工作,那就太好了——因此,您的操作系统将再次无形地中断您的程序,以便在网络操作仍在进行时可以发生其他事情。

注意:视频导出通常被描述为“CPU 密集型”或“计算密集型”操作。 它受到计算机在 CPU 或 GPU 中处理数据的能力以及它可以使用的速度的限制。 视频下载通常被描述为“IO 密集型”操作,因为它受到计算机输入和输出速度的限制。 它的速度只能与数据通过网络发送的速度一样快。

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

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

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

我们可以通过生成一个专用线程来下载每个文件来避免阻塞主线程。 然而,我们最终会发现这些线程的开销是一个问题。 如果调用一开始不是阻塞的,那就更好了。 最后但并非最不重要的一点是,如果我们能以与我们在阻塞代码中使用的相同直接风格编写代码,那就更好了。 像这样

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

这正是 Rust 的 async 抽象为我们提供的。 不过,在我们了解这在实践中如何工作之前,我们需要简要了解一下并行性和并发性之间的区别。

并行性和并发性

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

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

当一个人在完成任何任务之前处理多个不同的任务时,这就是并发。 也许您的计算机上检出了两个不同的项目,当您对一个项目感到厌烦或卡住时,您会切换到另一个项目。 您只是一个人,所以您无法完全同时在两个任务上取得进展——但是您可以多任务处理,通过在任务之间切换来在多个任务上取得进展。

Concurrent work flow
图 17-1:并发工作流程,在任务 A 和任务 B 之间切换

当您同意在团队成员之间分配一组任务时,每个人负责一项任务并单独完成,这就是并行性。 团队中的每个人都可以在完全相同的时间取得进展。

Concurrent work flow
图 17-2:并行工作流程,其中任务 A 和任务 B 独立进行

在这两种情况下,您可能都必须在不同任务之间进行协调。 也许您认为一个人正在处理的任务完全独立于其他人的工作,但实际上它需要团队中另一个人完成某些事情。 某些工作可以并行完成,但某些工作实际上是串行的:它只能按顺序发生,一件事接一件事。 同样,您可能会意识到您自己的一个任务依赖于您的另一个任务。 现在您的并发工作也变成了串行的。

并行性和并发性也可以相互交叉。 如果您得知同事在您完成其中一项任务之前被卡住了,您可能会将所有精力集中在该任务上以“解除”同事的阻塞。 您和您的同事不再能够并行工作,您也无法再并发地处理自己的任务。

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

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

现在,让我们深入了解 Rust 中的 async 编程是如何实际工作的! 在本章的其余部分,我们将

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