17.1 Future 与 async 语法
讲解 Future、async/await 语法,并用运行时编写第一个异步程序
5 分钟阅读
译文 · 基于 The Rust Programming Language(rustc 1.97.1)
原文链接: https://doc.rust-lang.org/stable/book/ch17-00-async-await.html
我们让计算机执行的许多操作都可能耗时较长。若能在等待这些长时间任务完成时去做别的事,就再好不过了。现代计算机提供了两种同时推进多项工作的技术:并行(parallelism)与并发(concurrency)。然而,程序的逻辑大多仍按线性方式书写。我们希望能描述程序应执行哪些操作,以及函数可以在何处暂停、转而运行程序的其他部分,而不必事先精确规定每一段代码的运行顺序与方式。异步编程(asynchronous programming)正是这样一种抽象:用可能的暂停点与最终结果来表达代码,并把协调细节交给底层去处理。
本章在第 16 章用线程实现并行与并发的基础上,介绍另一种写法:Rust 的 Future、Stream,以及用于表达异步操作的 async 与 await 语法,还有实现异步运行时(runtime)的第三方 crate——运行时负责管理与协调异步操作的执行。
先看一个例子。假设你正在导出一段家庭庆祝活动的视频,这一过程可能耗时数分钟到数小时。视频导出会尽可能吃满 CPU 与 GPU。若只有一个 CPU 核心,且操作系统在导出完成前从不打断它——也就是说,导出是同步(synchronously)执行的——那么在这项任务运行期间你就无法在电脑上做任何别的事。那会非常令人沮丧。幸好,操作系统会频繁、无形地打断导出,好让你同时完成其他工作。
再假设你在下载别人分享的视频:同样可能较慢,但占用的 CPU 时间并不多。此时 CPU 需要等待网络数据到达。数据一开始到达就可以开始读取,但全部到齐可能还要一段时间。即便数据都齐了,若视频很大,完整加载也可能需要一两秒。听起来不长,但对每秒能完成数十亿次运算的现代处理器来说,这已经非常久了。同样,操作系统会无形地打断你的程序,好让 CPU 在等待网络调用完成时去做别的事。
视频导出是CPU 密集型(CPU-bound)或计算密集型(compute-bound)操作的例子:受限于 CPU/GPU 的数据处理能力,以及能分给该任务的份额。视频下载则是I/O 密集型(I/O-bound)操作:受限于计算机输入与输出的速度,只能和网络传数据一样快。
在这两个例子里,操作系统的无形打断提供了一种并发。不过这种并发只发生在整个程序这一层:操作系统打断一个程序,好让别的程序干活。很多时候,我们对程序的理解比操作系统更细粒度,因此能发现操作系统看不到的并发机会。
例如,若我们在做下载管理工具,就应写成启动一次下载不会卡住界面,且用户可以同时开始多次下载。但许多与网络交互的操作系统 API 是阻塞(blocking)的:也就是说,在它们处理的数据完全就绪之前,它们会挡住程序继续前进。
注意:细想之下,大多数函数调用其实都是这样工作的。不过,阻塞一词通常留给与文件、网络或计算机上其他资源交互的调用,因为正是在这些场景下,单个程序会从非阻塞操作中受益。
我们可以通过为每个文件派生专用线程来避免阻塞主线程。但那些线程占用的系统资源开销最终会成为问题。更理想的情况是调用本身就不阻塞,而是定义一批希望程序完成的任务,让运行时选择最佳的执行顺序与方式。
这正是 Rust 的 async(asynchronous 的缩写)抽象所提供的。本章将围绕以下主题介绍 async:
async 与 await 语法,并用运行时执行异步函数在实际看到 async 如何工作之前,我们需要先简短区分并行与并发。
到目前为止,我们大体把并行与并发当作可互换的概念。现在需要更精确地区分它们,因为开始动手后差异就会显现出来。
设想一个团队如何拆分软件项目中的工作。你可以让一个人同时领多项任务,也可以每人一项,或两者混用。
当一个人在任一任务完成前就在多项不同任务之间切换推进时,这就是并发。一种实现并发的方式,好比电脑上同时检出了两个项目:对一个项目感到无聊或卡住时,就切到另一个。你只有一个人,无法在同一瞬间同时推进两件事,但可以多任务切换,一次推进一项(见图 17-1)。
当团队把一组任务拆开,让每人单独负责一项时,这就是并行。团队中每个人可以在同一瞬间推进工作(见图 17-2)。
在这两种工作流里,你都可能需要在不同任务之间协调。也许你以为分给某人的任务与其他人完全无关,实际上却要等另一人先完成。有些工作可以并行,有些其实是串行(serial)的:只能一个接一个进行,如图 17-3。
同样,你也可能发现自己的某项任务依赖另一项任务。于是你的并发工作也变成了串行。
并行与并发还会彼此交织。若你得知同事卡在等你完成某项任务,你多半会全力投入该项任务以便“解封”对方。此时你们无法再并行工作,你也无法再并发地推进自己的其他任务。
软件与硬件中也有同样的基本动态。在只有单个 CPU 核心的机器上,CPU 一次只能执行一个操作,但仍然可以并发工作。借助线程、进程与 async 等工具,计算机可以暂停一项活动、切换到其他活动,最终再回到第一项。在多核机器上,还可以真正并行:一个核心做一件事,另一个核心做完全无关的另一件事,这些操作实际同时发生。
在 Rust 中运行 async 代码通常是并发的。取决于硬件、操作系统以及所用的异步运行时(稍后详述),这种并发在底层也可能用到并行。
接下来,让我们深入看看 Rust 中的异步编程究竟如何运作。
讲解 Future、async/await 语法,并用运行时编写第一个异步程序
用 async 任务、join 与消息传递解决与线程类似的并发问题
向运行时让出控制权,并组合 Future 构建 timeout 等异步抽象
介绍 Stream:随时间产生一系列值的异步序列,及其与迭代器的关系
深入 Future、Pin、Unpin 与 Stream trait,理解轮询与自引用
综合比较 Future、任务与线程,说明何时选用以及如何组合