《Rust 编译器原理》完整目录
- 前言
- 第1章 编译管线全景:从源码到机器码的完整旅程
- 第2章 所有权系统:编译期内存管理的核心机制
- 第3章 借用检查器:编译器如何证明内存安全
- 第4章 生命周期:编译器如何推断引用的有效范围
- 第5章 内存布局:编译器如何排列数据
- 第6章 单态化:泛型的编译期展开
- 第7章 Trait 静态分发:零成本抽象的编译器实现
- 第8章 Trait Object 与虚表:运行时多态的内存布局
- 第9章 async/await:状态机的编译器变换(当前)
- 第10章 Pin、Waker 与 Future:异步运行时的三大支柱
- 第11章 闭包:匿名函数的编译器实现
- 第12章 unsafe:安全抽象的逃生舱
- 第13章 FFI:与 C 世界的桥梁
- 第14章 宏系统:编译期的元编程引擎
- 第15章 MIR 优化:编译器的中间表示与优化管线
- 第16章 LLVM 代码生成:从 MIR 到机器码
- 第17章 增量编译:让重编译只做必要的事
- 第18章 设计哲学与架构决策
第9章 async/await:状态机的编译器变换
"async fn 不是语法糖------它是编译器替你写了一个你永远不想手写的状态机。这个状态机的每一个字节都经过精确计算,不多也不少。"
:::tip 本章要点
async fn经历三个编译阶段:HIR 脱糖 (.await→loop + yield)、MIR 生成 (yield →Yieldterminator)、协程变换 (StateTransform将函数体重写为状态机)- 状态机是一个多变体联合体 ,每个挂起点对应一个变体,只存储跨越该挂起点的活跃变量
- 编译器通过
MaybeLiveLocals、MaybeBorrowedLocals、MaybeRequiresStorage三重数据流分析精确计算需要保存的变量 - async 状态机大小在编译期完全确定------零成本异步的内存基础
- 自引用问题直接导致了 Pin 的诞生(第10章详述) :::
9.1 async 解决的问题:非阻塞 I/O 与回调地狱
操作系统提供两种 I/O 模型。阻塞 I/O 简单直观,但每个并发连接需要一个线程------线程的栈空间(几 KB 到几 MB)和上下文切换开销使这种模型在数万连接时不可行。非阻塞 I/O 让一个线程可以服务数万连接,但代价是代码的执行流被打碎成回调:
rust
// 回调地狱:嵌套回调,错误处理困难,控制流丢失
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 点是函数可能挂起并让出控制权的位置,编译器自动生成保存和恢复状态的机制:
rust
// 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 变换全景:从源码到状态机
async fn + .await"] -->|"AST Lowering
rustc_ast_lowering"| B["HIR
coroutine + loop/yield"] B -->|"MIR Building
rustc_mir_build"| C["MIR
Yield terminators"] C -->|"StateTransform
rustc_mir_transform"| D["MIR'
switch 状态机"] style A fill:#3b82f6,color:#fff,stroke:none style B fill:#8b5cf6,color:#fff,stroke:none style C fill:#f59e0b,color:#fff,stroke:none style D fill:#10b981,color:#fff,stroke:none
阶段一(HIR 脱糖) :rustc_ast_lowering 将 async fn 标记为协程,每个 .await 展开为 loop { match poll() { Ready => break, Pending => yield } }。
阶段二(MIR 生成) :HIR 的 yield 转换为 MIR 的 Yield terminator,标记挂起点。
阶段三(StateTransform) :rustc_mir_transform/src/coroutine.rs 中的核心 pass,执行活跃变量分析、布局计算、MIR 重写、switch 分发插入和 drop shim 生成。
9.3 HIR 脱糖:.await 的真面目
在 compiler/rustc_ast_lowering/src/expr.rs 的 make_lowered_await 中,每个 .await 被展开为:
rust
// expr.await 脱糖为:
{
let mut __awaitee = expr;
loop {
match unsafe {
Future::poll(
Pin::new_unchecked(&mut __awaitee),
get_context(_task_context), // ResumeTy -> Context
)
} {
Poll::Ready(result) => break result,
Poll::Pending => {
_task_context = yield (); // 让出控制权
}
}
}
}
这段脱糖揭示了几个关键事实:
每个 .await 变成 loop + match + yield 。循环不断 poll 被等待的 future,如果返回 Pending 就 yield 让出控制权。外部执行器再次 resume 时,协程从 yield 点恢复继续循环。
_task_context 通过 yield/resume 传递 。协程每次被 resume 时收到新的 Context,Context 包含用于唤醒任务的 Waker。
ResumeTy 是编译器内部的绕行设计 。理想情况下应直接使用 &mut Context<'_>,但 Rust 的协程无法表达 for<'a, 'b> Coroutine<&'a mut Context<'b>> 这样的高阶生命周期(rust-lang/rust#68923)。编译器用 ResumeTy(内含 NonNull<Context<'static>> 裸指针)绕过限制,在后续 MIR 变换中再还原为 &mut Context<'_>。
注意 MatchSource::AwaitDesugar 这个标记------它告诉后续编译阶段这个 match 是 .await 脱糖产生的,而不是程序员手写的,这对错误信息和调试信息很重要。
HIR 的 yield 在 MIR 构建阶段转换为 Yield terminator:
rust
// MIR 中的 Yield terminator
TerminatorKind::Yield {
value: Operand, // yield 出去的值(async 中为 ())
resume: BasicBlock, // 恢复时跳转的目标
resume_arg: Place, // 恢复时收到的值(Context)存放位置
drop: Option<BasicBlock>, // 在此挂起点被 drop 时的清理块
}
9.4 Future trait:poll 协议
rust
// 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 的内部是一个裸指针加虚函数表:
rust
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(创建即执行)形成鲜明对比。
硬编码状态值:0 = UNRESUMED,1 = RETURNED,2 = POISONED,3+ = 用户挂起点。
9.5 StateTransform:核心变换的六个步骤
StateTransform::run_pass(coroutine.rs 第 1463 行)是整个变换的入口:
步骤一:ResumeTy 消除
transform_async_context 将 ResumeTy 替换为 &mut Context<'_>,消除 get_context 调用:
rust
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 使用四种数据流分析:
rust
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); // 协程自身不需要额外保存
}
}
具体例子展示分析的精确性:
rust
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:
rust
// 协程结构体布局(概念)
struct Coroutine {
upvars..., // 捕获的外部变量
state: u32, // 判别式
// 以下是联合体------不冲突的变量共享内存
variant_3: { first_future, a }, // 挂起点 1
variant_4: { second_future, d }, // 挂起点 2
}
步骤四:MIR 重写
TransformVisitor 遍历函数体,执行三类重写:
rust
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 在函数入口插入状态分发:
rust
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 完整示例:逐步跟踪变换
rust
async fn fetch_and_process(url: String) -> Result<String, Error> {
let response = http_get(&url).await?; // 挂起点 1
let body = response.text().await?; // 挂起点 2
Ok(body.to_uppercase())
}
HIR 脱糖后 :函数体变为协程,每个 .await 变为 loop { match poll(__awaitee) { Ready => break, Pending => yield } },? 操作符保持不变。
MIR(变换前) :包含两个 Yield terminator(bb3 和 bb8),分别对应两个 .await 点。活跃变量分析结果:挂起点 1 保存 {http_get_future, url},挂起点 2 保存 {text_future}(url 和 response 已不需要)。
MIR(变换后):
perl
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
9.7 内存布局:编译期确定的大小
async fn 的返回类型是一个编译期确定大小的类型,这是零成本异步的核心。状态机大小公式:
scss
size = size_of(upvars) + size_of(discriminant) + max(variant_3_size, variant_4_size, ...)
每个 variant 的大小等于该挂起点活跃变量大小之和。由于变体使用联合体布局(类似 C 的 union),总大小取决于最大的变体。
存储冲突与内存共享
compute_storage_conflicts 遍历所有程序点,构建 NxN 位矩阵记录哪些 saved locals 同时处于 StorageLive。不冲突的变量可以共享同一块内存:
rust
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 的大小:
rust
// 未优化: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 的大小:
rust
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 事件并唤醒任务。
核心流程:
- 执行器从队列取出任务,调用其
poll方法 - 状态机根据判别式跳转到恢复点,继续执行
- 到达
.await点,poll 子 future - 子 future 返回
Pending,它已在反应器中注册了 Waker - 状态机保存状态,返回
Poll::Pending - 执行器挂起任务,处理其他任务
- I/O 就绪时,反应器调用
Waker::wake() - 执行器将任务重新入队,回到步骤 1
关键优势:没有线程阻塞 。一个线程可以高效驱动成千上万个任务,因为每次 poll 要么快速推进后返回 Pending,要么计算出结果返回 Ready。
tokio 的多线程运行时使用工作窃取(work-stealing)调度器。每个工作线程有本地队列,还有全局共享队列。Waker::wake() 被调用时,任务放入调用者线程的本地队列或全局队列,空闲线程会被通知来处理。从 I/O 事件就绪到状态机被再次 poll 的延迟通常在微秒级。
9.9 自引用问题:为什么 async Future 不能移动
考虑这段看似无害的代码:
rust
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 存储在结构体内部------结构体内部有一个字段指向自身的另一个字段:
yaml
状态机在内存中(地址 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 就是取消它。
rust
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 树。
取消安全性的关键细节:
rust
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 的底层机制完全相同------编译为状态机。区别在于语法位置和捕获方式:
rust
// 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 实例:
rust
let closure = async |x: u32| { expensive(x).await };
let fut1 = closure(42); // 独立的状态机实例
let fut2 = closure(100); // 另一个独立实例
编译器中涉及 coroutine/by_move_body.rs 的 coroutine_by_move_body_def_id 处理捕获变量在每次调用时的移动语义。
递归 async fn
递归 async fn 无法编译------状态机包含自身导致大小无限:
rust
// 编译错误!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 字节的指针替代内联存储:
rust
fn factorial(n: u64) -> Pin<Box<dyn Future<Output = u64>>> {
Box::pin(async move {
if n <= 1 { 1 } else { n * factorial(n - 1).await }
})
}
// 状态机: { n: u64, inner: Box<dyn Future> } → 大小有限
这是 Rust async 中唯一必须堆分配的场景。
9.12 零成本异步的性能分析
"零成本"意味着什么
Rust 的 async/await 遵循零成本抽象原则:你只为使用的东西付费,手写无法做得更好。
- 无堆分配 :状态机在栈上(除非
Box::pin)。Go 每个 goroutine 至少 2-8 KB 堆分配栈。 - 精确变量保存 :只保存跨越
.await的活跃变量。手写状态机需要保存完全相同的集合。 - 无运行时调度开销 :
poll是普通函数调用,没有虚分发、没有上下文切换。 - 内联友好:状态机是具体类型(非 trait object),LLVM 可内联、常量折叠、死代码消除。
| 特性 | Rust async | Go goroutine | JavaScript async | C# async |
|---|---|---|---|---|
| 状态存储 | 编译期枚举(栈上) | 动态栈(2KB-1GB) | 堆分配 Promise | 堆分配状态机类 |
| 变量保存 | 只保存活跃变量 | 整个栈帧 | 闭包捕获 | 编译器分析 |
| 堆分配 | 无(除非 Box::pin) | 自动管理 | 每个 Promise | 每个 async 方法 |
| 取消 | drop 即取消 | context + channel | AbortController | CancellationToken |
| 大小可预测 | 是(size_of) |
否(运行时动态) | 否 | 部分 |
实际的开销
"零成本"不等于"零开销":
状态机大小受最大变体约束 。如果某个 .await 点需要保存大量变量,整个状态机都会膨胀------即使其他 .await 点只保存很少变量。
switch 分发开销 。每次 poll 读取判别式并分支跳转。2-3 个 .await 可忽略不计,但大量 .await 可能影响分支预测。
编译时间。数据流分析和布局计算在编译期完成,对大型 async 函数体或深度嵌套的 async 调用会增加编译时间。
对比手写状态机
编译器生成的状态机在逻辑上等价于手写版本,但可能更紧凑 ------编译器的联合体布局优化(存储冲突分析)使不冲突的变量共享内存,而手写 enum 不会自动做这个优化。
9.13 总结与展望
本章揭示了 async fn 从源码到状态机的完整变换路径。核心认知:
- 三阶段变换:HIR 脱糖 → MIR Yield → StateTransform 状态机重写
- 精确的活跃变量分析:三重数据流分析确保只保存必要的变量
- 编译期确定的大小:状态机可以栈上分配,这是零成本的基础
- poll + Waker 协议:将状态机、执行器、反应器三者解耦
- 取消即 drop:编译器生成的 drop shim 保证任何状态下的安全清理
自引用问题是本章留下的最大悬念------状态机内部的引用可能指向自身字段,移动会导致悬垂指针。Future::poll(self: Pin<&mut Self>) 中的 Pin 就是为此而生。第10章将完整讲解 Pin 的类型系统设计:它如何在不引入运行时开销的前提下,在类型层面保证状态机不被移动。