Rust 编译器与运行时揭秘

第9章 async/await:状态机的编译器变换

作者 杨艺韬 · 11,073 字

第9章 async/await:状态机的编译器变换

“async fn 不是语法糖——它是编译器替你写了一个你永远不想手写的状态机。这个状态机的每一个字节都经过精确计算,不多也不少。”

本章要点

  • async fn 经历三个编译阶段:HIR 脱糖.awaitloop + yield)、MIR 生成(yield → Yield terminator)、协程变换StateTransform 将函数体重写为状态机)
  • 状态机是一个多变体联合体,每个挂起点对应一个变体,只存储跨越该挂起点的活跃变量
  • 编译器通过 MaybeLiveLocalsMaybeBorrowedLocalsMaybeRequiresStorage 三重数据流分析精确计算需要保存的变量
  • 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();
                    });
                }
            }
        });
    });
}

回调模型的根本问题是:局部变量的生命周期跨越回调边界时需要手动管理,正常的控制流语句(forif-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_loweringasync 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.rsmake_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,如果返回 Pendingyield 让出控制权。外部执行器再次 resume 时,协程从 yield 点恢复继续循环。

_task_context 通过 yield/resume 传递。协程每次被 resume 时收到新的 ContextContext 包含用于唤醒任务的 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_awaitStateTransform 的文件全景

本章到此出现了多次”编译器做了某事”的说法,但真正读进 rust-lang/rust 仓库,你会发现 async 变换横跨四个 crate 的十余个文件。把它们按调用顺序排成一张表,比零散地”某个函数做某事”要可校对得多:

阶段crate / 文件关键函数 / 类型作用
AST 构建rustc_parse/src/parser/expr.rsparse_expr_dot_suffix(处理 .await 语法)识别 .await 为后缀运算符,生成 ExprKind::Await
AST→HIRrustc_ast_lowering/src/expr.rslower_expr_await / make_lowered_await.await 脱糖为 loop+match+yield;见 §9.3 展示的那段代码
AST→HIRrustc_ast_lowering/src/item.rslower_maybe_coroutine_body / lower_async_fn_bodyasync fn foo() { body } 整体包装为 async move { body } coroutine 闭包
AST→HIRrustc_ast_lowering/src/lib.rsCoroutineKind::Async { .. } 分支标记协程类型(async vs gen vs async-gen),后续 MIR 需要这信息区分 Yield 语义
HIR→THIR→MIRrustc_mir_build/src/build/expr/as_rvalue.rsExprKind::Yield 的 MIR 构建yield () 变成 TerminatorKind::Yield { value, resume, resume_arg, drop }
MIR 变换rustc_mir_transform/src/coroutine.rsStateTransformMirPass impl @ line 1463)+ 六个 helper本章 §9.5 的主角;2011 行单文件
MIR 变换rustc_mir_transform/src/coroutine/drop.rscreate_coroutine_drop_shim§9.10 讨论的 drop shim 生成
MIR 变换rustc_mir_transform/src/coroutine/by_move_body.rscoroutine_by_move_body_def_id§9.11 的 async 闭包 by-move 体处理
布局查询rustc_middle/src/mir/query.rsCoroutineLayout<'tcx>(5 字段)§9.7.1 展开的存储结构
类型定义rustc_type_ir/src/ty_kind.rsTyKind::Coroutine(did, args) + CoroutineArgs协程在类型系统里的身份证——每个 async fn 都产生一个匿名的 Coroutine 类型
运行时契约library/core/src/future/future.rsFuture::poll 签名§9.4 那段 fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>
运行时绑定library/core/src/task/wake.rsContextWakerRawWakerRawWakerVTable§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 里的契约只有 Future trait + Context/Waker + Poll 枚举,不含任何调度实现——所以 tokio、async-std、smol 才能互相替换:编译器只负责生成”会调 Future::poll” 的状态机,至于谁来 poll、从哪个线程 poll,运行时自己说了算。这条边界,比第 7 章 trait dispatch 里的 trait object 契约更干净——那里至少还有一个 vtable 的物理布局规范,这里连物理布局都留给了执行器。

读源码时的一个典型跳转路径——如果你在终端里 rg 'TyKind::Coroutine' compiler/,会看到三十多个匹配点遍布 rustc_mir_transformrustc_trait_selectionrustc_borrowckrustc_hir_analysisrustc_const_eval。这意味着”async 变换”并不是一条单向流水线、而是一个横向弥散在编译器所有中后期阶段的类型——每个阶段都要问”这个类型是不是协程?是的话我要不要特殊处理?” 这种横向耦合是 Rust async 实现复杂度的隐藏来源,也是为什么稳定 async trait 用了七年——每引入一种新的协程变体(async gen、async closure、async drop)都要在十几个地方同时打补丁。

9.5 StateTransform:核心变换的六个步骤

StateTransform::run_passcoroutine.rs 第 1463 行)是整个变换的入口:

步骤一:ResumeTy 消除

transform_async_contextResumeTy 替换为 &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}urlresponse 已不需要)。

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.rsRESERVED_VARIANTS 常量背后的真实含义,也是调试 async future 时 p *self 能立刻看出”它卡在第几个 await” 的物理依据。

三条保留状态 + N 条挂起状态

语义进入条件退出方向
0UNRESUMEDimpl Future 的 future 刚被构造、还没 poll 过;或者 coroutine 刚 create首次 poll 时 switch 把 PC 跳到 START_BLOCK,然后状态会被覆盖为 3+ 或 1
1RETURNED函数体运行到 ReturnPoll::Ready(val) 已返回;或 coroutine return再次 poll 会走到 panic_block——重复 poll 已完成的 future 是未定义行为,编译器用 panic 防御
2POISONEDpoll 过程中 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.rsRESERVED_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 moveSend/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>——每个变体对应源码里的哪一个 .awaityield。调试器里看到 “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 内部”数据结构里什么参与类型系统什么不参与”的清晰标注。

  • CoroutineSavedLocaldebug_format = "_s{}" 通过 newtype_index! 宏给出——这是为什么 -Zdump-mir 输出里 async fn 的变量名是 _s0, _s1newtype_index! 是 rustc 内部大量使用的类型安全 index 宏、不同语义 index(如 BasicBlock vs Local vs Place)各自有独立类型、不会混淆传参。

理解了这 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

核心流程:

  1. 执行器从队列取出任务,调用其 poll 方法
  2. 状态机根据判别式跳转到恢复点,继续执行
  3. 到达 .await 点,poll 子 future
  4. 子 future 返回 Pending,它已在反应器中注册了 Waker
  5. 状态机保存状态,返回 Poll::Pending
  6. 执行器挂起任务,处理其他任务
  7. I/O 就绪时,反应器调用 Waker::wake()
  8. 执行器将任务重新入队,回到步骤 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
}

在挂起点,datar 都是活跃变量,被保存到状态机结构体中。问题是: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(SendSyncUnpinUnwindSafe 等)的推导会透明穿过协程类型、落到 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 里”把大对象在 .awaitdrop 掉” 建议的类型系统版本——同一条优化既影响大小、也影响 auto-trait。

“意外失败”的两类典型

  1. MutexGuard 跨 await——std::sync::MutexGuard<T>!Send(因为 std::sync::Mutex 的实现不允许跨线程释放锁)。如果你 let g = m.lock().unwrap(); some.await; drop(g);、witness 包含 MutexGuard、future 不是 Sendtokio::spawn 失败。解决办法是换 tokio::sync::Mutex(其 guard 是 Send)、或者确保锁的作用域不跨 .await
  2. 裸指针跨 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.rscoroutine_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 asyncGo goroutineJavaScript asyncC# async
状态存储编译期枚举(栈上)动态栈(2KB-1GB)堆分配 Promise堆分配状态机类
变量保存只保存活跃变量整个栈帧闭包捕获编译器分析
堆分配无(除非 Box::pin)自动管理每个 Promise每个 async 方法
取消drop 即取消context + channelAbortControllerCancellationToken
大小可预测是(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.rs2011全部 StateTransform 主流程——TransformVisitor (line 188) + LivenessInfo (678) + CoroutineSavedLocals (816) + StorageConflictVisitor (912) + EnsureCoroutineFieldAssignmentsNeverAlias (1698) + SuspendCheckData (1847);MirPass impl 在 line 1463
coroutine/drop.rs762§9.10 讨论的 drop shim 生成——每个状态变体的 drop 路径
coroutine/by_move_body.rs371by-move 闭包(async move || ...)的特殊处理
总计3144整个 async/coroutine 状态机变换的全部 MIR 通行

从源码注释里读到的”暂时设计”——coroutine.rs:569-587 的注释原文(实测 rustc 当前 master)——

The ResumeTy hides a &mut Context<'_> behind an unsafe raw pointer, and the get_context function is being used to convert that back to a &mut Context<'_>.

Ideally the async lowering would not use the ResumeTy/get_context indirection, 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 ResumeTy indirection for the time being, and that indirection is removed here.

这条注释揭示三件事——

  1. ResumeTy 是个 wrapper struct,里面藏了一个 unsafe 的 *mut () —— 在 HIR 层 .await 还在用它,到 MIR transform_async_context 才把它解开为真实的 &mut Context<'_>——本章 §9.5 步骤一”ResumeTy 消除”实际就是这一步
  2. 设计承认它不是最优——issue #105501 的”higher-kinded lifetime errors” 是它存在的根本原因——直接用 &mut Context<'_> 会让 trait solver 推不出某些 HKL 约束——这是**“完美设计 vs 编译器现状”妥协的一个真实案例**
  3. 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::pollPin<&mut Self> 自引用安全第 10 章 Pin/Waker/FuturePin 的类型系统完整证明、Unpin auto-trait、Pin::get_unchecked_mut 的 unsafe 义务
Future trait 作为运行时契约第 7 章 trait dispatchBox<dyn Future> 的 vtable 布局、poll 的静态分发 vs 动态分发的代价差异
impl Future 的匿名返回类型第 8 章 trait object为什么 impl Future 可以零成本但 dyn Future 要堆分配——协程类型是匿名具名类型,不满足 dyn-safe 的直接推论
每个 async fn 都生成匿名协程类型第 6 章 monomorphizationasync 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 章 unsafeRAII drop 语义延伸到挂起状态;drop shim 本身是一个编译器自动生成的 MirSource::DropShim,和用户写的 Drop impl 在 MIR 层并列
async fn 函数指针的 ABI第 16 章 LLVM codegencoroutine 最终仍然降到普通函数——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” 这一事实配合:

维度RustC#Kotlin
状态机生成时机MIR 层(优化后 IR)Roslyn CIL 层(高级 IL)前端 desugar 到字节码
状态表达匿名类型 + 独立 StateTransform pass编译器生成的 struct + IAsyncStateMachine 接口Continuation<T> 接口 + suspend 函数的 CPS 变换
是否走字节码虚拟机否、LLVM IR是、CLR是、JVM 或 LLVM (Native)
堆分配Box::pinspawnAsyncTaskMethodBuilder 有 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 从源码到状态机的完整变换路径。核心认知:

  1. 三阶段变换:HIR 脱糖 → MIR Yield → StateTransform 状态机重写
  2. 精确的活跃变量分析:三重数据流分析确保只保存必要的变量
  3. 编译期确定的大小:状态机可以栈上分配,这是零成本的基础
  4. poll + Waker 协议:将状态机、执行器、反应器三者解耦
  5. 取消即 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”措辞非常罕见的诚实。