async/await:Future 是一台状态机
异步是很多人被 Rust 劝退的「第二关」—— Future、Pin、.await、执行器…… 一堆新词。但只要抓住一个核心图景,全都通了:一个 async fn,是编译器把你的函数改写成的一台状态机,每个 .await 是一个可以暂停、之后再续上的断点。看懂这台状态机怎么单步走,异步就不再玄了。
先分清: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():「推进一下,看能不能往前走」。它就是异步的心脏。
下面就是上面那个 handle 编成的状态机。点 poll() 单步推进:能往前走(等的东西就绪了)就推进到下一个状态;走不动(还在等)就返回 Pending、把线程让出去。看它一格一格地走完,你就看懂了 .await 到底在干什么。
executor:谁在调 poll
状态机自己不会动,得有人反复调它的 poll() 推它往前 —— 这个「人」叫执行器(executor),来自异步运行时(最主流的是 tokio)。它的工作循环是:
- 调某个任务的
poll()。 - 如果返回
Ready(值)—— 这个任务完成了,收工。 - 如果返回
Pending—— 它还在等(比如网络没回来)。执行器不傻等,转身去 poll 别的就绪任务,把这条宝贵的线程用满。 - 等那个被等的东西就绪了,一个叫 waker 的机制会「叫醒」这个任务,执行器再回来 poll 它,从上次暂停的状态接着往下走。
这就是全部魔法了:.await = 「poll 到这里,如果没就绪就返回 Pending、让出线程;就绪了就取出值、推进到下一个状态」。一条线程靠这个机制,在无数个「暂停中的状态机」之间穿梭,谁就绪了推进谁 —— 上万个并发任务,就这么被一小撮线程照看着。异步省下的,就是那些本该「傻等」的时间。
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 过来」的对照表和一份上路地图。