Async Rust 从未离开 MVP 状态:状态机膨胀问题深度剖析与编译器优化方案

2026 年 5 月 5 日,一篇题为 "Async Rust never left the MVP state" 的文章登顶 Hacker News 首页。作者 Dion Dokter(Tweedegolf 工程师)以其在嵌入式 Rust 领域的实践经验为依据,系统性地揭示了 Async Rust 在编译器层面的状态机膨胀问题,并且已经向 Rust 项目提交了正式的 Project Goal,寻求 3 万欧元资助来实现编译器优化。

这篇文章在 Rust 社区引发了广泛讨论——五年过去,async/await 早已稳定,但编译器生成的 Future 状态机距离真正的"零成本抽象"还有多远?本文带你看懂这场争论的核心。


一、问题本质:async 编译器生成的代码远非最优

Rust 在 2019 年 11 月稳定 async/await 时,打出的旗号是"零成本抽象"(zero-cost abstractions)。理论上,用 async/await 写的代码应该被编译成与手写状态机相同的机器码,没有任何额外的运行时开销。

但实际上,Dokter 发现——特别是在嵌入式微控制器和 WASM 这类对二进制体积敏感的场景——编译器生成的 Future 状态机存在大量不必要的冗余。

举个例子,一个简单的 async { 5 } 块,没有任何 await 点,手写实现只需要一个空的 struct 和一行 return。但编译器生成的 MIR(中级中间表示)包含:

  • 3 个默认状态(Unresumed、Returned、Panicked)
  • 一个完整的 switch 分发跳转表
  • 360 行 MIR(vs 非 async 版本仅 23 行)

等你嵌套两层 async 函数,加上 trait 抽象和泛型——代码膨胀程度会指数级增长。


二、四大编译器优化方向

Dokter 在文章中提出了四个关键优化方向,部分已经在编译器分支上完成了原型实现:

1. Returned 状态不要 panic,改为返回 Pending

目前,Future 一旦返回 Ready 后再被 poll,会进入 Returned 状态并调用 panic!。这个 panic 分支阻止了大量后续优化(LLVM 不敢假设它不存在)。

Dokter 的 Hack:在 release 模式下,Returned 状态改为返回 Poll::Pending。这没有任何安全性问题(Future::poll 是 safe 函数),测试显示嵌入式固件体积减少 2%-5%

如果配合 panic=abort,还可以完全消除 Panicked 状态。

2. 无 await 的 async 块直接返回 Ready,不要生成状态机

async { 5 } 这种简单的块,编译器仍然为其生成完整的 3 状态状态机。Dokter 原型实现显示,消除这种冗余可以节省约 0.2% 的固件体积——虽然不多,但优化实现简单且完全向后兼容。

3. Future 内联(Inlining)

这是最有潜力的优化方向。当前编译器将每个 async 块独立编译为状态机,然后在运行时由 LLVM 做内联。但正如文章所示,LLVM 在复杂嵌套场景下常常无能为力。

async fn foo(blah: bool) -> i32 { /* ... */ }
async fn bar(input: u32) -> i32 {
    let blah = input > 10;
    let result = foo(blah).await;
    result * 2
}

这个模式在 trait 实现中极其常见。通过 Future 内联,可以将 bar 的状态机"折叠"进 foo 的状态机,大幅减少代码体积。

4. 合并相同状态

当 async 函数中有 matchif-else 分支时,每个分支的 await 点会生成独立的 Suspend 状态。但这些状态在数据类型和结构上可能完全相同。编译器可以检测并合并它们。

pub async fn process_command() {
    match get_command() {
        CommandId::A => send_response(123).await,
        CommandId::B => send_response(456).await,
    }
}

这段代码中,两个分支的 send_response Future 类型完全一样,但编译器会生成两个独立的 Suspend 状态,MIR 长达 456 行且大量重复。将 match 重构为统一调用后,MIR 降到 302 行。


三、LLVM 救不了你

一个常见的反驳是:"LLVM 会在后端优化掉这些冗余。"但 Dokter 的测试证明:

  • 只在 opt-level=3 下有效,且仅在 Future 足够简单时
  • 一旦 Future 嵌套变复杂(这在真实代码中几乎不可避免),LLVM 就优化不动了
  • 更致命的是:LLVM 无法消除 panic 分支,因为它无法确定该分支不会被触发
  • 当使用 opt-level=s(体积优化)时,这些优化几乎完全不生效

Dokter 的实验显示,如果手动删除 IR 中的 panic 分支,LLVM 就能顺利地将 foo().await + foo().await 优化为常量 10。这说明问题出在 rustc 的 MIR 生成阶段,而不是 LLVM。


四、对谁的冲击最大?

这个问题对不同人群的影响天差地别:

场景受影响程度原因
嵌入式固件严重每字节都宝贵,且常用 opt-level=s
WASM严重下载体积直接影响加载性能
Web 后端服务轻微内存充裕,二进制大小不太敏感
CLI 工具中等安装包体积和冷启动时间有影响

对于大多数使用 Tokio 做 Web 服务的开发者而言,这些优化可能不会带来肉眼可见的改善。但对于嵌入式 Rust(如使用 Embassy 框架)和 WASM 目标,这可以成为决定项目成败的关键。


五、项目目标与社区反响

Dokter 已经正式提交了 Rust Project Goal,预计需要约 3 万欧元 资助来完成全部优化工作。

这项提案的核心亮点在于:

  • 不需要语言层面的大改动
  • 完全向后兼容
  • 优化效果可量化(原型已实现并测试)
  • 对嵌入式生态有直接价值

Hacker News 上的讨论已经超过 60 条,观点两极分化:一边认为 async Rust 确实在体积优化方面处于"MVP 状态",另一边则认为对于一个嵌入式可见的优化,3 万欧元的要价偏高。


六、现在你能做什么?

如果你在项目中使用 async Rust(特别是嵌入式),有以下临时方案:

  • 认真阅读 Dokter Part 1 文章,了解手动优化的技巧
  • 将 match 内部的 await 统一提到分支外,减少重复状态
  • 考虑用"手写状态机"替代关键路径上的 async fn
  • 使用 Box::pin 时注意闭包捕获的额外体积

如果你是公司或组织,希望支持这项工作,可以联系 dion@tweedegolf.com


总结

"Async Rust never left the MVP state" 这个标题虽然尖锐,但它揭示了一个真实的技术债务。async/await 在用户体验和语义设计上无疑是成功的,但在代码生成质量上确实还有很长的路要走。

好消息是——这不是设计缺陷,而是实现层面的优化问题。Dokter 的四项优化都不需要改变语言本身,只需要在编译器 pass 上做加法。一旦实现,嵌入式 Rust 和 WASM 生态将直接受益,而这也会让 Rust 在更多场景下获得竞争优势。

这提醒我们:即使是最成熟的抽象层,也值得时不时审视其底层代码生成质量。零成本抽象不是宣言,是需要持续验证的承诺。