异步编程基础:Async、Await、Futures 和 Streams
我们要求计算机执行的许多操作可能需要一段时间才能完成。如果在等待这些长时间运行的进程完成的同时,我们还能做其他事情,那将是非常好的。现代计算机提供了两种同时处理多个操作的技术:并行性和并发性。然而,我们的程序逻辑主要是以线性方式编写的。我们希望能够指定程序应执行的操作以及函数可以暂停的点,以及程序的其他部分可以替代运行的点,而无需事先精确指定每段代码的运行顺序和方式。异步编程是一种抽象,它让我们以潜在的暂停点和最终结果来表达代码,而它会为我们处理协调的细节。
本章在第16章使用线程实现并行性和并发性的基础上,介绍了编写代码的另一种方法:Rust的futures、streams以及async和await语法,这些让我们能够表达操作可以是异步的,以及实现异步运行时的第三方crate:管理并协调异步操作执行的代码。
让我们考虑一个例子。假设你正在导出一个你制作的家庭庆祝视频,这个操作可能需要几分钟到几小时不等。视频导出将使用尽可能多的CPU和GPU资源。如果你只有一个CPU核心,而你的操作系统没有在导出完成前暂停该导出——也就是说,如果它以同步方式执行导出——在该任务运行期间,你将无法在计算机上做任何其他事情。这将是一个相当令人沮丧的体验。幸运的是,你的计算机操作系统可以,并且确实经常无形地中断导出,以便让你同时完成其他工作。
现在假设你在下载其他人分享的视频,这可能也需要一些时间,但不会占用太多的CPU时间。在这种情况下,CPU必须等待数据从网络到达。虽然一旦数据开始到达,你可以开始读取数据,但所有数据完全到达可能需要一些时间。即使数据全部到达后,如果视频非常大,加载所有数据可能至少需要一两秒。这听起来可能不多,但对于现代处理器来说,这是一个非常长的时间,因为现代处理器每秒可以执行数十亿次操作。同样,你的操作系统会无形中中断你的程序,以允许CPU在等待网络调用完成时执行其他工作。
视频导出是一个CPU-bound或compute-bound操作的例子。 它受到计算机CPU或GPU潜在数据处理速度的限制, 以及它可以将多少速度用于该操作。视频 下载是一个I/O-bound操作的例子,因为它受到计算机输入和输出速度的限制; 它只能以数据在网络中传输的速度进行。
在这两个例子中,操作系统的不可见中断提供了一种并发形式。这种并发仅在整个程序的层面发生:操作系统中断一个程序以让其他程序完成工作。在许多情况下,由于我们对程序的理解比操作系统更细致,因此能够发现操作系统无法看到的并发机会。
例如,如果我们正在构建一个管理文件下载的工具,我们应该能够编写程序,使得启动一个下载不会锁定用户界面,并且用户应该能够同时启动多个下载。许多操作系统用于与网络交互的API是阻塞的;也就是说,它们会阻塞程序的进度,直到它们处理的数据完全准备好。
注意:这大多数函数调用的工作方式,如果你仔细想想。然而, 术语阻塞通常保留给与文件、网络或其他计算机资源交互的函数调用,因为这些情况下,单个程序将从非阻塞操作中受益。
我们可以通过创建一个专门的线程来下载每个文件,从而避免阻塞主线程。然而,这些线程所使用的系统资源开销最终会成为一个问题。如果调用本身不阻塞,而是我们可以定义一些我们希望程序完成的任务,并允许运行时选择运行这些任务的最佳顺序和方式,那将更好。
这正是 Rust 的 async(异步的缩写)抽象给我们的。在本章中,你将学习所有关于 async 的知识,我们将涵盖以下主题:
- 如何使用 Rust 的
async和await语法以及使用运行时执行异步函数 - 如何使用异步模型来解决我们在第16章中讨论的一些相同挑战
- 如何多线程和异步提供互补的解决方案,您可以在许多情况下结合使用。
在我们了解 async 在实践中如何工作之前,我们需要简要讨论一下并行性和并发性之间的区别。
并行性和并发性
我们迄今为止基本上将并行性和并发性视为可以互换的。现在 我们需要更精确地区分它们,因为这些差异将在我们开始工作时显现出来。
考虑团队在软件项目中分配工作的不同方式。 你可以分配给单个成员多个任务,给每个成员分配一个任务,或者 采用这两种方法的混合。
当一个人在完成任何一项任务之前就开始处理多个不同的任务时,这称为并发。实现并发的一种方式类似于在你的计算机上同时检出两个不同的项目,当你对一个项目感到厌倦或卡住时,就切换到另一个项目。你只是一个人,所以你不能同时在两个任务上取得进展,但你可以通过在它们之间切换,一次一个地取得进展(见图17-1)。
当团队通过让每个成员各自承担一个任务并单独工作来分配一组任务时,这称为并行性。团队中的每个人可以同时取得进展(见图17-2)。
在这两种工作流程中,你可能需要协调不同的任务。也许你认为分配给一个人的任务与其他人的工作完全独立,但实际上需要团队中的另一个人先完成他们的任务。有些工作可以并行完成,但有些实际上是串行的:只能按顺序进行,一个任务接一个任务,如图17-3所示。
同样,您可能会意识到自己的一个任务依赖于另一个任务。现在,您的并行工作也变成了串行。
并行性和并发性也可以相互交织。如果你得知一位同事在你完成某项任务之前无法继续工作,你可能会集中所有精力完成那项任务以“解除”同事的阻塞。你和你的同事不再能够并行工作,你也无法再同时处理自己的任务。
同样的基本动态也适用于软件和硬件。在单个CPU核心的机器上,CPU一次只能执行一个操作,但它仍然可以并发工作。通过使用线程、进程和异步等工具,计算机可以暂停一个活动并切换到其他活动,最终再回到第一个活动。在具有多个CPU核心的机器上,它还可以并行工作。一个核心可以执行一个任务,而另一个核心可以执行一个完全不相关的任务,这些操作实际上是同时发生的。
在 Rust 中运行异步代码通常会并发执行。根据硬件、操作系统以及我们使用的异步运行时(稍后会详细介绍异步运行时),这种并发可能在底层也会使用并行性。
现在,让我们深入探讨 Rust 中的异步编程实际上是如何工作的。