Async 和 await

ch17-00-async-await.md
commit 3111eda07a4a4692bf69e3aaad999d840ac9c138

很多我们要求计算机处理的操作都需要一定的时间才能完成。例如,如果你使用视频编辑器来创建一个家庭聚会的视频,导出视频可能会花费几分钟到几小时不等。同样,从家庭成员那里下载共享的视频也可能需要很长时间。如果我们能在等待这些长时间运行的操作完成期间做点其他事情,那就太好了。

视频导出会尽可能使用所有的 CPU 和 GPU。如果你只有一个 CPU 核,同时操作系统在导出完成前也不会暂停,那么在其运行期间你无法使用计算机进行任何其他操作。这会是一个非常糟糕的体验。相反计算机的操作系统可以(也确实可以)隐式地中断导出过程,频率足够高,使你能够在导出进行的同时完成其他任务。

下载文件则有所不同。它不占用大量的 CPU 时间。相反 CPU 需要等待来自于网络的数据。虽然可以在部分数据就绪时就开始读取,但等待剩余数据可能还需要一段时间。即便数据全部就绪了,视频文件也可能非常大,因此加载所有数据也会花费一些时间。虽然这可能只需要一两秒,不过这对于一个现代处理器来说已经是非常长的时间了,因为它每秒可以执行数十亿次操作。因此,如果能让 CPU 在等待网络调用完成的同时去处理别的工作就再好不过了。所以同上操作系统会隐式地中断你的程序以便其它工作可以在网络操作进行的同时继续进行。

注意:视频导出这类操作通常被称为 “CPU 密集型”(“CPU-bound”)或者 “计算密集型”(“compute-bound”)操作。其受限于计算机 CPUGPU 处理数据的速度,以及它所能利用的计算能力。而下载视频这类操作通常被称为 “IO 密集型”(“IO-bound”)操作,因为其受限于计算机的 输入输出 速度。下载的速度最多只能与通过网络传输数据的速度一致。

在上述两个例子中,操作系统的隐式中断提供了一种形式的并发。不过这种并发仅限于整个程序的级别:操作系统中断一个程序并让其它程序得以执行。在很多场景中,由于我们能比操作系统在更细粒度上理解我们的程序,因此我们可以观察到很多操作系统无法察觉的并发机会。

例如,如果我们在构建一个管理文件下载的工具,我们应当以一种不会因开始一个下载任务而锁定 UI 的方式来编写程序,并且用户应该能够同时开始多个下载任务。不过很多操作系统与网络交互的 API 都是 阻塞 的(blocking)。也就是说这些 API 会阻塞程序的进程,直到它们处理的数据完全就绪。

注意:如果你仔细思索一下,会发现这是 大部分 函数调用的工作方式!不过我们通常将 “阻塞” 这个术语保留给那些与文件、网络或其它计算机资源交互的函数调用,因为这些地方是单个程序可以从 阻塞操作中获益的地方。

我们可以新建专用的线程来下载每个文件以免阻塞主线程。然而,我们最终会发现这些线程的开销会成为一个问题。如果这些调用在一开始就是非阻塞的话那就更理想了。最后,如果我们能够像在阻塞代码中一样,以直接的风格编写非阻塞代码,那就更好了。比如这样:

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

这正是 Rust 的 async 抽象所提供的。不过在讲解它们在实践中如何工作之前,让我们稍微绕个远路来了解一下并行(parallelism)和并发(concurrency)的区别。

并行与并发

在上一章中,我们大致将并行和并发视为可以互换的概念。但现在我们需要更加精确地区分它们,因为它们的区别将在实际工作中显现出来。

思考一下不同的团队分割方法来开发一个软件项目。我们可以分配给一个个人多个任务,也可以每个团队成员各自负责一个任务,或者可以采用这两种方法的组合。

当一个个人在任何一个任务完成前同时处理多个任务,这就是 并发。你可能在计算机上同时运行两个项目,当你对其中一个项目感到厌倦或遇到困难时,可以切换到另一个项目。因为你是单独一个人,所以无法真正同时推进两个任务,但是你可以多任务处理,在不同任务之间切换以取得进展。

并发工作流
图 17-1:一个并发工作流,在任务 A 和任务 B 之间切换

当你同意将一组任务在组员中分配,每一个组员分配一个任务并单独处理它,这就是 并行。每个组员可以真正同时进行工作。

并发工作流
图 17-2:一个并行流,其中任务 A 和任务 B 的工作同时独立进行

在这两种场景中,你可能需要协调不同的任务。也许你 认为 某个人负责的任务与其他人的工作完全不相关,但实际上它确实依赖于团队中另一位成员的工作完成。一些工作可以并行进行,不过一些工作事实上是 串行 的:它们只能串行地发生,一个接着一个,如图 17-3 所示。

并发工作流
图 17-3:一个部分并行的工作流,其中任务 A 和任务 B 的工作相互独立,直到任务 A3 阻塞在等待任务 B3 的结果

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

并行与并发也可能相互交叉(阻塞)。如果你得知某个同事卡在等待你的一个任务完成,你可能会集中所有精力在这个任务上来 “解锁” 你的同事。你和你的同事则不再能并行地工作了,同时你也不能够并发地处理自己的任务。

同样的基础动态也作用于软件与硬件。在一个单核的机器上,CPU 一次只能执行一个操作,不过它仍然可以并发工作。借助像线程、进程和异步(async)等工具,计算机可以暂停一个活动,并在最终切换回第一个活动之前切换到其它活动。在一个有多个 CPU 核心的机器上,它也可以并行工作。一个核心可以做一件工作的同时另一个核心可以做一些完全不相关的工作,而且这些工作实际上是同时发生的。

当使用 Rust 中的 async 时,我们总是在处理并发。取决于硬件、操作系统和所使用的异步运行时(async runtime)-- 稍后会介绍更多的异步运行时!并发也可能在底层使用了并行。

现在让我们深入理解 Rust 的异步编程实际上是如何工作的!在接下来的章节中,我们将:

  • 学习如何使用 Rust 的 asyncawait 语法
  • 探索如何使用异步模型来解决第十六章中遇到的一些挑战
  • 了解多线程和异步如何互补,在很多场景中你甚至可以同时使用两者