Rust 编译器与运行时揭秘
第9章 async/await:状态机的编译器变换
第9章 async/await:状态机的编译器变换
“async fn 不是语法糖——它是编译器替你写了一个你永远不想手写的状态机。这个状态机的每一个字节都经过精确计算,不多也不少。”
本章要点
async fn经历三个编译阶段:HIR 脱糖(.await→loop + yield)、MIR 生成(yield →Yieldterminator)、协程变换(StateTransform将函数体重写为状态机)- 状态机是一个多变体联合体,每个挂起点对应一个变体,只存储跨越该挂起点的活跃变量
- 编译器通过
MaybeLiveLocals、MaybeBorrowedLocals、MaybeRequiresStorage三重数据流分析精确计算需要保存的变量 - async 状态机大小在编译期完全确定——零成本异步的内存基础
- 自引用问题直接导致了 Pin 的诞生(第10章详述)
9.1 async 解决的问题:非阻塞 I/O 与回调地狱
操作系统提供两种 I/O 模型。阻塞 I/O 简单直观,但每个并发连接需要一个线程——线程的栈空间(几 KB 到几 MB)和上下文切换开销使这种模型在数万连接时不可行。非阻塞 I/O 让一个线程可以服务数万连接,但代价是代码的执行流被打碎成回调:
// 回调地狱:嵌套回调,错误处理困难,控制流丢失
fn handle(socket: Socket) {
socket.read_async(|data| { // 回调 1
socket.write_async(process(data), |result| { // 回调 2
match result {
Ok(_) => log("done"),
Err(e) => {
socket.write_async(error_page(e), |_| { // 回调 3
socket.close();
});
}
}
});
});
}
回调模型的根本问题是:局部变量的生命周期跨越回调边界时需要手动管理,正常的控制流语句(for、if-else、?)无法跨越回调使用。
async/await 的承诺是:写同步风格的代码,获得非阻塞的性能。每个 .await 点是函数可能挂起并让出控制权的位置,编译器自动生成保存和恢复状态的机制:
// async/await:看起来像同步代码,实际是非阻塞的
async fn handle(socket: Socket) -> Result<(), Error> {
let data = socket.read().await?; // 挂起点 1
socket.write(process(data)).await?; // 挂起点 2
Ok(())
}
// 使用正常控制流、? 操作符,局部变量自然跨越 .await
但这个承诺背后,编译器需要做大量工作。每个 .await 点函数可能暂停,所有活跃的局部状态必须被保存;恢复时必须被完整恢复。编译器自动生成的这个保存/恢复机制就是状态机。
9.2 变换全景:从源码到状态机
flowchart LR
A["源码<br/>async fn + .await"] -->|"AST Lowering<br/>rustc_ast_lowering"| B["HIR<br/>coroutine + loop/yield"]
B -->|"MIR Building<br/>rustc_mir_build"| C["MIR<br/>Yield terminators"]
C -->|"StateTransform<br/>rustc_mir_transform"| D["MIR'<br/>switch 状态机"]
style A fill:#3b82f6,color:#fff,stroke:none
style B fill:#8b5cf6,color:#fff,stroke:none
style C fill:#f59e0b,color:#fff,stroke:none
style D fill:#10b981,color:#fff,stroke:none
阶段一(HIR 脱糖):rustc_ast_lowering 将 async fn 标记为协程,每个 .await 展开为 loop { match poll() { Ready => break, Pending => yield } }。
阶段二(MIR 生成):HIR 的 yield 转换为 MIR 的 Yield terminator,标记挂起点。
阶段三(StateTransform):rustc_mir_transform/src/coroutine.rs 中的核心 pass,执行活跃变量分析、布局计算、MIR 重写、switch 分发插入和 drop shim 生成。
9.3 HIR 脱糖:.await 的真面目
在 compiler/rustc_ast_lowering/src/expr.rs 的 make_lowered_await 中,每个 .await 被展开为:
// expr.await 脱糖为:
{
let mut __awaitee = expr;
loop {
match unsafe {
Future::poll(
Pin::new_unchecked(&mut __awaitee),
get_context(_task_context), // ResumeTy -> Context
)
} {
Poll::Ready(result) => break result,
Poll::Pending => {
_task_context = yield (); // 让出控制权
}
}
}
}
这段脱糖揭示了几个关键事实:
每个 .await 变成 loop + match + yield。循环不断 poll 被等待的 future,如果返回 Pending 就 yield 让出控制权。外部执行器再次 resume 时,协程从 yield 点恢复继续循环。
_task_context 通过 yield/resume 传递。协程每次被 resume 时收到新的 Context,Context 包含用于唤醒任务的 Waker。
ResumeTy 是编译器内部的绕行设计。理想情况下应直接使用 &mut Context<'_>,但 Rust 的协程无法表达 for<'a, 'b> Coroutine<&'a mut Context<'b>> 这样的高阶生命周期(rust-lang/rust#68923)。编译器用 ResumeTy(内含 NonNull<Context<'static>> 裸指针)绕过限制,在后续 MIR 变换中再还原为 &mut Context<'_>。
注意 MatchSource::AwaitDesugar 这个标记——它告诉后续编译阶段这个 match 是 .await 脱糖产生的,而不是程序员手写的,这对错误信息和调试信息很重要。
HIR 的 yield 在 MIR 构建阶段转换为 Yield terminator:
// MIR 中的 Yield terminator
TerminatorKind::Yield {
value: Operand, // yield 出去的值(async 中为 ())
resume: BasicBlock, // 恢复时跳转的目标
resume_arg: Place, // 恢复时收到的值(Context)存放位置
drop: Option<BasicBlock>, // 在此挂起点被 drop 时的清理块
}
9.4 Future trait:poll 协议
// library/core/src/future/future.rs
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
pub enum Poll<T> {
Ready(T), // 完成
Pending, // 未完成,已注册 Waker
}
poll 方法的签名包含三个精心设计的元素:
self: Pin<&mut Self>——接收者是被 pin 住的可变引用。Pin 保证 Self 在内存中的位置不会改变,这对 async 状态机至关重要,因为状态机内部可能包含自引用(详见 9.9 节和第10章)。
cx: &mut Context<'_>——包含 Waker,future 返回 Pending 时必须保存 Waker 的克隆。底层 I/O 事件就绪时,通过 Waker::wake() 通知执行器重新 poll。
Poll<Self::Output>——Ready(T) 表示完成,Pending 表示未完成。Future 一旦返回 Ready,不应再被 poll。
Waker 的内部是一个裸指针加虚函数表:
pub struct RawWaker {
data: *const (), // 执行器特定的数据
vtable: &'static RawWakerVTable, // clone/wake/wake_by_ref/drop
}
这使 Future trait 与具体执行器实现完全解耦——future 不需要知道它被 tokio、async-std 还是自定义执行器驱动。
Future 的一个关键特性是惰性(inert)。创建一个 future 不会开始执行,只有被 poll 时才推进。这与 JavaScript 的 Promise(创建即执行)形成鲜明对比。
stateDiagram-v2
[*] --> Unresumed : 创建 Future
Unresumed --> Suspend1 : poll, await 1 Pending
Unresumed --> Returned : poll, 所有 await Ready
Suspend1 --> Suspend1 : poll, await 1 仍 Pending
Suspend1 --> Suspend2 : poll, await 1 Ready, await 2 Pending
Suspend1 --> Returned : poll, 后续都 Ready
Suspend2 --> Suspend2 : poll, await 2 仍 Pending
Suspend2 --> Returned : poll, await 2 Ready
Suspend1 --> Poisoned : panic
Suspend2 --> Poisoned : panic
Returned --> [*]
硬编码状态值:0 = UNRESUMED,1 = RETURNED,2 = POISONED,3+ = 用户挂起点。
9.4.1 async lowering 源码账本:从 lower_expr_await 到 StateTransform 的文件全景
本章到此出现了多次”编译器做了某事”的说法,但真正读进 rust-lang/rust 仓库,你会发现 async 变换横跨四个 crate 的十余个文件。把它们按调用顺序排成一张表,比零散地”某个函数做某事”要可校对得多:
| 阶段 | crate / 文件 | 关键函数 / 类型 | 作用 |
|---|---|---|---|
| AST 构建 | rustc_parse/src/parser/expr.rs | parse_expr_dot_suffix(处理 .await 语法) | 识别 .await 为后缀运算符,生成 ExprKind::Await |
| AST→HIR | rustc_ast_lowering/src/expr.rs | lower_expr_await / make_lowered_await | .await 脱糖为 loop+match+yield;见 §9.3 展示的那段代码 |
| AST→HIR | rustc_ast_lowering/src/item.rs | lower_maybe_coroutine_body / lower_async_fn_body | 把 async fn foo() { body } 整体包装为 async move { body } coroutine 闭包 |
| AST→HIR | rustc_ast_lowering/src/lib.rs | CoroutineKind::Async { .. } 分支 | 标记协程类型(async vs gen vs async-gen),后续 MIR 需要这信息区分 Yield 语义 |
| HIR→THIR→MIR | rustc_mir_build/src/build/expr/as_rvalue.rs 等 | ExprKind::Yield 的 MIR 构建 | yield () 变成 TerminatorKind::Yield { value, resume, resume_arg, drop } |
| MIR 变换 | rustc_mir_transform/src/coroutine.rs | StateTransform(MirPass impl @ line 1463)+ 六个 helper | 本章 §9.5 的主角;2011 行单文件 |
| MIR 变换 | rustc_mir_transform/src/coroutine/drop.rs | create_coroutine_drop_shim | §9.10 讨论的 drop shim 生成 |
| MIR 变换 | rustc_mir_transform/src/coroutine/by_move_body.rs | coroutine_by_move_body_def_id | §9.11 的 async 闭包 by-move 体处理 |
| 布局查询 | rustc_middle/src/mir/query.rs | CoroutineLayout<'tcx>(5 字段) | §9.7.1 展开的存储结构 |
| 类型定义 | rustc_type_ir/src/ty_kind.rs | TyKind::Coroutine(did, args) + CoroutineArgs | 协程在类型系统里的身份证——每个 async fn 都产生一个匿名的 Coroutine 类型 |
| 运行时契约 | library/core/src/future/future.rs | Future::poll 签名 | §9.4 那段 fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> |
| 运行时绑定 | library/core/src/task/wake.rs | Context、Waker、RawWaker、RawWakerVTable | §9.4 的裸指针+vtable 组合;和 trait object(第 8 章)不同的 vtable 布局:手写四函数指针、不含 Drop 之外的自省字段 |
几条”看行数就知道难度”的观察——
coroutine.rs单文件 2011 行与前面几章谈的trait_selection动辄上万行比显得克制,但它承担了”从 HIR 生成的线性 MIR 重建成状态机”这一把编译器风格反写回源代码结构的任务;而borrow_check(第 3 章)那样的 1 万行主要是分析器逻辑——功能不同、代码密度也不同。rustc_ast_lowering里 async 相关的代码量远小于你想象的——make_lowered_await大约 100 行、加上lower_async_fn_body的包装层也就两三百行。真正繁琐的工作推迟到了 MIR 层。这符合 rustc 的一贯策略:HIR 层做结构性脱糖、MIR 层做数据流级变换,§9.2 的三阶段架构正是这一策略在 async 上的具体兑现。library/core里的契约只有Futuretrait +Context/Waker+Poll枚举,不含任何调度实现——所以 tokio、async-std、smol 才能互相替换:编译器只负责生成”会调Future::poll” 的状态机,至于谁来 poll、从哪个线程 poll,运行时自己说了算。这条边界,比第 7 章 trait dispatch 里的 trait object 契约更干净——那里至少还有一个 vtable 的物理布局规范,这里连物理布局都留给了执行器。
读源码时的一个典型跳转路径——如果你在终端里 rg 'TyKind::Coroutine' compiler/,会看到三十多个匹配点遍布 rustc_mir_transform、rustc_trait_selection、rustc_borrowck、rustc_hir_analysis、rustc_const_eval。这意味着”async 变换”并不是一条单向流水线、而是一个横向弥散在编译器所有中后期阶段的类型——每个阶段都要问”这个类型是不是协程?是的话我要不要特殊处理?” 这种横向耦合是 Rust async 实现复杂度的隐藏来源,也是为什么稳定 async trait 用了七年——每引入一种新的协程变体(async gen、async closure、async drop)都要在十几个地方同时打补丁。
9.5 StateTransform:核心变换的六个步骤
StateTransform::run_pass(coroutine.rs 第 1463 行)是整个变换的入口:
步骤一:ResumeTy 消除
transform_async_context 将 ResumeTy 替换为 &mut Context<'_>,消除 get_context 调用:
fn transform_async_context(tcx, body) -> Ty {
let context_mut_ref = Ty::new_task_context(tcx);
replace_resume_ty_local(tcx, body, CTX_ARG, context_mut_ref);
// 将每个 get_context(resume_ty) 调用替换为直接赋值
for bb in body.basic_blocks.indices() {
if let Call { func, .. } = &terminator.kind {
if def_id == get_context_def_id {
eliminate_get_context_call(&mut body[bb]);
}
}
}
}
步骤二:活跃变量分析
这是整个变换中最精细的部分。编译器需要精确回答:每个挂起点,哪些变量在恢复后还会被使用? 只有这些变量需要保存到状态机中。
locals_live_across_suspend_points 使用四种数据流分析:
fn locals_live_across_suspend_points(tcx, body, always_live, movable) -> LivenessInfo {
// 分析 1: 哪些变量的存储当前活跃(StorageLive/StorageDead 之间)
let storage_live = MaybeStorageLive::new(...).iterate_to_fixpoint(...);
// 分析 2: 哪些变量曾经被借用
let borrowed_locals = MaybeBorrowedLocals.iterate_to_fixpoint(...);
// 分析 3: 综合借用和存储需求
let requires_storage = MaybeRequiresStorage::new(...).iterate_to_fixpoint(...);
// 分析 4: 标准活跃性分析——变量在未来是否会被读取
let liveness = MaybeLiveLocals.iterate_to_fixpoint(...);
for each Yield terminator (suspend point) {
let mut live = liveness.get().clone();
// 关键区分:可移动 vs 不可移动协程
// 可移动协程:借用不能跨越挂起点(目标可能被移动)
// 不可移动协程:借用可以跨越挂起点,需保守处理
if !movable {
live.union(borrowed_locals.get());
}
// 最终活跃集 = 活跃性 ∩ 需要存储
live.intersect(requires_storage.get());
live.remove(SELF_ARG); // 协程自身不需要额外保存
}
}
具体例子展示分析的精确性:
async fn analysis_demo() -> u32 {
let a = compute_a(); // a: 创建
let b = compute_b(); // b: 创建
let c = compute_c(); // c: 创建
drop(b); // b: 已销毁
first_future.await; // 挂起点 1: 活跃 = {a, c, first_future}
// b 已 drop,不保存
let d = use_a(a); // a: 最后使用(如果非 Copy 则被消费)
second_future.await; // 挂起点 2: 活跃 = {c, d, second_future}
// a 不再需要,不保存
c + d
}
这个精确性直接影响状态机的大小。如果编译器粗暴地保存所有变量,状态机会不必要地膨胀。三重分析的交集保证了最小化保存集合。
步骤三:存储冲突与布局计算
compute_storage_conflicts 构建 NxN 位矩阵,记录哪些变量同时处于 StorageLive。不冲突的变量可以共享内存位置。
compute_layout 为每个挂起点创建一个变体,生成 CoroutineLayout:
// 协程结构体布局(概念)
struct Coroutine {
upvars..., // 捕获的外部变量
state: u32, // 判别式
// 以下是联合体——不冲突的变量共享内存
variant_3: { first_future, a }, // 挂起点 1
variant_4: { second_future, d }, // 挂起点 2
}
步骤四:MIR 重写
TransformVisitor 遍历函数体,执行三类重写:
impl MutVisitor for TransformVisitor {
// 1. 变量访问重写: _x → (*self).variant.field
fn visit_place(&mut self, place, ..) {
if let Some((ty, variant, idx)) = self.remap.get(place.local) {
replace_base(place, self.make_field(variant, idx, ty), self.tcx);
}
}
// 2. StorageLive/Dead 消除(已 remap 的变量)
fn visit_statement(&mut self, stmt, ..) {
if let StorageLive(l) | StorageDead(l) = stmt.kind && self.remap.contains(l) {
stmt.make_nop(true);
}
}
// 3. Yield → 设置状态 + Return
fn visit_basic_block_data(&mut self, block, data) {
if let Yield { value, resume, .. } = terminator.kind {
self.make_state(value, .., false, ..); // Poll::Pending
let state = RESERVED_VARIANTS + self.suspension_points.len();
data.statements.push(self.set_discr(state, ..));
data.terminator_mut().kind = Return;
}
if let Return = terminator.kind {
self.make_state(.., true, ..); // Poll::Ready(val)
data.statements.push(self.set_discr(RETURNED, ..));
}
}
}
步骤五:插入 switch 分发
create_coroutine_resume_function 在函数入口插入状态分发:
fn create_coroutine_resume_function(tcx, transform, body, ..) {
let cases = create_cases(body, &transform, Resume);
cases.insert(0, (UNRESUMED, START_BLOCK)); // 从头执行
cases.insert(1, (RETURNED, panic_block)); // panic
cases.insert(1, (POISONED, panic_block)); // panic
// 在 bb0 插入: switch(discriminant) -> cases
insert_switch(body, cases, &transform, unreachable);
}
每个恢复分支恢复 StorageLive 声明,传递 Context 参数,然后跳转到原始恢复点。
步骤六:参数转换与 drop shim
make_coroutine_state_argument_pinned 将参数类型从 Coroutine(按值)改为 Pin<&mut Coroutine>,与 Future::poll 的签名匹配。具体实现是在函数入口添加 unpinned = Pin::get_unchecked_mut(self),然后将所有 self 的使用替换为通过 unpinned 的解引用。
create_coroutine_drop_shim 生成析构函数——也是一个状态机,根据当前判别式执行不同的清理:
| 状态 | 清理动作 |
|---|---|
| 0(UNRESUMED) | 只 drop upvars(函数体还没开始执行) |
| 1(RETURNED) | 什么都不做(值已被移走) |
| 2(POISONED) | 什么都不做(已处于无效状态) |
| 3+(挂起状态) | drop 该挂起点的所有活跃变量,包括子 future |
drop shim 通过 elaborate_coroutine_drops 进行展开优化,确保按正确顺序 drop、处理部分初始化变量的 drop flag、以及 panic 安全性。
9.6 完整示例:逐步跟踪变换
async fn fetch_and_process(url: String) -> Result<String, Error> {
let response = http_get(&url).await?; // 挂起点 1
let body = response.text().await?; // 挂起点 2
Ok(body.to_uppercase())
}
HIR 脱糖后:函数体变为协程,每个 .await 变为 loop { match poll(__awaitee) { Ready => break, Pending => yield } },? 操作符保持不变。
MIR(变换前):包含两个 Yield terminator(bb3 和 bb8),分别对应两个 .await 点。活跃变量分析结果:挂起点 1 保存 {http_get_future, url},挂起点 2 保存 {text_future}(url 和 response 已不需要)。
MIR(变换后):
bb0 (switch 分发):
switchInt(discriminant(*self)) -> [
0: bb_start, // UNRESUMED
1: bb_panic, // RETURNED
2: bb_panic, // POISONED
3: bb_resume1, // 挂起点 1 恢复
4: bb_resume2, // 挂起点 2 恢复
]
bb_start: // 创建 http_get future,开始 poll ...
bb_suspend1: // Poll::Pending, discriminant=3, return
bb_resume1: // 恢复 StorageLive,跳回 poll 循环
bb_suspend2: // Poll::Pending, discriminant=4, return
bb_resume2: // 恢复 StorageLive,跳回 poll 循环
bb_done: // Poll::Ready(Ok(result)), discriminant=1, return
flowchart TD
subgraph before["变换前"]
A0["创建 future"] --> A1["poll http_get"]
A1 -->|Ready| A2["处理, 创建 text future"]
A1 -->|Pending| A3["yield ()"]
A3 -.->|resume| A1
A2 --> A4["poll text"]
A4 -->|Ready| A5["返回结果"]
A4 -->|Pending| A6["yield ()"]
A6 -.->|resume| A4
end
subgraph after["变换后"]
B0["switch(state)"]
B0 -->|"0"| B1["开始执行, poll"]
B0 -->|"3"| B2["恢复, poll http_get"]
B0 -->|"4"| B3["恢复, poll text"]
B1 -->|Pending| B4["state=3, return Pending"]
B1 -->|Ready| B5["poll text"]
B2 -->|Pending| B4
B2 -->|Ready| B5
B5 -->|Pending| B6["state=4, return Pending"]
B5 -->|Ready| B7["state=1, return Ready"]
B3 -->|Pending| B6
B3 -->|Ready| B7
end
style A3 fill:#f59e0b,color:#fff,stroke:none
style A6 fill:#f59e0b,color:#fff,stroke:none
style B0 fill:#3b82f6,color:#fff,stroke:none
style B7 fill:#10b981,color:#fff,stroke:none
9.6.1 降级后的 discriminant:一张只能单调前进的状态表
TransformVisitor 在 §9.5 步骤四把 Yield 改写成”set_discr(state); Return”、在 §9.5 步骤五又在入口插入 switchInt(discriminant)。两端配合,状态机的 discriminant 变成了整个运行时语义的控制面板。值得把这张表从字节层面完整过一遍——因为它既是 rustc_mir_transform/src/coroutine.rs 里 RESERVED_VARIANTS 常量背后的真实含义,也是调试 async future 时 p *self 能立刻看出”它卡在第几个 await” 的物理依据。
三条保留状态 + N 条挂起状态:
| 值 | 语义 | 进入条件 | 退出方向 |
|---|---|---|---|
| 0 | UNRESUMED | impl Future 的 future 刚被构造、还没 poll 过;或者 coroutine 刚 create 完 | 首次 poll 时 switch 把 PC 跳到 START_BLOCK,然后状态会被覆盖为 3+ 或 1 |
| 1 | RETURNED | 函数体运行到 Return,Poll::Ready(val) 已返回;或 coroutine return | 再次 poll 会走到 panic_block——重复 poll 已完成的 future 是未定义行为,编译器用 panic 防御 |
| 2 | POISONED | poll 过程中 panic、generate_poison_block_and_redirect_unwinds_there 插入的 unwind 路径把状态改成 2 | 再次 poll 同样 panic;drop shim 遇到 2 会什么都不做(值已经逻辑损坏) |
| 3..N+2 | 第 k 个挂起点(k=1..N) | Yield 执行、set_discriminant(k+2)、Return Poll::Pending | 下次 poll 时 switch 跳到 bb_resume_k、恢复 StorageLive、继续执行 |
为什么是 3 而不是其他数字——coroutine.rs 的 RESERVED_VARIANTS 是一个编译期常量 3,确保前 3 个变体名义上被 UNRESUMED/RETURNED/POISONED 占用。即使用户代码只有一个 .await、这三个变体也必须存在——drop shim 的 match arm 依赖它们的 index 编码。这和 §9.7 那段 variant_fields 表里”第 3 号变体开始装用户数据”是同一回事:variant index 0/1/2 物理存在、但 variant_fields[0..3] 的 IndexVec<FieldIdx, CoroutineSavedLocal> 都是空的——这些”哨兵变体”占据 tag 空间、不占据 field 空间。
单调前进的约束——状态 transition 从 UNRESUMED 开始,只能前进到挂起状态、从挂起状态前进到下一个挂起状态或 RETURNED、不能回退。POISONED 是单向陷阱、进入就不出来。这条约束在 MIR 上没有显式写出(switch 允许任意跳转),但 TransformVisitor 生成的代码物理上不会产生回退的 set_discriminant——因为每个 bb_resume_k 的出口只会进入后续代码路径,不会跳回 bb_resume_{k-1}。
discriminant 和布局的关系——CoroutineLayout::variant_fields[k] 记录第 k 个变体要保存哪些 saved local,而 storage_conflicts 矩阵决定这些 local 在物理字段上怎么重叠。当 poll 走到 yield 点、TransformVisitor 插入的 set_discriminant(k) 不是一条独立指令、而是和之前 §9.7.1 讲的 “把活跃 local 搬进 variant_k 的物理字段位”一起发生:赋值 + 设 tag,构成一个原子的 yield 动作。这一点和 C 的 tagged union 完全一致——C11 里写 u.kind = K_INT; u.data.int_val = 42; 必须同时更新 tag 和 data,rustc 生成的 MIR 本质上是这组语义的自动化版本。
和第 5 章内存布局的接口——这整张 discriminant 表其实就是第 5 章讲过的 niche optimization 的一个极端用例。state: u32 占 4 字节、但实际只用 log2(N+3) 位;一般不做 niche 优化因为保留变体用得少、且状态机总大小以字段布局为主、tag 大小不是瓶颈。但同一套 niche/tag/variant 机制在这里展现了它作为全局状态编码器的另一面——和 Option<&T>、Result<T, !> 的优化用的是同一个 Variants::Multiple 的 codegen 路径。
9.7 内存布局:编译期确定的大小
async fn 的返回类型是一个编译期确定大小的类型,这是零成本异步的核心。状态机大小公式:
size = size_of(upvars) + size_of(discriminant) + max(variant_3_size, variant_4_size, ...)
每个 variant 的大小等于该挂起点活跃变量大小之和。由于变体使用联合体布局(类似 C 的 union),总大小取决于最大的变体。
存储冲突与内存共享
compute_storage_conflicts 遍历所有程序点,构建 NxN 位矩阵记录哪些 saved locals 同时处于 StorageLive。不冲突的变量可以共享同一块内存:
async fn sharing() {
let a = [0u8; 512];
first.await; // 变体: {a, first}
use_a(a); // a 在此被消费
let b = [0u8; 512];
second.await; // 变体: {b, second}
use_b(b);
}
// a 和 b 的存储不冲突(不会同时活跃)→ 共享 512 字节
// 总大小 ≈ 512 + max(size_of(first), size_of(second)) + discriminant
// 而非 1024 + max(first, second) + discriminant
这比手写 enum 更紧凑——Rust 的 enum 不会自动做变体间的字段共享,但编译器生成的协程布局通过 CoroutineLayout 中的 storage_conflicts 矩阵实现了这一优化。
实践中的大小优化
理解了布局原理,可以有目的地优化 async fn 的大小:
// 未优化:buf 跨越 await,状态机增大 4096 字节
async fn unoptimized() {
let buf = [0u8; 4096];
some_future.await; // buf 在活跃集合中
use_buf(&buf);
}
// 优化:buf 在 await 前 drop
async fn optimized() {
let buf = [0u8; 4096];
use_buf(&buf);
drop(buf); // 显式 drop
some_future.await; // buf 不在活跃集合中!
}
// 另一种优化:用作用域限制生命周期
async fn scoped() {
{
let buf = [0u8; 4096];
use_buf(&buf);
} // buf 在此自然 drop
some_future.await; // buf 不在活跃集合中
}
检查 async fn 的大小:
use std::mem::size_of_val;
let fut = optimized();
println!("optimized size: {}", size_of_val(&fut));
// 比 unoptimized 小约 4096 字节
这就是为什么 Rust 的 async 不需要像 Go 的 goroutine 那样分配独立的栈——Future 的大小在编译期确定,可以直接放在调用者的栈帧上或 Box 在堆上的固定位置。
9.7.1 CoroutineLayout 真身:承载变体布局的五字段结构
storage_conflicts 是一个关键优化,但它只是 CoroutineLayout 这个结构体的一部分。完整的布局信息包含 5 个字段(rustc_middle/src/mir/query.rs:35):
// rustc_middle/src/mir/query.rs:35
pub struct CoroutineLayout<'tcx> {
/// The type of every local stored inside the coroutine.
pub field_tys: IndexVec<CoroutineSavedLocal, CoroutineSavedTy<'tcx>>,
/// The name for debuginfo.
pub field_names: IndexVec<CoroutineSavedLocal, Option<Symbol>>,
/// Which of the above fields are in each variant. Note that one field may
/// be stored in multiple variants.
pub variant_fields: IndexVec<VariantIdx, IndexVec<FieldIdx, CoroutineSavedLocal>>,
/// The source that led to each variant being created (usually, a yield or
/// await).
pub variant_source_info: IndexVec<VariantIdx, SourceInfo>,
/// Which saved locals are storage-live at the same time.
#[type_foldable(identity)]
#[type_visitable(ignore)]
pub storage_conflicts: BitMatrix<CoroutineSavedLocal, CoroutineSavedLocal>,
}
五个字段对应五层信息:
1. field_tys: IndexVec<CoroutineSavedLocal, CoroutineSavedTy>——每个被保存的局部变量的类型。这是状态机的”全集”:所有跨过任一挂起点的活跃变量全部登记。CoroutineSavedLocal 是一个 newtype index(debug_format = "_s{}"——打印成 _s0, _s1, _s2, ...,这是读 -Zdump-mir 输出时出现的神秘变量名的来源)。CoroutineSavedTy 除了类型还带 source_info(原始 MIR 里它从哪来)和 ignore_for_traits(后者是为 async move 的 Send/Sync 推导特别准备的——某些字段虽然保存但不参与 auto-trait 检查)。
2. field_names: IndexVec<CoroutineSavedLocal, Option<Symbol>>——仅用于 debuginfo。Option<Symbol> 表明有些 saved local 是编译器引入的临时值、没有用户可见名字。调试 async fn 时 gdb/lldb 里能看到 let r = ...、let data = ... 等原名就靠这张表。
3. variant_fields: IndexVec<VariantIdx, IndexVec<FieldIdx, CoroutineSavedLocal>>——每个挂起点变体包含哪些 field。注意源码注释原文:“one field may be stored in multiple variants”——这正是存储冲突分析的允许写入点:一个 CoroutineSavedLocal 在多个变体里出现不是 bug、只要它们不”同时活跃”。用 double-indexed vec 表达 [variant_idx][field_idx] = saved_local_id——物理布局上每个变体的 field 顺序由此决定。
4. variant_source_info: IndexVec<VariantIdx, SourceInfo>——每个变体对应源码里的哪一个 .await 或 yield。调试器里看到 “Suspended at line 42 in foo.rs” 靠的就是这张表。也给 futures-rs 那种生态工具(比如 tokio-console)提供了”当前 future 卡在哪里”的数据来源。
5. storage_conflicts: BitMatrix<CoroutineSavedLocal, CoroutineSavedLocal>——上一节讲的 N×N 位矩阵。实际上是对称矩阵(冲突是双向关系)、但 BitMatrix 在存储上不优化这个对称性——简单起见存满。位被设的位置表示两个 saved local 同时 storage-live、不能共享内存;未设的位置可以共享。编译器的布局算法(类似染色问题的贪心)在这张图上找最小覆盖的字段分组——把不冲突的 saved locals 塞进同一个物理字段位。
两个隐藏的类型级技巧:
-
#[type_foldable(identity)]和#[type_visitable(ignore)]标在storage_conflicts上——告诉TypeFoldable/TypeVisitable派生宏”这个字段不包含任何泛型类型、不需要遍历”。对BitMatrix<SavedLocal, SavedLocal>这种纯 bitset 来说、让类型折叠机制空过省下 O(N²) 的无用遍历。这是 rustc 内部”数据结构里什么参与类型系统什么不参与”的清晰标注。 -
CoroutineSavedLocal的debug_format = "_s{}"通过newtype_index!宏给出——这是为什么-Zdump-mir输出里 async fn 的变量名是_s0, _s1。newtype_index!是 rustc 内部大量使用的类型安全 index 宏、不同语义 index(如BasicBlockvsLocalvsPlace)各自有独立类型、不会混淆传参。
理解了这 5 个字段、size_of::<SomeAsyncFuture>() 背后的那个数字就不再是黑盒——它是 field_tys 决定的上限、variant_fields 决定的每变体 footprint、storage_conflicts 决定的共享节省三者共同算出的结果。这也是为什么 “async fn 的大小有时比预期小一半” 或 “有时一个数组字段让 future 胖了 4KB”——都能用这 5 个字段逐一定位原因。
9.8 执行器与反应器
Future 是惰性的——需要执行器(executor)驱动 poll,需要反应器(reactor)监听 I/O 事件并唤醒任务。
flowchart TD
subgraph Executor["执行器"]
EQ["任务队列"] -->|取出| EP["poll 循环"]
EP -->|Ready| ED["完成"]
EP -->|Pending| EW["等待 wake"]
end
subgraph Reactor["反应器"]
RE["epoll/kqueue"] -->|事件就绪| RW["Waker::wake()"]
end
EP -->|"调用 poll"| SM["状态机"]
SM -->|"Pending + 注册 Waker"| RE
RW -->|重新入队| EQ
style EQ fill:#3b82f6,color:#fff,stroke:none
style RE fill:#f59e0b,color:#fff,stroke:none
style SM fill:#8b5cf6,color:#fff,stroke:none
核心流程:
- 执行器从队列取出任务,调用其
poll方法 - 状态机根据判别式跳转到恢复点,继续执行
- 到达
.await点,poll 子 future - 子 future 返回
Pending,它已在反应器中注册了 Waker - 状态机保存状态,返回
Poll::Pending - 执行器挂起任务,处理其他任务
- I/O 就绪时,反应器调用
Waker::wake() - 执行器将任务重新入队,回到步骤 1
关键优势:没有线程阻塞。一个线程可以高效驱动成千上万个任务,因为每次 poll 要么快速推进后返回 Pending,要么计算出结果返回 Ready。
tokio 的多线程运行时使用工作窃取(work-stealing)调度器。每个工作线程有本地队列,还有全局共享队列。Waker::wake() 被调用时,任务放入调用者线程的本地队列或全局队列,空闲线程会被通知来处理。从 I/O 事件就绪到状态机被再次 poll 的延迟通常在微秒级。
9.9 自引用问题:为什么 async Future 不能移动
考虑这段看似无害的代码:
async fn self_ref() {
let data = vec![1, 2, 3];
let r = &data; // r 指向 data
some_future.await; // 两者都被保存到状态机
println!("{:?}", r); // 恢复后使用 r
}
在挂起点,data 和 r 都是活跃变量,被保存到状态机结构体中。问题是:r 是指向 data 的引用,而 data 存储在结构体内部——结构体内部有一个字段指向自身的另一个字段:
状态机在内存中(地址 0x1000):
┌─────────────────────────────────────────┐
│ discriminant: 3 │
│ data: Vec<i32> ────────────────┐ │ data 在 0x1008
│ r: &Vec<i32> ─────────────────►│ │ r 的值 = 0x1008
│ some_future: SomeFuture │ │
└─────────────────────────────────────────┘
如果结构体被移动到 0x2000:
┌─────────────────────────────────────────┐
│ discriminant: 3 │
│ data: Vec<i32> ────────────────┐ │ data 现在在 0x2008
│ r: &Vec<i32> = 0x1008 (悬垂!) │ │ r 仍指向旧地址!
│ some_future: SomeFuture │ │
└─────────────────────────────────────────┘
编译器通过两个层面处理这个问题:
类型系统层面:async fn 生成的 Future 类型不实现 Unpin。这意味着它只能通过 Pin<&mut Self> 来 poll——Pin 的合约保证被 pin 的值不会被移动。
MIR 分析层面:对不可移动协程(!Unpin),locals_live_across_suspend_points 中被借用的变量也被视为活跃:live.union(borrowed_locals.get())。这保证了自引用关系的完整性。
重要细节:async future 在第一次 poll 之前可以安全移动。此时状态为 UNRESUMED,内部只有 upvars,还没执行任何代码,不可能产生自引用。这就是 tokio::spawn(async { ... }) 能工作的原因——spawn 在 poll 前将 future 移动到堆上固定位置。
Pin 的完整类型系统设计和安全性证明见第10章。
9.9.1 witness 类型与 auto-trait 推导:为什么 Send/Sync 会”意外失败”
使用 async 时最常见的运行时困惑是”我这个 future 为什么不是 Send”——错误信息里出现一个陌生的词:generator / coroutine witness。理解它需要把 §9.5 的 saved locals 和 Rust 的 auto-trait 推导机制接起来。
什么是 witness 类型——rustc 为每个协程构造一个辅助类型 CoroutineWitness(did, args),它的”成员”是该协程的全部 saved locals(CoroutineLayout::field_tys 列出的那些)。这个类型没有运行时表示——它只存在于 trait 求解器的视角中、作为”协程内部持有的所有跨挂起点类型”的集合证据(witness)。auto-trait(Send、Sync、Unpin、UnwindSafe 等)的推导会透明穿过协程类型、落到 witness 上:如果 witness 的每个成员都是 Send、协程就是 Send;有一个不是、整个协程就不是 Send。
具体翻译到用户经验:
async fn a() -> String {
let rc = std::rc::Rc::new(1); // Rc<T>: !Send
let s = fetch().await; // 挂起点 1
format!("{}/{:?}", s, rc)
}
// witness = { Rc<i32>, FetchFuture, String, ... }
// Rc<i32> !Send → witness !Send → a() 返回的 Future !Send
// 所以 tokio::spawn(a()) 编译失败
关键区分——Rc::new(1) 在挂起点前后都活跃、才会被放入 saved locals;如果在 .await 前就 drop(rc)、它就不再是 saved local、不进 witness、future 就可以是 Send。这是 §9.7 里”把大对象在 .await 前 drop 掉” 建议的类型系统版本——同一条优化既影响大小、也影响 auto-trait。
“意外失败”的两类典型:
- MutexGuard 跨 await——
std::sync::MutexGuard<T>是!Send(因为std::sync::Mutex的实现不允许跨线程释放锁)。如果你let g = m.lock().unwrap(); some.await; drop(g);、witness 包含MutexGuard、future 不是Send、tokio::spawn失败。解决办法是换tokio::sync::Mutex(其 guard 是Send)、或者确保锁的作用域不跨.await。 - 裸指针跨 await——
*const T/*mut T不自动实现Send。如果一个async fn的局部变量保存了裸指针、并且跨.await、future 就不是Send。这个限制本来是好事(防止你把非线程安全的指针跨线程发),但经常让人迷惑——因为错误信息指向的是”你没看到的”witness、不是你写的代码。
ignore_for_traits 字段的作用——§9.7.1 列出的 CoroutineSavedTy 结构里有一个 ignore_for_traits: bool 字段。它是为少数逻辑上保存但不应参与 auto-trait 推导的 saved locals 准备的——比如某些被 async lowering 插入的临时值、或者已知在 yield 后立刻消亡的值。这是一个极窄的 escape hatch、只有编译器自己会设、用户写不出来。但存在本身说明 rustc 曾经遇到过”witness 推导过于保守”的场景、不得不手动标注豁免。读源码时看到这个字段、就知道它背后是一段”我们暂时没法精确推导、先给自己留条路”的妥协史。
9.10 取消:drop 即取消
Rust 的 async 取消模型优雅而简单:drop 一个 future 就是取消它。
async fn cancellation_example() {
let fut = Box::pin(long_running_task());
match tokio::time::timeout(Duration::from_secs(1), fut).await {
Ok(result) => println!("完成: {:?}", result),
Err(_) => {
println!("超时");
// fut 在这里被 drop——递归取消整个 future 树
}
}
}
编译器生成的 drop shim 根据当前状态执行清理(源自 coroutine.rs 注释):
- 状态 0(unresumed):drops the upvars
- 状态 1(returned)/ 2(poisoned):does nothing
- 其他挂起状态:drops all values in scope at the last suspension point
当 drop 处于挂起状态的 future 时,其持有的子 future 也被 drop,递归取消整个 future 树。
取消安全性的关键细节:
async fn cancel_safety() {
let guard = mutex.lock().await; // 获取锁
do_work().await; // 如果在这里被取消...
drop(guard); // ...这行代码不会执行
// 但!guard 的 Drop impl 仍会被 drop shim 调用
// MutexGuard 的析构函数释放锁——RAII 保护有效
}
RAII 类型的 Drop impl 会被 drop shim 正确调用。但如果清理逻辑需要 .await(异步清理),那么取消时这些异步清理代码不会被执行。Poisoned 状态(panic 时由 generate_poison_block_and_redirect_unwinds_there 设置)防止 double-drop。
9.11 async 闭包、async 块与递归
async 块
async { ... } 与 async fn 的底层机制完全相同——编译为状态机。区别在于语法位置和捕获方式:
// async fn: 整个函数体是状态机
async fn foo() -> u32 { bar().await + 1 }
// async 块: 在非 async 函数中创建 future
fn make_future(x: u32) -> impl Future<Output = u32> {
async move { // move 捕获 x 为 upvar
expensive(x).await
}
}
// 两者生成的状态机结构等价
async 闭包
async 闭包(Rust 2024 稳定)每次调用创建一个新的 Future 实例:
let closure = async |x: u32| { expensive(x).await };
let fut1 = closure(42); // 独立的状态机实例
let fut2 = closure(100); // 另一个独立实例
编译器中涉及 coroutine/by_move_body.rs 的 coroutine_by_move_body_def_id 处理捕获变量在每次调用时的移动语义。
递归 async fn
递归 async fn 无法编译——状态机包含自身导致大小无限:
// 编译错误!size = C + size → 无限
async fn factorial(n: u64) -> u64 {
if n <= 1 { 1 } else { n * factorial(n - 1).await }
}
// 状态机: { n: u64, inner: Factorial状态机 } → 大小递归,无有限解
解决方案:Box::pin 引入间接层,用固定 8 字节的指针替代内联存储:
fn factorial(n: u64) -> Pin<Box<dyn Future<Output = u64>>> {
Box::pin(async move {
if n <= 1 { 1 } else { n * factorial(n - 1).await }
})
}
// 状态机: { n: u64, inner: Box<dyn Future> } → 大小有限
这是 Rust async 中唯一必须堆分配的场景。
9.12 零成本异步的性能分析
”零成本”意味着什么
Rust 的 async/await 遵循零成本抽象原则:你只为使用的东西付费,手写无法做得更好。
- 无堆分配:状态机在栈上(除非
Box::pin)。Go 每个 goroutine 至少 2-8 KB 堆分配栈。 - 精确变量保存:只保存跨越
.await的活跃变量。手写状态机需要保存完全相同的集合。 - 无运行时调度开销:
poll是普通函数调用,没有虚分发、没有上下文切换。 - 内联友好:状态机是具体类型(非 trait object),LLVM 可内联、常量折叠、死代码消除。
| 特性 | Rust async | Go goroutine | JavaScript async | C# async |
|---|---|---|---|---|
| 状态存储 | 编译期枚举(栈上) | 动态栈(2KB-1GB) | 堆分配 Promise | 堆分配状态机类 |
| 变量保存 | 只保存活跃变量 | 整个栈帧 | 闭包捕获 | 编译器分析 |
| 堆分配 | 无(除非 Box::pin) | 自动管理 | 每个 Promise | 每个 async 方法 |
| 取消 | drop 即取消 | context + channel | AbortController | CancellationToken |
| 大小可预测 | 是(size_of) | 否(运行时动态) | 否 | 部分 |
实际的开销
“零成本”不等于”零开销”:
状态机大小受最大变体约束。如果某个 .await 点需要保存大量变量,整个状态机都会膨胀——即使其他 .await 点只保存很少变量。
switch 分发开销。每次 poll 读取判别式并分支跳转。2-3 个 .await 可忽略不计,但大量 .await 可能影响分支预测。
编译时间。数据流分析和布局计算在编译期完成,对大型 async 函数体或深度嵌套的 async 调用会增加编译时间。
对比手写状态机
编译器生成的状态机在逻辑上等价于手写版本,但可能更紧凑——编译器的联合体布局优化(存储冲突分析)使不冲突的变量共享内存,而手写 enum 不会自动做这个优化。
9.12.1 实测:rustc 状态机变换的真实代码量与一处源码注释承认的”暂时设计”
打开 rust-lang/rust 仓库 compiler/rustc_mir_transform/src/coroutine*——
| 文件 | 行 | 角色 |
|---|---|---|
coroutine.rs | 2011 | 全部 StateTransform 主流程——TransformVisitor (line 188) + LivenessInfo (678) + CoroutineSavedLocals (816) + StorageConflictVisitor (912) + EnsureCoroutineFieldAssignmentsNeverAlias (1698) + SuspendCheckData (1847);MirPass impl 在 line 1463 |
coroutine/drop.rs | 762 | §9.10 讨论的 drop shim 生成——每个状态变体的 drop 路径 |
coroutine/by_move_body.rs | 371 | by-move 闭包(async move || ...)的特殊处理 |
| 总计 | 3144 | 整个 async/coroutine 状态机变换的全部 MIR 通行 |
从源码注释里读到的”暂时设计”——coroutine.rs:569-587 的注释原文(实测 rustc 当前 master)——
The
ResumeTyhides a&mut Context<'_>behind an unsafe raw pointer, and theget_contextfunction is being used to convert that back to a&mut Context<'_>.Ideally the async lowering would not use the
ResumeTy/get_contextindirection, but rather directly use&mut Context<'_>, however that would currently lead to higher-kinded lifetime errors. See https://github.com/rust-lang/rust/issues/105501.The async lowering step and the type / lifetime inference / checking are still using the
ResumeTyindirection for the time being, and that indirection is removed here.
这条注释揭示三件事——
ResumeTy是个 wrapper struct,里面藏了一个 unsafe 的*mut ()—— 在 HIR 层.await还在用它,到 MIRtransform_async_context才把它解开为真实的&mut Context<'_>——本章 §9.5 步骤一”ResumeTy 消除”实际就是这一步- 设计承认它不是最优——issue #105501 的”higher-kinded lifetime errors” 是它存在的根本原因——直接用
&mut Context<'_>会让 trait solver 推不出某些 HKL 约束——这是**“完美设计 vs 编译器现状”妥协的一个真实案例** for the time being这句措辞——在生产编译器源码里出现”目前先这样”是非常罕见的诚实——证明 async lowering 不是终点、是演进中
一个补充测量——coroutine.rs 单文件 2011 行 + 7 个主结构体——比本章开头的”State Transform 是六个步骤”看起来温和得多——真实代码里只有 1 个 MirPass impl(line 1463)、其他 6 个步骤是这一个 pass 内部的 helper 函数链——这是为什么本章 §9.5 的”六个步骤”讲的是函数顺序、不是 pass 顺序。
9.12.2 跨章校对:async 状态机在本书其他 17 章里的位置
async 是 Rust 编译器里横向耦合最严重的特性——本章挑选了 StateTransform 这个最集中的入口讲、但这张状态机的每一个组成要素其实散布在前后多个章节。读到这里、把 async 拆回去对应本书其他章节,能把”async fn 看起来很特殊”这件事完整祛魅:
| 本章要点 | 对接章节 | 对接内容 |
|---|---|---|
Future::poll 的 Pin<&mut Self> 自引用安全 | 第 10 章 Pin/Waker/Future | Pin 的类型系统完整证明、Unpin auto-trait、Pin::get_unchecked_mut 的 unsafe 义务 |
Future trait 作为运行时契约 | 第 7 章 trait dispatch | Box<dyn Future> 的 vtable 布局、poll 的静态分发 vs 动态分发的代价差异 |
impl Future 的匿名返回类型 | 第 8 章 trait object | 为什么 impl Future 可以零成本但 dyn Future 要堆分配——协程类型是匿名具名类型,不满足 dyn-safe 的直接推论 |
| 每个 async fn 都生成匿名协程类型 | 第 6 章 monomorphization | async fn foo<T> 每个 T 都生成独立的 Coroutine(foo, T) 类型和独立的状态机——和泛型函数单态化共用 Instance 的同一套机制 |
CoroutineLayout + storage_conflicts 的字段重叠 | 第 5 章 memory layout | 与 enum 的 Variants::Multiple codegen 共用同一条路径;variant tag = discriminant 的 niche 优化在 §9.6.1 已展开 |
StateTransform 作为 MirPass impl | 第 15 章 MIR 优化 | MIR 通行框架(MirPass trait、run_pass、pass manager)的使用——StateTransform 是所有 MIR pass 里改动最结构化的一个(重写 CFG),和常规的 dead-code/const-prop 完全不同 |
活跃变量分析(MaybeLiveLocals 等) | 第 3 章 borrow checker + 第 15 章 | 借用检查器用同一套 iterate_to_fixpoint dataflow 引擎;§9.5 步骤二的四种分析和 borrowck 的 MaybeInitializedPlaces 走同一条 rustc_mir_dataflow 通路 |
drop shim(coroutine/drop.rs) | 第 2 章 ownership + 第 12 章 unsafe | RAII drop 语义延伸到挂起状态;drop shim 本身是一个编译器自动生成的 MirSource::DropShim,和用户写的 Drop impl 在 MIR 层并列 |
async fn 函数指针的 ABI | 第 16 章 LLVM codegen | coroutine 最终仍然降到普通函数——poll 是 regular Rust ABI 的函数、没有 LLVM coroutine intrinsic(C++ 那一套);这和 clang 走 llvm.coro.* intrinsic 路线完全相反,是 §9.12 对比表格未展开的一个深层差别 |
| async fn 大小受最大变体约束 | 第 5 章 memory layout | 每个 async 调用点的栈帧大小由最大状态决定——和普通函数”栈帧大小 = 局部变量最大同时存活集合”的计算规则同源,只是 async 把”同时”改成了”跨所有挂起点”;§9.7 的实测建议可以和第 5 章”尽量让大对象离开作用域”的建议合并阅读 |
读者动线建议——本章如果和 第 10 章 Pin 连着读、会把”Pin 为什么存在”从抽象安全性降到具体字节布局的层面(第 10 章的 self-referential 例子直接引用本章 §9.9 的内存图)。如果和 第 15 章 MIR 优化 对比读,会理解”StateTransform 不是优化、是 lowering”——大多数 MIR pass 是不改变程序语义的化简,而 StateTransform 是把 coroutine 语义翻译成普通 Rust 语义的结构性重写,本质上和前端的 desugar 是同类工作、只是写在了 MIR 层。
一个容易被忽略的接口——本章 §9.4.1 表格里 CoroutineArgs 这一项、和第 6 章讨论的 Instance + GenericArgs 是同一套机制:async fn 生成的协程类型实际上带有与宿主函数相同的泛型参数、每次单态化时 Coroutine(did, &[T1, T2, ...]) 里的 args 决定一个独立的状态机实例。这就是为什么 async fn serve<H: Handler>() 里换一个 H 就要重新跑一遍 StateTransform——不是语言设计决定、是底层类型模型决定。
和 C# / Kotlin 的对比再校对一次——§9.12 已经有一张生态层面的对比表、这里补一张编译器内部机制层面的对比,和 §9.4.1 的 “rustc 把 async 做在 MIR” 这一事实配合:
| 维度 | Rust | C# | Kotlin |
|---|---|---|---|
| 状态机生成时机 | MIR 层(优化后 IR) | Roslyn CIL 层(高级 IL) | 前端 desugar 到字节码 |
| 状态表达 | 匿名类型 + 独立 StateTransform pass | 编译器生成的 struct + IAsyncStateMachine 接口 | Continuation<T> 接口 + suspend 函数的 CPS 变换 |
| 是否走字节码虚拟机 | 否、LLVM IR | 是、CLR | 是、JVM 或 LLVM (Native) |
| 堆分配 | 仅 Box::pin 或 spawn | AsyncTaskMethodBuilder 有 box/unbox 优化、热路径可栈化 | Continuation 默认堆分配、unconfined 优化可减少 |
| 自引用安全 | Pin trait(类型系统) | 运行时检查 + GC 管理 | 运行时保证(GC) |
| 取消模型 | drop 即取消、递归清理 | CancellationToken 显式传递 | CoroutineContext + Job.cancel() |
这张表和 §9.12 那张(面向开发者视角)的差别——上表聚焦编译器做了什么、在哪一层做、为什么做在那一层。Rust 把状态机做到 MIR 层是因为它没有 GC 兜底、需要让 borrow checker + Drop 的常规机制在状态机上继续成立——而这些机制恰好都工作在 MIR 层;C# 把状态机做到高级 IL 是因为 CLR 的 GC/JIT 可以在运行时补救所有布局和生命周期问题;Kotlin 的 CPS 变换做到字节码是因为 JVM 已经有继续执行帧(continuation stack frame)的运行时支持、不需要编译器自己编码状态表。同一个概念、在不同运行时约束下选择了不同的实现层——这是读 rustc 源码时反复会见到的主题:Rust 因为缺 GC、所以把许多 managed 语言放在运行时的机制前移到了编译期(borrow check、drop 插入、状态机展开都是如此)。
9.13 总结与展望
本章揭示了 async fn 从源码到状态机的完整变换路径。核心认知:
- 三阶段变换:HIR 脱糖 → MIR Yield → StateTransform 状态机重写
- 精确的活跃变量分析:三重数据流分析确保只保存必要的变量
- 编译期确定的大小:状态机可以栈上分配,这是零成本的基础
- poll + Waker 协议:将状态机、执行器、反应器三者解耦
- 取消即 drop:编译器生成的 drop shim 保证任何状态下的安全清理
物理事实:rustc_mir_transform/src/coroutine.rs 单文件 2011 行 + drop.rs 762 + by_move_body.rs 371 = 3144 行的 MIR pass 完成本章讲的全部状态机变换;ResumeTy 在 line 569-587 的注释里明确承认是”暂时设计”(issue #105501 的 HKL 错误所迫)——在生产编译器源码里出现”for the time being”措辞非常罕见的诚实。