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

10 阅读19分钟

《Rust 编译器原理》完整目录

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

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

:::tip 本章要点

  • 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.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.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.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.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.13 总结与展望

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

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

自引用问题是本章留下的最大悬念——状态机内部的引用可能指向自身字段,移动会导致悬垂指针。Future::poll(self: Pin<&mut Self>) 中的 Pin 就是为此而生。第10章将完整讲解 Pin 的类型系统设计:它如何在不引入运行时开销的前提下,在类型层面保证状态机不被移动。