卷 V · 并发CH 20深度 20/24

async/await:Future 是一台状态机

异步是很多人被 Rust 劝退的「第二关」—— FuturePin.await、执行器…… 一堆新词。但只要抓住一个核心图景,全都通了:一个 async fn,是编译器把你的函数改写成的一台状态机,每个 .await 是一个可以暂停、之后再续上的断点。看懂这台状态机怎么单步走,异步就不再玄了。

async / awaitFuture状态机executor

先分清:async 不是多开线程

上两章的并发是线程:真的开好几条执行流,由操作系统调度,适合 CPU 密集的活。但网络服务的瓶颈通常不是 CPU,而是等待 —— 等数据库、等下游 API、等磁盘。一条线程在那儿干等一个网络响应,是巨大的浪费(线程本身也不便宜,每条要几十上百 KB 栈)。开一万个连接就开一万条线程?扛不住。

async 解决的正是这个:让一条线程在「等」的间隙,去照看别的任务。等 A 的网络响应时,先去推进 B、C、D…… 谁的数据到了就回来接着做谁。于是一条线程能同时照看成千上万个任务。这不是靠多开线程,而是靠让每个任务能在等待处暂停、把线程让出来。而「能暂停、能续上」这个能力,就是这一章的全部。

async fn 会变成什么

当你写一个 async fn,它不会立刻执行,也不返回结果 —— 它返回一个 Future:一个「将来会算出值、但现在还没算」的东西。而这个 Future,是编译器把你的函数改写成的一台状态机

async fn handle() {
    let a = fetch_user().await;     // 断点 1:这里可能要等
    let b = fetch_posts(a).await;   // 断点 2:这里也可能要等
    render(b);
}

编译器看到两个 .await,就把这个函数拆成几个状态,每个 .await 是一个状态的边界:

// 编译器生成的状态机(示意):
enum HandleState {
    Start,              // 还没开始
    AwaitingUser,       // 停在第一个 .await,等 fetch_user
    AwaitingPosts,      // 停在第二个 .await,等 fetch_posts
    Done,               // 完事
}

这台状态机有一个方法叫 poll():「推进一下,看能不能往前走」。它就是异步的心脏。

▶ 亲手 poll 这台状态机

下面就是上面那个 handle 编成的状态机。点 poll() 单步推进:能往前走(等的东西就绪了)就推进到下一个状态;走不动(还在等)就返回 Pending、把线程让出去。看它一格一格地走完,你就看懂了 .await 到底在干什么。

executor:谁在调 poll

状态机自己不会动,得有人反复调它的 poll() 推它往前 —— 这个「人」叫执行器(executor),来自异步运行时(最主流的是 tokio)。它的工作循环是:

  1. 调某个任务的 poll()
  2. 如果返回 Ready(值) —— 这个任务完成了,收工。
  3. 如果返回 Pending —— 它还在等(比如网络没回来)。执行器不傻等,转身去 poll 别的就绪任务,把这条宝贵的线程用满。
  4. 等那个被等的东西就绪了,一个叫 waker 的机制会「叫醒」这个任务,执行器再回来 poll 它,从上次暂停的状态接着往下走

这就是全部魔法了:.await = 「poll 到这里,如果没就绪就返回 Pending、让出线程;就绪了就取出值、推进到下一个状态」。一条线程靠这个机制,在无数个「暂停中的状态机」之间穿梭,谁就绪了推进谁 —— 上万个并发任务,就这么被一小撮线程照看着。异步省下的,就是那些本该「傻等」的时间。

◆ 一个容易忽略的点:Future 是懒的

let f = handle(); 这一行什么都不会发生 —— 它只是造出了那台状态机,一步都没走。必须有人 .await 它、或把它交给执行器,poll 才开始被调用。这和 JavaScript 很不一样(JS 的 Promise 一创建就开始跑)。Rust 的 Future 不 poll 就不动,这叫「惰性」。忘了 await 一个 future,它就静静地什么都不做 —— 编译器通常会警告你「这个 future 没被使用」。

诚实说:async 确实有它的坎

不骗你,Rust 的 async 是这门语言里公认较难的部分,被劝退第二次很正常。难点主要是:

  • 要引入一个运行时。Rust 标准库只定义了 Future 这个「接口」,不自带执行器 —— 你得引入 tokio 之类的库来 poll 它。这是刻意的(Rust 要能用在没有操作系统的嵌入式上),但对新手是个额外台阶。
  • .await 的所有权与 Send。状态机要在 .await 处「把局部变量存起来、下次续上」,于是那些跨越 await 点的变量,都成了状态机的字段 —— 它们的所有权、借用、以及能不能跨线程(Send),会变得比同步代码更讲究。你会撞上「借用不能活过 await 点」「future 不是 Send」这类报错。好消息是:它们全是你前面已经学过的所有权 / 借用 / Send 规则,只是在状态机的语境里再演一遍。地基打得牢,这一关就没那么可怕。
  • 函数染色。async 有点「传染」:调 async 函数一般得在 async 上下文里。这不是 Rust 独有(JS、Python、Kotlin 的挂起函数都有类似性质)。

但抓住那台状态机的核心图景,这些坎就从「玄学」变成了「具体的、可查的规则」。而且日常写业务时,tokio + .await 的手感其实相当顺 —— 你多数时候就是加个 .await,享受着底层那台状态机替你腾出的线程。

回流

⟲ 回流 · 「状态机」这个把戏,你的语言也在用

Kotlin 协程:如果你写过 Kotlin 的 suspend 函数,那你已经见过这台状态机了 —— Kotlin 编译器把 suspend 函数编译成的,正是一台以每个挂起点为状态的状态机(那个 Continuation 就是它的把手)。「挂起 = 保存状态、让出线程;恢复 = 从状态续上」的机制,Kotlin 协程和 Rust async 是同一个设计。区别是 Kotlin 的挂起点是隐式的(调 suspend 函数就是),Rust 的用显式 .await 标出来;Kotlin 自带调度器,Rust 让你选运行时。你写 Kotlin 协程的直觉,八成能迁移过来。

JavaScript async/await:语法几乎一样,但模型有别 —— JS 是单线程 + 事件循环,async 只是在一个线程上交错任务;Rust 的执行器可以是多线程的(tokio 默认用一个线程池 poll 任务)。而且 JS 的 Promise 是急切的(创建即执行),Rust 的 Future 是惰性的(poll 才执行)。同样的 async/await 关键字,底下的引擎不太一样。

Go:Go 走了另一条路 —— goroutine 是「有栈协程」,由运行时调度,你写同步风格的代码,运行时替你在 I/O 处切换,没有 async/await 的染色。代价是每个 goroutine 有自己的栈、且必须有 Go 运行时。Rust 的「无栈状态机」更省内存、能跑在裸机上,代价是那套显式的 async 机制。两种取舍,没有绝对优劣。

这一章的一句话

async fn 不是线程,是编译器把你的函数改写成的状态机;.await 是能暂停、能续上的断点。执行器反复 poll 它 —— 能走就走,走不动就返回 Pending 让出线程去照看别的任务。一条线程于是能同时照看上万个任务,省下的是「傻等」的时间。

并发这一卷收官了。你应该已经感受到那个反复出现的主题:Rust 的每一个「高级特性」,几乎都是前面地基的再一次应用。最后一卷,我们从语言本身抬起头,看怎么把这些拼成真正能交付的工程 —— 并给你一张「从 Kotlin 过来」的对照表和一份上路地图。