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

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

让我们考虑一个例子。假设你正在导出一个你制作的家庭庆祝视频,这个操作可能需要几分钟到几小时不等。视频导出将使用尽可能多的CPU和GPU资源。如果你只有一个CPU核心,而你的操作系统没有在导出完成前暂停该导出——也就是说,如果它以同步方式执行导出——在该任务运行期间,你将无法在计算机上做任何其他事情。这将是一个相当令人沮丧的体验。幸运的是,你的计算机操作系统可以,并且确实经常无形地中断导出,以便让你同时完成其他工作。

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

视频导出是一个CPU-boundcompute-bound操作的例子。 它受到计算机在CPU或GPU中潜在数据处理速度的限制,以及可以分配给该操作的速度。视频 下载是一个IO-bound操作的例子,因为它受到计算机输入和输出速度的限制;它只能以数据 在网络中传输的速度进行。

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

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

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

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

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

这正是 Rust 的 async(异步的缩写)抽象给我们的。在本章中,你将学习所有关于 async 的知识,我们将涵盖以下主题:

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

在我们了解 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.
Figure 17-1: A concurrent workflow, switching between Task A and Task 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.
Figure 17-2: A parallel workflow, where work happens on Task A and Task B independently

在这两种工作流程中,你可能需要协调不同的任务。也许你认为分配给一个人的任务与其他人的工作完全独立,但实际上需要团队中的另一个人先完成他们的任务。有些工作可以并行完成,但有些实际上是串行的:只能按顺序进行,一个任务接一个任务,如图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.
Figure 17-3: A partially parallel workflow, where work happens on Task A and Task B independently until Task A3 is blocked on the results of Task B3.

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

并行性和并发性也可以相互交织。如果你得知一位同事在你完成某项任务之前无法继续工作,你可能会集中所有精力完成那项任务以“解除”同事的阻塞。你和你的同事不再能够并行工作,你也无法再同时处理自己的任务。

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

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

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