异步编程基础: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
语法 - 如何使用async模型解决我们在第16章中看到的相同挑战
- 多线程和async如何提供互补的解决方案,在许多情况下可以结合使用
在我们看到async在实践中如何工作之前,我们需要稍微绕道讨论并行性和并发性之间的区别。
并行性和并发性
到目前为止,我们将并行性和并发性视为基本可以互换的。现在我们需要更精确地区分它们,因为在我们开始工作时,这些差异将会显现出来。
考虑团队在软件项目上分配工作的不同方式。你可以为单个成员分配多个任务,为每个成员分配一个任务,或者混合使用这两种方法。
当一个人在完成任何任务之前处理多个不同的任务时,这就是_并发性_。也许你在计算机上检查了两个不同的项目,当你在一个项目上感到无聊或卡住时,你会切换到另一个项目。你只是一个人,所以你不能同时在两个任务上取得进展,但你可以多任务处理,通过切换任务一次在一个任务上取得进展(见图17-1)。
当团队通过让每个成员承担一个任务并单独工作时,这就是_并行性_。团队中的每个人可以同时取得进展(见图17-2)。
在这两种工作流中,你可能需要在不同任务之间进行协调。也许你_认为_分配给一个人的任务与团队中其他人的工作完全独立,但实际上它需要团队中的另一个人先完成他们的任务。有些工作可以并行完成,但有些工作实际上是_串行_的:它只能按顺序发生,一个接一个,如图17-3所示。
同样,你可能会意识到你的一个任务依赖于你的另一个任务。现在你的并发工作也变成了串行。
并行性和并发性也可以相互交叉。如果你了解到一个同事在你完成你的一个任务之前被卡住了,你可能会集中所有精力在该任务上以“解锁”你的同事。你和你的同事不再能够并行工作,你也不再能够并发地处理自己的任务。
同样的基本动态在软件和硬件中也适用。在具有单个CPU核心的机器上,CPU一次只能执行一个操作,但它仍然可以并发工作。使用线程、进程和async等工具,计算机可以暂停一个活动并切换到其他活动,最终再回到第一个活动。在具有多个CPU核心的机器上,它还可以并行工作。一个核心可以执行一个任务,而另一个核心执行一个完全不相关的任务,这些操作实际上同时发生。
在Rust中使用async时,我们总是在处理并发性。根据硬件、操作系统和我们使用的async运行时(稍后会详细介绍async运行时),这种并发性可能还会在底层使用并行性。
现在,让我们深入了解Rust中的异步编程实际上是如何工作的。