异步编程基础:异步、等待、未来值与流
许多我们要求计算机执行的操作,都需要一段时间才能完成。在等待这些长时间运行的进程完成时,若我们能做一些其他事情,那就再好不过了。现代计算机提供了两种同时处理多个操作的技术:并行机制与并发操作。不过,一旦我们开始编写那些涉及并行或并发操作的程序时,我们很快就会遇到 异步编程 所固有的一些新挑战,即操作可能无法按照他们启动的顺序,依次完成。本章建立在第 16 章中使用线程的并行机制,及通过引入另一种异步编程方法: Rust 的 Futures、流与支持他们的 async
和 await
语法,以及在异步操作间进行管理及协调的一些工具,实现的并发上。
我们来举个例子。假设咱们要导出一段家庭庆祝活动的视频,这个操作可能需要几分钟到几小时不等。视频导出将尽可能多地用到其做能使用的 CPU 和 GPU。如果咱们只有一个 CPU 内核,而咱们的操作系统在导出完成前,不会暂停该导出 -- 也就是说,如果他 同步地 执行该导出,那么在该任务运行期间,咱们就无法在咱们的计算机上做任何其他事情。这将是非常令人沮丧的经历。幸运的是,咱们电脑的操作系统可以,而且也确实经常隐蔽地中断该导出,让咱们可同时完成其他工作。
现在,假设咱们正下载某个别人共享的视频,这也需要一段时间,但不会占用那么多 CPU 时间。在这种情况下,CPU 必须等待数据自网络到达。虽然数据开始到达后咱们就可以开始读取,但可能需要一些时间才能全部显示出来。即使数据全部都有了,但如果视频相当大,那么加载数据也可能需要至少一两秒才能全部加载。这听起来可能不算什么,但对于每秒可以执行数十亿次运算的现代处理器来说,这已经是很长的时间了。同样,在等待网络调用结束时,咱们的操作系统会隐形地中断咱们的程序,允许 CPU 执行其他工作。
视频输出是个 CPU 密集,CPU-bound 或 计算密集,compute-bound 操作的例子。他受限于计算机 CPU 或 GPU 的潜在数据处理速度,以及能将多少速度用于该操作。而视频下载则是个 IO 密集,IO-bound 操作的例子,因为他受计算机 输入及输出 速度的限制;他只能以通过网络发送数据的速度运行。
在这两个例子中,操作系统的隐形中断,the operating system's invisible interrupts,均提供了某种形式的并发性。不过,这种并发只发生在整个程序的层面上:操作系统会中断某个程序,以让其他程序完成工作。在很多情况下,由于我们对咱们程序的掌握,要比操作系统更细粒度,因此我们可以发现操作系统所无法看到的并发机会。
例如,若我们正开发某个管理文件下载的工具,我们应能将咱们的程序,编写成启动一次下载不会锁定用户界面,且用户应能同时启动多个下载。不过,多数操作系统与网络交互的 API,却都是 阻塞式的,blocking;也就是说,他们会阻塞程序的进程,直到他们正处理的数据完全就绪。
注意:仔细想想,这正是 大多数 函数调用的工作方式。不过,阻塞 一词通常保留给那些与文件、网络或计算机上其他资源交互的函数调用,因为在这些情况下,单个程序会从 非阻塞,non-blocking 的操作中受益。
通过生成一个专门下载各个文件的线程,我们可以避免阻塞咱们的主线程。不过,这些线程的开销,最终会成为问题。若调用一开始就不阻塞,那就更好了。此外,如果我们能采用与阻塞代码相同的直接写法,效果会更好,就像下面这样:
#![allow(unused)] fn main() { let data = fetch_data_from(url).await; println!("{data}"); }
这正是 Rust 的 async
(异步,asynchronous 的缩写)抽象所给到我们的。在本章中,咱们将学到有关 async
的所有知识,包括以下主题:
- 怎样使用 Rust 的
async
与await
语法; - 如何使用异步模型,解决我们在第 16 章中遇到的一些同样难题;
- 多线程和异步如何提供,咱们可在许多情况下结合使用的互补方案。
不过,在了解异步在实际中的工作原理前,我们需要先绕道讨论一下并行和并发之间的区别,the difference between parallelism and concurrency。
并行与并发机制
Parrallelism and Concurrency
到目前为止,我们都将并行和并发,看作是可以互换的。现在,我们需要更准确地区分他们,因为他们间的区别,会在我们开始工作时显现出来。
请设想某个团队在某个软件项目中,分工的不同方式。咱们可以给单个成员分配多项任务,也可以给每名成员分配一项任务,或者混合使用这两种方式。
当某单个成员在几项不同任务中的任何一项完成前,都工作于这些任务上时,这就是 并发。或许咱们在咱们电脑上,有着两个不同项目,当咱们在一个项目上感到无聊或卡住时,咱们就会切换到另一项目。咱们只是一个人,所以咱们无法同一时间在两项任务上取得进展,但咱们可以多任务处理,通过在两个任务之间切换,一次在一个任务上取得进展(见图 17-1)。
图 17-1:一种并发工作流程,在任务 A 和任务 B 之间切换
而在团队采取让每个成员单独完成某项任务方式,拆分一组任务时,这就是 并行。团队中的每个人,在同一时间团队中的每个人都能取得进展(见图 17-2)。
图 17-2:一种并行工作流程,其中任务 A 和任务 B 上的工作独立进行
在这两种工作流程中,咱们都可能需要协调不同任务。也许咱们 会以为 分配给某个人的任务,是完全独立于其他人工作的,但该任务实际上需要团队中另一人先完成他们的任务。有些工作可并行的完成,但有些工作实际上是 串行的,serial:其只能以序列方式进行,一项任务接着另一项任务,如图 17-3 所示。
图 17-3:一种部分并行的工作流程,任务 A 与任务 B 的工作独立进行,直到任务 A3 被任务 B3 的结果阻塞
同样,咱们也可能会发现,自己的一项任务依赖于另一项任务。现在,咱们的并行工作也变成串行的了。
并行与并发也会相互交叉。如果咱们得知某名同事在我们完成我们的某项任务前卡住了,咱们就可能会把所有精力都放在这项任务上,以 “解除” 该名同事的阻塞。这样,咱们和咱们的同事就无法再并行工作,咱们也无法再并发地执行咱们自己的任务。
软件和硬件的基本动态也是如此。在只有一个 CPU 核的机器上,CPU 一次只能执行一个操作,但其仍可并发地工作。利用线程、进程及异步等工具,计算机可暂停一项活动,并切换到其他活动,最后再次循环到第一项活动。在有着多个 CPU 核的机器上,计算机还可以并行工作。一个核心可以执行一项任务,而另一个核心可以执行完全无关的任务,这些操作实际上是在同一时间进行的。
在 Rust 中使用异步时,我们总是要处理并发问题。根据硬件、操作系统和我们所使用的异步运行时(稍后将详细介绍异步运行时),并发也可能在其表象之下,使用并行。
现在,我们来深入了解一下,Rust 中异步编程的工作原理。