Rust 编译器与运行时揭秘

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

作者 杨艺韬 · 12,968 字 · 发布于 · 更新于

async fn 不是语法糖——它是编译器替你写了一个你永远不想手写的状态机。这个状态机的每一个字节都由编译器在编译期算定。

本章要点

  • async fn 经历三个编译阶段:HIR 脱糖(.await → loop + yield)、MIR 生成(yield → Yield terminator)、协程变换(StateTransform 将函数体重写为状态机)
  • 状态机是一个多变体联合体,每个挂起点对应一个变体,只存储跨越该挂起点的活跃变量
  • 编译器通过 MaybeStorageLive、MaybeBorrowedLocals、MaybeRequiresStorage、MaybeLiveLocals 四种数据流分析计算需要保存的变量
  • 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:#2563eb,color:#fff,stroke:none
    style B fill:#7c3aed,color:#fff,stroke:none
    style C fill:#f59e0b,color:#333,stroke:none
    style D fill:#10b981,color:#333,stroke:none

阶段一(HIR 脱糖):rustc_ast_lowering 将 async fn 标记为协程,每个 .await 展开为 loop { match poll() { Ready => break, Pending => yield } }。

阶段二(MIR 生成):HIR 的 yield 转换为 MIR 的 Yield terminator,标记挂起点。

阶段三(StateTransform):compiler/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 被展开为(lower_expr_await 的文档注释原文,伪 Rust):

// compiler/rustc_ast_lowering/src/expr.rs:832-846(注释中的 task_context 即 _task_context)
match ::std::future::IntoFuture::into_future(<expr>) {
    mut __awaitee => loop {
        match unsafe { ::std::future::Future::poll(
            <::std::pin::Pin>::new_unchecked(&mut __awaitee),
            ::std::future::get_context(task_context),   // ResumeTy -> &mut Context
        ) } {
            ::std::task::Poll::Ready(result) => break result,
            ::std::task::Poll::Pending => {}
        }
        task_context = yield ();                       // 让出控制权
    }
}

这段脱糖揭示了几个关键事实:

每个 .await 变成 loop + match + yield。被等待的表达式先经 IntoFuture::into_future 转成 future,再绑定到 __awaitee;循环不断 poll 它,返回 Ready 就 break,返回 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#105501)。编译器用 ResumeTy(内含 NonNull<Context<'static>> 裸指针)绕过限制,在后续 MIR 变换中再还原为 &mut Context<'_>。

注意 MatchSource::AwaitDesugar 这个标记——它告诉后续编译阶段这个 match 是 .await 脱糖产生的,而不是程序员手写的,这对错误信息和调试信息很重要。

HIR 的 yield 在 MIR 构建阶段转换为 Yield terminator:

// MIR 中的 Yield terminator(compiler/rustc_middle/src/mir/syntax.rs:899,省略 'tcx)
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:37(省略属性与文档注释)
pub trait Future {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

// library/core/src/task/poll.rs
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 的内部是一个裸指针加虚函数表:

// library/core/src/task/wake.rs:22(省略文档注释)
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 加 core 库的十余个文件。把它们按调用顺序排成一张表,比零散地"某个函数做某事"要可校对得多:

阶段 crate / 文件 关键函数 / 类型 作用
AST 构建 compiler/rustc_parse/src/parser/expr.rs parse_dot_suffix_expr → parse_dot_suffix → mk_await_expr(line 4129) 识别 .await 为后缀运算符,生成 ExprKind::Await
AST→HIR compiler/rustc_ast_lowering/src/expr.rs lower_expr_await / make_lowered_await .await 脱糖为 loop+match+yield;见 §9.3 展示的那段代码
AST→HIR compiler/rustc_ast_lowering/src/item.rs lower_maybe_coroutine_body(line 1450) 把 async fn foo() { body } 整体包装为 async move { body } coroutine 闭包
AST→HIR compiler/rustc_ast_lowering/src/lib.rs CoroutineKind::Async { .. } 分支 标记协程类型(async vs gen vs async-gen),后续 MIR 需要这信息区分 Yield 语义
HIR→THIR→MIR compiler/rustc_mir_build/src/builder/expr/into.rs(line 835) ExprKind::Yield 的 MIR 构建 yield () 变成 TerminatorKind::Yield { value, resume, resume_arg, drop: None }
MIR 变换 compiler/rustc_mir_transform/src/coroutine.rs StateTransform(MirPass impl @ line 1463)+ 六个 helper 本章 §9.5 的主角;2011 行单文件
MIR 变换 compiler/rustc_mir_transform/src/coroutine/drop.rs create_coroutine_drop_shim §9.10 讨论的 drop shim 生成
MIR 变换 compiler/rustc_mir_transform/src/coroutine/by_move_body.rs coroutine_by_move_body_def_id §9.11 的 async 闭包 by-move 体处理
布局查询 compiler/rustc_middle/src/mir/query.rs CoroutineLayout<'tcx>(5 字段) §9.7.1 展开的存储结构
类型定义 compiler/rustc_type_ir/src/ty_kind.rs(TyKind::Coroutine,line 217)+ ty_kind/closure.rs(CoroutineArgs,line 565) TyKind::Coroutine(CoroutineId, GenericArgs);状态常量 UNRESUMED/RETURNED/POISONED/RESERVED_VARIANTS 在 rustc_middle/src/ty/sty.rs:76-84 协程在类型系统里的身份证——每个 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 布局:只有 clone/wake/wake_by_ref/drop 四个手写函数指针,没有 size/align 字段

几条"看行数就知道难度"的观察——

  • coroutine.rs 单文件 2011 行与前面几章谈的 trait_selection 动辄上万行比显得克制,但它承担了"从 HIR 生成的线性 MIR 重建成状态机"这一把编译器风格反写回源代码结构的任务;而 rustc_borrowck(第 3 章;59 个 .rs 文件共 34458 行)主要是分析器逻辑——功能不同、代码密度也不同。
  • rustc_ast_lowering 里 async 相关的代码量远小于你想象的——make_lowered_await(expr.rs:853)连同 lower_maybe_coroutine_body(item.rs:1450)的包装层也就几百行。真正繁琐的工作推迟到了 MIR 层。这符合 rustc 的一贯策略:HIR 层做结构性脱糖、MIR 层做数据流级变换,§9.2 的三阶段架构正是这一策略在 async 上的具体兑现。
  • library/core 里的契约只有 Future trait + Context/Waker + Poll 枚举,不含任何调度实现——所以 tokio、async-std、smol 才能互相替换:编译器只负责生成"会调 Future::poll" 的状态机,至于谁来 poll、从哪个线程 poll,运行时自己说了算。这条边界,比第 8 章 trait object 的契约更干净——那里 vtable 由编译器按 trait 生成,这里 core 只规定了 RawWakerVTable 的四个函数指针,函数本身和调度语义全部由执行器提供。

读源码时的一个典型跳转路径——如果你在终端里 rg 'ty::Coroutine\(' compiler/,在 1.96.0 的源码树上会看到 220 多个匹配点、分布在 25 个以上的 rustc_* crate 里,rustc_mir_transform、rustc_trait_selection、rustc_borrowck、rustc_hir_analysis、rustc_const_eval 全都在列。这意味着"async 变换"并不是一条单向流水线、而是一个横向弥散在编译器所有中后期阶段的类型——每个阶段都要问"这个类型是不是协程?是的话我要不要特殊处理?" 这种横向耦合是 Rust async 实现复杂度的隐藏来源,也是为什么 async fn 从 1.39.0(2019-11-07)稳定,到 trait 里的 async fn 在 1.75.0(2023-12-28)稳定、async 闭包在 1.85.0(2025-02-20)稳定,中间隔了这么多年——原因之一是:每引入一种新的协程变体(async gen、async closure、async drop),都要在这些地方逐一打补丁。

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

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

步骤一:ResumeTy 消除

transform_async_context 将 ResumeTy 替换为 &mut Context<'_>,消除 get_context 调用:

// compiler/rustc_mir_transform/src/coroutine.rs:588(简化,省略类型与 cleanup 块处理)
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 使用四种数据流分析:

// compiler/rustc_mir_transform/src/coroutine.rs:707(简化,省略游标定位与结果收集)
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
}

这个精确性直接影响状态机的大小。如果编译器粗暴地保存所有变量,状态机会不必要地膨胀。这四种分析的并/交运算尽量缩小了保存集合,但对被借用过的局部仍是保守的(§9.7 有实测反例)。

步骤三:存储冲突与布局计算

compute_storage_conflicts 构建 NxN 位矩阵,记录哪些变量同时处于 StorageLive。不冲突的变量可以共享内存位置。

compute_layout 为每个挂起点创建一个变体,生成 CoroutineLayout,概念上相当于:

// 协程结构体布局(概念,以上面的 analysis_demo 为例)
struct Coroutine {
    upvars...,           // 捕获的外部变量
    state,               // 判别式:模块注释写作 u32,实测只占 1 字节(见下文)
    c,                   // 两个挂起点都活跃 → 提升到前缀
    // 以下是联合体——不同变体的字段可以重叠
    variant_3: { a, first_future },    // 挂起点 1
    variant_4: { d, second_future },   // 挂起点 2
}

真正的字节布局在 compiler/rustc_abi/src/layout/coroutine.rs 里算:只出现在一个变体里的 saved local 可以与其他变体重叠;出现在多个变体里的(如上例的 c),或与其他变体的变量存储冲突而被贪心选中的,会被"提升"到前缀,始终占一份独立空间(coroutine_saved_local_eligibility,第 42 行起)。判别式取能容纳最大状态值的最小无符号整数(第 165 行),本章各例实测都是 1 字节。

步骤四:MIR 重写

TransformVisitor 遍历函数体,执行三类重写:

// compiler/rustc_mir_transform/src/coroutine.rs:401(简化)
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 在函数入口插入状态分发:

// compiler/rustc_mir_transform/src/coroutine.rs:1247(简化,省略 poison 块生成与参数 pin 化)
fn create_coroutine_resume_function(tcx, transform, body, ..) {
    let cases = create_cases(body, &transform, Resume);
    cases.insert(0, (UNRESUMED, START_BLOCK));           // 从头执行
    if can_unwind { cases.insert(1, (POISONED, panic_block)); }  // ResumedAfterPanic
    if can_return { cases.insert(1, (RETURNED, panic_block)); }  // ResumedAfterReturn

    // 在 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 的签名匹配。具体实现并不调用 Pin::get_unchecked_mut——make_coroutine_state_argument_pinned 直接在入口块插入 unpinned = copy (_1.0: &mut Coroutine)(_1 就是按值传入的 Pin<&mut Coroutine>,它的第 0 号字段就是那个指针;rustc 1.96.0 实测 MIR 为 _89 = copy (_1.0: &mut {async fn body of fetch_and_process()});),再用 SelfArgVisitor 把所有对 self 的访问替换为 (*unpinned)。

create_coroutine_drop_shim 生成析构函数——也是一个状态机,根据当前判别式执行不同的清理:

状态 清理动作
0(UNRESUMED) 只 drop upvars(函数体还没开始执行)
1(RETURNED) 什么都不做(值已被移走)
2(POISONED) 什么都不做(已处于无效状态)
3+(挂起状态) drop 该挂起点的所有活跃变量,包括子 future

各局部变量的 drop flag 早在 StateTransform 之前的常规 drop 展开(ElaborateDrops)中就已生成;StateTransform 自己插入的 drop(coroutine_struct)(UNRESUMED 状态下释放 upvars)再由 elaborate_coroutine_drops 展开成逐个 upvar 的 drop 梯,之后才复制出 drop shim(coroutine.rs 模块注释第 6-12 行、run_pass 中 insert_clean_drop → elaborate_coroutine_drops → create_coroutine_drop_shim 的调用顺序)。

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())
}

下面的 MIR 是把 http_get(返回借用 &url 的 future)和 text(按值消费 self)换成最小桩实现后,用 rustc 1.96.0 加 RUSTC_BOOTSTRAP=1 -Zdump-mir=fetch_and_process 实测得到的;块号、局部变量编号随代码而变。

HIR 脱糖后:函数体变为协程,每个 .await 变为 loop { match poll(__awaitee) { Ready => break, Pending => yield } },? 也在同一阶段脱糖为对 Try::branch 结果的 match(lower_expr_try,compiler/rustc_ast_lowering/src/expr.rs:1980)。

MIR(变换前):包含两个 Yield terminator(实测在 bb10 和 bb30),分别对应两个 .await 点。活跃变量分析结果(CoroutineLayout 实测):挂起点 1 保存 {url, http_get 的 __awaitee},挂起点 2 保存 {url, text 的 __awaitee, 一个 bool drop flag}。response 已被 text(self) 消费,不保存;url 在挂起点 2 仍被保存——它曾被 &url 借用,而 async 协程是不可移动协程,被借用过的局部要保守地保留到 StorageDead(见 §9.9)。

MIR(变换后,StateTransform.after 节选):

bb0: {
    _89 = copy (_1.0: &mut {async fn body of fetch_and_process()});
    _88 = discriminant((*_89));
    switchInt(move _88) -> [0: bb1, 1: bb139, 2: bb138, 3: bb136, 4: bb137, otherwise: bb8];
}
// 0 → bb1 从头执行;3/4 → bb136/bb137 恢复 StorageLive 后跳回各自的 poll 循环

// 挂起点 1:bb11(即变换前的 bb10,insert_switch 在最前面插入 bb0 后块号整体 +1)
    _0 = Poll::<Result<String, Error>>::Pending;
    ...
    discriminant((*_89)) = 3;
    return;

// 挂起点 2:bb31 以同样方式写入 discriminant((*_89)) = 4

bb55: {
    _0 = Poll::<Result<String, Error>>::Ready(move _87);
    discriminant((*_89)) = 1;
    return;
}

bb138: {
    assert(const false, "`async fn` resumed after panicking") -> [success: bb138, unwind continue];
}

bb139: {
    assert(const false, "`async fn` resumed after completion") -> [success: bb139, unwind continue];
}
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:#333,stroke:none
    style A6 fill:#f59e0b,color:#333,stroke:none
    style B0 fill:#2563eb,color:#fff,stroke:none
    style B7 fill:#10b981,color:#333,stroke:none

9.6.1 降级后的 discriminant:一张状态表

TransformVisitor 在 §9.5 步骤四把 Yield 改写成"set_discr(state); Return"、在 §9.5 步骤五又在入口插入 switchInt(discriminant)。两端配合,状态机的 discriminant 变成了整个运行时语义的控制面板。值得把这张表从字节层面完整过一遍——因为它既是 compiler/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 会走到 insert_panic_block 插入的 Assert(ResumedAfterReturn)——Future::poll 的文档说明这种调用"可能 panic、永久阻塞或造成其他问题",但因为 poll 不是 unsafe 方法,它绝不允许触发未定义行为
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 而不是其他数字——RESERVED_VARIANTS 是一个编译期常量 3,定义在 compiler/rustc_middle/src/ty/sty.rs:84(CoroutineArgsExt 扩展 trait 里,和 UNRESUMED = 0 / RETURNED = 1 / POISONED = 2 并排),coroutine.rs 只是以 CoroutineArgs::RESERVED_VARIANTS 的形式引用它(如 compiler/rustc_mir_transform/src/coroutine.rs:462)。它确保前 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 空间。

哪些转移是单向的——离开 UNRESUMED 之后再也回不去;RETURNED 和 POISONED 都是终态,再次 poll 只会走到 panic 块。挂起状态之间则没有单调性:状态编号按 Yield 在 MIR 里被访问的顺序分配,运行时的转移跟着控制流走。for _ in 0..n { a.await; b.await; } 这样的循环里,状态会 3 → 4 → 3 → 4 地来回切换(实测该循环的 MIR 里分别写入 discriminant = 3 和 = 4,第二轮迭代会从 4 回到 3)。

discriminant 和布局的关系——CoroutineLayout::variant_fields[k] 记录第 k 个变体要保存哪些 saved local,而 storage_conflicts 矩阵决定这些 local 在物理字段上怎么重叠。被保存的 local 并不是在 yield 时才"搬进"变体字段:TransformVisitor::visit_place 已经把它们的每一次读写都改写成对协程结构体字段的访问(如实测 MIR 里的 (((*_89) as variant#4).2: bool)),它们从一开始就住在状态机里。所以走到 yield 点时只需写 Poll::Pending、设 discriminant、return(compiler/rustc_mir_transform/src/coroutine.rs:441-500)。

和第 5 章内存布局的接口——协程的 discriminant 走的是第 5 章 enum 布局里的显式 tag(TagEncoding::Direct),不是 niche 编码:compiler/rustc_abi/src/layout/coroutine.rs 用 Integer::fit_unsigned(max_discr) 取能装下最大状态值的最小无符号整数(第 165 行),并固定使用 TagEncoding::Direct(第 296 行)。所以虽然模块注释里写的是 state: u32,-Zprint-type-sizes 实测本章各例的 discriminant 都只占 1 字节。它和普通 enum 共用 Variants::Multiple 这套变体表示,而 Option<&T> 用的是另一种 tag 编码 TagEncoding::Niche。

9.7 内存布局:编译期确定的大小

async fn 的返回类型是一个编译期确定大小的类型,这是零成本异步的核心。状态机大小公式:

size = size_of(upvars) + size_of(discriminant) + max(variant_3_size, variant_4_size, ...)

这是忽略对齐填充的粗略估算。每个 variant 的大小约等于该挂起点活跃变量大小之和;由于变体使用联合体布局(类似 C 的 union),总大小取决于最大的变体。在多个挂起点都活跃的变量会被提升到前缀(见 §9.5 步骤三),只占一份空间,不在每个变体里重复计算。

存储冲突与内存共享

compute_storage_conflicts 遍历所有程序点,构建 NxN 位矩阵记录哪些 saved locals 同时处于 StorageLive。不冲突的变量可以共享同一块内存:

async fn sharing() {
    {
        let a = [0u8; 512];
        first.await;        // 变体: {a, first}
        use_a(a);
    }                       // a 的存储在此结束(StorageDead)

    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

rustc 1.96.0 -Zprint-type-sizes 实测(first/second 用 1 字节的桩 future):上面的写法是 514 字节,两个变体都从同一偏移开始放 512 字节的数组。如果去掉内层块、让 a 声明在函数顶层,则是 1026 字节:[u8; 512] 是 Copy,use_a(a) 只是复制、并不移走 a,a 的存储要一直持续到函数末尾,和 b 冲突。把数组换成非 Copy 的类型并按值传给 use_a,同样能得到 514 字节。

这和手写 enum 的变体重叠效果相当;区别在于编译器自动完成活跃性划分,并且同一个 saved local 可以同时出现在多个变体里(被提升到前缀、偏移固定不动),手写 enum 做不到这一点,只能在状态切换时把值从一个变体搬到另一个变体。

实践中的大小优化

理解了布局原理,可以有目的地优化 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);           // buf 被借用过
    drop(buf);               // 显式 drop(对 Copy 数组只是复制一份)
    some_future.await;       // buf 仍被保存:借用过的局部保留到 StorageDead
}

// 有效的优化:用作用域限制存储期
async fn scoped() {
    {
        let buf = [0u8; 4096];
        use_buf(&buf);
    }  // buf 的存储在此结束(StorageDead)
    some_future.await;       // buf 不在活跃集合中
}

检查 async fn 的大小:

use std::mem::size_of_val;

let fut = scoped();
println!("scoped size: {}", size_of_val(&fut));
// rustc 1.96.0 实测(some_future 为 1 字节的桩 future):
// unoptimized 4098、optimized 4098、scoped 2

optimized 没有变小,原因在 §9.9 会讲到的规则:async 状态机是不可移动协程,一个局部只要被借用过,编译器就保守地认为它一直活到 StorageDead(也就是离开作用域),drop(buf) 只是把值移走或复制走,结束不了它的存储期。把 [u8; 4096] 换成非 Copy 的包装类型,结果同样是 4098。反过来,如果 buf 从未被借用、只是按值使用后 drop,状态机也会降到 2 字节。所以最稳妥的做法是用块作用域把大对象圈在 .await 之前。

这就是为什么 Rust 的 async 不需要像 Go 的 goroutine 那样分配独立的栈——Future 的大小在编译期确定,可以直接放在调用者的栈帧上或 Box 在堆上的固定位置。

9.7.1 CoroutineLayout 真身:承载变体布局的五字段结构

storage_conflicts 是一个关键优化,但它只是 CoroutineLayout 这个结构体的一部分。完整的布局信息包含 5 个字段(compiler/rustc_middle/src/mir/query.rs:35):

// compiler/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. Locals that do not
    /// have conflicts with each other are allowed to overlap in the computed
    /// layout.
    #[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 输出开头的 coroutine_layout 注释时看到的就是这些名字)。CoroutineSavedTy 除了类型还带 source_info(原始 MIR 里它从哪来)和 ignore_for_traits(标记虽然保存、但不参与 Send/Sync 等 trait 约束计算的字段,详见 §9.9.1)。

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。LLVM 后端生成调试信息时用它给每个挂起变体标注源码位置(compiler/rustc_codegen_llvm/src/debuginfo/metadata/enums/native.rs:200、compiler/rustc_codegen_llvm/src/debuginfo/metadata/enums/cpp_like.rs:767),调试器据此把挂起状态对应回源码行;Send 报错里那句"await occurs here"也要查这张表(compiler/rustc_trait_selection/src/error_reporting/traits/suggestions.rs:2814)。

5. storage_conflicts: BitMatrix<CoroutineSavedLocal, CoroutineSavedLocal>——上一节讲的 N×N 位矩阵。实际上是对称矩阵(冲突是双向关系)、但 BitMatrix 在存储上不优化这个对称性——简单起见存满。位被设的位置表示两个 saved local 同时 storage-live、不能共享内存;未设的位置可以共享。布局算法(compiler/rustc_abi/src/layout/coroutine.rs 的 coroutine_saved_local_eligibility)并不求最优解:只出现在一个变体里的 saved local 才有资格和别的变体重叠;两个有资格的 local 分属不同变体却存储冲突时,用贪心启发式(源码注释:"not always optimal")把冲突更多的那个踢到前缀。

两个隐藏的类型级技巧:

  • #[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 输出的 coroutine_layout 里 saved local 叫 _s0, _s1。newtype_index! 是 rustc 内部大量使用的类型安全 index 宏、不同语义 index(如 BasicBlock vs Local vs VariantIdx)各自有独立类型、不会混淆传参。

理解了这 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:#2563eb,color:#fff,stroke:none
    style RE fill:#f59e0b,color:#333,stroke:none
    style SM fill:#7c3aed,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() 被调用时,任务放入调用者线程的本地队列或全局队列,空闲线程会被通知来处理。至于"事件就绪到再次 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 分析层面:locals_live_across_suspend_points(rust-1.96.0/compiler/rustc_mir_transform/src/coroutine.rs:707)带一个 movable: bool 参数;只有在 !movable(即不可移动协程)这条分支上,才把被借用的局部并进活跃集合——源码是 borrowed_locals_cursor2.seek_before_primary_effect(loc); live_locals.union(borrowed_locals_cursor2.get());(rust-1.96.0/compiler/rustc_mir_transform/src/coroutine.rs:746-759)。注释把理由说明白了:可移动协程里借用本来就不能跨挂起点,所以忽略借用的 liveness 就够;不可移动协程则必须保守地假设"所有被借用过的局部一直活到 StorageDead 为止"。这保证了自引用关系的完整性。

重要细节:async future 在第一次 poll 之前可以安全移动。此时状态为 UNRESUMED,内部只有 upvars,还没执行任何代码,不可能产生自引用。这就是 tokio::spawn(async { ... }) 能工作的原因——spawn 在 poll 前将 future 移动到堆上固定位置。

Pin 的完整类型系统设计和安全性证明见第10章。

9.9.1 witness 类型与 auto-trait 推导:为什么 Send/Sync 会"意外失败"

使用 async 时最常见的编译期困惑是"我这个 future 为什么不是 Send"——错误信息写的是 future cannot be sent between threads safely,并指出某个值 "is used across an await"。背后起作用的是一个叫 coroutine witness 的内部类型。理解它需要把 §9.5 的 saved locals 和 Rust 的 auto-trait 推导机制接起来。

什么是 witness 类型——rustc 为每个协程构造一个辅助类型 CoroutineWitness(did, args)(TyKind 变体,构造入口见 rust-1.96.0/compiler/rustc_middle/src/ty/sty.rs:822 的 Ty::new_coroutine_witness),它的"成员"是该协程的 saved locals(CoroutineLayout::field_tys: IndexVec<CoroutineSavedLocal, CoroutineSavedTy<'tcx>> 列出的那些,见 rust-1.96.0/compiler/rustc_middle/src/mir/query.rs:37;标了 ignore_for_traits 的除外,见 rust-1.96.0/compiler/rustc_traits/src/coroutine_witnesses.rs:22)。这个类型没有运行时表示——它只存在于 trait 求解器的视角中、作为"协程内部持有的所有跨挂起点类型"的集合证据(witness)。auto-trait(Send、Sync、UnwindSafe——rust-1.96.0/library/core/src/panic/unwind_safe.rs:91 等)的推导会透明穿过协程类型、落到 witness 上(Unpin——rust-1.96.0/library/core/src/marker.rs:1012 的 pub auto trait Unpin {}——是例外:不可移动协程一律不实现、可移动协程一律实现,不看 witness,见 rust-1.96.0/compiler/rustc_trait_selection/src/traits/select/candidate_assembly.rs:812-822):如果 upvars 和 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>, fetch() 的 __awaitee }(s 在 await 之后才赋值,不跨挂起点)
// Rc<i32> !Send → witness !Send → a() 返回的 Future !Send
// 所以 tokio::spawn(a()) 编译失败

关键区分——Rc::new(1) 在挂起点前后都活跃、才会被放入 saved locals;如果在 .await 前就 drop(rc)、它就不再是 saved local、不进 witness、future 就可以是 Send。前提是 rc 此前没被借用过:rustc 1.96.0 实测,drop(rc) 之前若有一句 println!("{:?}", rc),future 依然不是 Send,原因与 §9.7 里 optimized 没变小相同,这时要用块作用域把 rc 圈在 .await 之前。同一条规则既影响大小、也影响 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 不是 Send、tokio::spawn 失败。解决办法是换 tokio::sync::Mutex(其 guard 是 Send)、或者确保锁的作用域不跨 .await。
  2. 裸指针跨 await——*const T / *mut T 不自动实现 Send。如果一个 async fn 的局部变量保存了裸指针、并且跨 .await、future 就不是 Send。这个限制本来是好事(防止你把非线程安全的指针跨线程发),但经常让人迷惑——因为问题出在哪个局部变量跨越了哪个 .await,而不在函数签名上,要顺着错误信息里的 "await occurs here" 去找。

ignore_for_traits 字段的作用——§9.7.1 列出的 CoroutineSavedTy 结构里有一个 ignore_for_traits: bool 字段(rust-1.96.0/compiler/rustc_middle/src/mir/query.rs:24-30,源码注释是 "Whether the local should be ignored for trait bound computations.")。它是为少数逻辑上保存但不应参与 auto-trait 推导的 saved locals 准备的。1.96.0 里设置它的只有 compute_layout 一处(rust-1.96.0/compiler/rustc_mir_transform/src/coroutine.rs:997-1007),只覆盖两种情况:访问非线程局部 static 时产生的裸指针临时值(源码注释:它们随时可以通过再次访问同一个 static 重新得到),以及 fake borrow 临时值(只被 fake read 读取,分析后的 MIR 里并不真实存在)。这是一个极窄的 escape hatch、只有编译器自己会设、用户写不出来。

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 1.85.0,2025-02-20 稳定,rust-lang/rust#132706)每次调用创建一个新的 Future 实例:

let closure = async |x: u32| { expensive(x).await };
let fut1 = closure(42);   // 独立的状态机实例
let fut2 = closure(100);  // 另一个独立实例

compiler/rustc_mir_transform/src/coroutine/by_move_body.rs 的 coroutine_by_move_body_def_id 处理的是另一种情形:async 闭包返回的 future 默认按引用借用闭包的 upvar,而经 FnOnce/AsyncFnOnce 调用时闭包本身被消耗,于是编译器再构造一份把这些捕获改为按值持有的"by-move"协程体(见该文件模块注释)。

递归 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状态机 } → 大小递归,无有限解

rustc 1.96.0 报 error[E0733]: recursion in an async fn requires boxing。解决方案:Box::pin 引入间接层,用固定大小的指针替代内联存储(Pin<Box<dyn Future>> 是胖指针,64 位下 16 字节,实测):

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 都要有一条独立的、可增长的栈。
  • 精确变量保存:只保存跨越 .await 的活跃变量。手写状态机需要保存完全相同的集合。
  • 无额外调度开销:嵌套 .await 对子 future 的 poll 是静态分发的普通函数调用,没有上下文切换;只有执行器驱动顶层任务时,通常才经过一次 vtable 调用。
  • 内联友好:状态机是具体类型(非 trait object),LLVM 可内联、常量折叠、死代码消除。
特性 Rust async Go goroutine JavaScript async C# async
状态存储 编译期枚举(栈上) 动态栈(2KB-1GB) 堆分配 Promise 编译器生成的状态机 struct(需要挂起时装箱到堆)
变量保存 只保存活跃变量 整个栈帧 闭包捕获 编译器分析
堆分配 无(除非 Box::pin) 自动管理 每个 Promise 未同步完成、真正挂起的 async 方法
取消 drop 即取消 context + channel AbortController CancellationToken
大小可预测 是(size_of) 否(运行时动态) 否 部分

实际的开销

"零成本"不等于"零开销":

状态机大小受最大变体约束。如果某个 .await 点需要保存大量变量,整个状态机都会膨胀——即使其他 .await 点只保存很少变量。

switch 分发开销。每次 poll 读取判别式并分支跳转。2-3 个 .await 可忽略不计,但大量 .await 可能影响分支预测。

编译时间。数据流分析和布局计算在编译期完成,对大型 async 函数体或深度嵌套的 async 调用会增加编译时间。

对比手写状态机

编译器生成的状态机在逻辑上等价于手写版本。布局上,手写 enum 的各变体同样彼此重叠;编译器版本的不同在于活跃性划分自动完成、跨多个挂起点的变量只存一份且不用在状态间搬移(见 §9.7)。但它也不保证最优:§9.7 的 optimized 例子里,借用过的局部会被保守地保留下来。

9.13 实测: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
compiler/rustc_mir_transform/src/coroutine/drop.rs 762 §9.10 讨论的 drop shim 生成——每个状态变体的 drop 路径
compiler/rustc_mir_transform/src/coroutine/by_move_body.rs 371 async 闭包经 FnOnce/AsyncFnOnce 调用时所需的 by-move 协程体
总计 3144 整个 async/coroutine 状态机变换的全部 MIR 通行

从源码注释里读到的"暂时设计"——compiler/rustc_mir_transform/src/coroutine.rs:566-586(1.96.0)的 transform_async_context 文档注释原文——

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 是个 newtype,里面是 NonNull<Context<'static>>——准确定义见 library/core/src/future/mod.rs:51:pub struct ResumeTy(NonNull<Context<'static>>);,带 #[lang = "ResumeTy"] + #[doc(hidden)],并手写了 unsafe impl Send/Sync—— 在 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 行、10 个结构体(含 StateTransform 本身)——比本章开头的"State Transform 是六个步骤"看起来温和得多——真实代码里只有 1 个 MirPass impl(line 1463)、其他 6 个步骤是这一个 pass 内部的 helper 函数链——这是为什么本章 §9.5 的"六个步骤"讲的是函数顺序、不是 pass 顺序。

9.14 跨章校对: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 + 第 8 章 trait object Box<dyn Future> 的 vtable 布局、poll 的静态分发 vs 动态分发的代价差异
impl Future 的匿名返回类型 第 8 章 trait object 为什么 impl Future 可以零成本但 dyn Future 通常要堆分配——impl Future 背后是编译期已知大小的具体协程类型;Future 本身是 dyn 兼容的,但 dyn Future 是动态大小类型,只能放在指针(通常是 Pin<Box<_>>)后面
每个 async fn 都生成匿名协程类型 第 6 章 monomorphization async fn foo<T> 每个 T 都生成独立的 Coroutine(foo, T) 类型和独立的状态机——和泛型函数单态化共用 Instance 的同一套机制
CoroutineLayout + storage_conflicts 的字段重叠 第 5 章 memory layout 与 enum 共用 Variants::Multiple 表示;discriminant 用显式 tag(TagEncoding::Direct)而非 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(compiler/rustc_mir_transform/src/coroutine/drop.rs) 第 2 章 ownership + 第 12 章 unsafe RAII drop 语义延伸到挂起状态;drop shim 本身是一段编译器自动生成的 MIR,其 MirSource 的 instance 是 InstanceKind::DropGlue(drop_in_place, Some(coroutine_ty))(drop.rs:610),和用户写的 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 的实测结论是:用块作用域把大对象圈在 .await 之前

读者动线建议——本章如果和 第 10 章 Pin 连着读、会把"Pin 为什么存在"从抽象安全性降到具体字节布局的层面(第 10 章 §10.1 的自引用例子讲的正是本章 §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 决定一个独立的状态机实例。注意 StateTransform 作为 MIR pass 只在泛型 MIR 上跑一次;async fn serve<H: Handler>() 里换一个 H,得到的是一个新的协程类型实例,它的布局(大小)和代码生成要按具体参数各做一遍——不是语言设计决定、是底层类型模型决定。

和 C# / Kotlin 的对比再校对一次——§9.12 已经有一张生态层面的对比表、这里补一张编译器内部机制层面的对比,和 §9.4.1 的 "rustc 把 async 做在 MIR" 这一事实配合:

维度 Rust C# Kotlin
状态机生成时机 MIR 层(借用检查与 drop 展开之后、MIR 优化之前) Roslyn CIL 层(高级 IL) 前端 desugar 到字节码
状态表达 匿名类型 + 独立 StateTransform pass 编译器生成的 struct + IAsyncStateMachine 接口 Continuation<T> 接口 + suspend 函数的 CPS 变换
是否走字节码虚拟机 否、LLVM IR 是、CLR JVM 上是;Kotlin/Native 走 LLVM 则否
堆分配 仅 Box::pin 或 spawn AsyncTaskMethodBuilder 有 box/unbox 优化、热路径可栈化 Continuation 默认堆分配
自引用安全 Pin trait(类型系统) 运行时检查 + GC 管理 运行时保证(GC)
取消模型 drop 即取消、递归清理 CancellationToken 显式传递 CoroutineContext + Job.cancel()

这张表和 §9.12 那张(面向开发者视角)的差别——上表聚焦编译器做了什么、在哪一层做、为什么做在那一层。Rust 把状态机做到 MIR 层是因为它没有 GC 兜底、需要让 borrow checker + Drop 的常规机制在状态机上继续成立——而这些机制恰好都工作在 MIR 层;C# 把状态机做到高级 IL,可以依赖 CLR 的 GC 和 JIT 在运行时管理对象的生命周期与布局;Kotlin 同样由编译器对 suspend 函数做 CPS 变换、生成带状态标签的状态机,续体对象分配在堆上、交给 JVM 的 GC 管理。同一个概念、在不同运行时约束下选择了不同的实现层——这是读 rustc 源码时反复会见到的主题:Rust 因为缺 GC、所以把许多 managed 语言放在运行时的机制前移到了编译期(borrow check、drop 插入、状态机展开都是如此)。

9.15 总结与展望

本章揭示了 async fn 从源码到状态机的完整变换路径。核心认知:

  1. 三阶段变换:HIR 脱糖 → MIR Yield → StateTransform 状态机重写
  2. 活跃变量分析:四种数据流分析决定哪些变量需要跨挂起点保存
  3. 编译期确定的大小:状态机可以栈上分配,这是零成本的基础
  4. poll + Waker 协议:将状态机、执行器、反应器三者解耦
  5. 取消即 drop:编译器生成的 drop shim 保证任何状态下的安全清理