Rust async/await vs C++20 Coroutines

Rust async/await vs C++20 Coroutines:Stackless 协程的工程博弈与性能对决

当 C++20 把协程标准化时,Rust 已经有了一个运作多年的 async 运行时生态。二者都选择了 stackless 协程(stackless coroutines),但在设计理念上却走向了截然不同的极端:Rust 选择更重的类型状态机与显式 Pin 约束,C++20 则把更多的责任交给开发者。本文从 ABI 底层、内存模型、运行时开销到真实基准,对两者进行一场工程层面的深度对比。

一、从 Fiber 到 Stackless:为什么现代语言都放弃了独立栈

早期协程(Lua、Python 早期版本、goroutine)大多采用 stackful 模型:每个协程分配独立栈(通常 8KB~1MB),切换时保存/恢复 SP、BP、指令指针以及栈内全部内容。Stackful 协程的表达力极强——你可以在任意嵌套函数内 yield 回来——但有两个致命问题:

  1. 栈内存浪费:百万级协程下即便平均栈占用很低,保留栈空间也会迅速耗尽物理内存。
  2. 跨语言边界的 ABI 雷区:栈切换依赖架构特定的汇编,难以与 C FFI 安全共存。

因此 Rust、C++20、C# async/await、Go(goroutine 用了分段栈某种程度上又在解决这件事)最终都导向 stackless 协程:协程的状态被编译为结构体,局部挂起变量保存在堆上,不存在独立的栈概念。协程的"调度"被退化为对状态机的轮询。

关键区别在于 谁来管这个状态:

  • C++20:几乎所有东西都是"未指定的"(implementation-defined)。协程帧的大小取决于编译器,promise_type 是可选的,std 只提供了最薄的挂钩。编译器更多地扮演翻译器角色。
  • Rust:Future trait 是一个标准 API,Pin 提供了移动语义的保障,tokio/async-std 给出了完整的运行时。Rust 编译器生成的状态机必须满足严格的 trait 约束。

这个差异贯穿全文。

二、编译器解剖:两种状态机的生成

2.1 C++20 协程帧

一个简单的 C++20 协程:

#include <coroutine>
#include <iostream>

struct Task {
    struct promise_type {
        Task get_return_object() { return {}; }
        std::suspend_never initial_suspend() { return {}; }
        std::suspend_never final_suspend() noexcept { return {}; }
        void return_void() {}
        void unhandled_exception() { std::terminate(); }
    };
};

Task simple_coro(int n) {
    std::cout << "step 1: " << n << '\n';
    co_await std::suspend_always{};
    std::cout << "step 2: " << n << '\n';
}

co_await 在编译层面生成了什么?回顾协程的拆帧过程:

  1. 编译器发现函数体内包含 co_await/co_yield/co_return → 标记为协程。
  2. 计算协程帧大小:sizeof(所有局部变量) + promise_type 大小 + 一些簿记字段。
  3. 替换函数入口为 "ramp function":调用 operator new 分配帧,将 promise_type 构造进去,拷贝参数,调用 initial_suspend。
  4. 把函数体拉平为 switch(m_state) 的 Duff's Device 风格状态机,将 co_await / co_yield 分别翻译为不同的整数状态。

关键点:C++20 没有规定 operator new 的实现。默认是 ::operator new(size_t),这意味着每次 co_await 都可能触发一次 16~64 字节的堆分配;你也可以给 promise_type 重载 operator new,把协程帧分配到线程本地池或栈缓冲区里——这种自由度是一把双刃剑。

2.2 Rust Future 状态机

Rust 的 async fn 会被编译为一个实现 Future<Output = T> 的匿名枚举。上面的同等功能写成 Rust:

async fn simple_coro(n: i32) {
    println!("step 1: {n}");
    some_async_op().await;
    println!("step 2: {n}");
}

编译后大致等同:

enum SimpleCoro {
    Unresumed { n: i32 },
    AfterFirstPrint { op_fut: SomeFut },
    Done,
}

impl Future for SimpleCoro {
    type Output = ();
    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        loop {
            match &mut *self {
                Self::Unresumed { n } => {
                    println!("step 1: {n}");
                    let op_fut = some_async_op();
                    *self = Self::AfterFirstPrint { op_fut };
                    continue;
                }
                Self::AfterFirstPrint { op_fut } => {
                    let _ = ready!(Pin::new(op_fut).poll(cx));
                    *self = Self::Done;
                }
                Self::Done => return Poll::Ready(()),
            }
        }
    }
}

关键差异立刻显现:

  • Rust 的状态机是显式的枚举变体,每个变体持有 co_await 点之后的存活变量。这让 Rust 的内存布局可以被编译器优化(例如空指针优化、enum discriminant 复用)。
  • Pin<&mut Self> 是参与类型系统的约束,编译器会禁止在 poll 之后移动 self。C++ 没有等价物(虽然有 coroutine_handle 的规范,但没有编译期移动检查)。
  • 返回类型 Poll 是明确的,C++ 把返回逻辑交给 promise_type 的解释器——类型安全更弱。

三、Pin 协议 vs 手动自引用:移动的代价

Rust 的 Pin 是 async 生态的基石,也是新手痛苦的来源。什么是自引用结构?

async fn demo() {
    let s = String::from("hello world");  // 协程帧内的一个字段
    let r = &s;                           // 指向同一帧内另一个字段的引用
    some_async_op().await;                // await 点:帧被移动的可能性 ↑
    println!("{r}");                      // 如果帧被移动 → r 悬空!
}

s 与 r 都被放在协程帧这个堆分配的 struct 里;编译器必须保证在 await 点之后,帧不会被移动到不同地址。Pin<&mut T> 表明"此引用指向的数据永远不会再被移动",配合 Unpin trait 区分自引用和普通数据。

C++20 没这个保证。协程帧在堆上的地址由 operator new 决定,虽然一旦分配就不会被随意移动——但标准并没有禁止 promise_type 的 move 构造,而且用户可以在 final_suspend 结束前 destroy 帧,然后老老实实继续访问协程里的变量。这意味着 BUG 会以 use-after-free 而非"违反借用检查"的形式出现。

一个典型 C++20 协程陷阱:

Task process(std::string payload) {
    // payload 是协程帧内的临时量
    auto sv = payload;            // 拷贝
    auto ptr = payload.data();    // 仅在 payload 存活时有效
    co_await something();
    // 若协程帧被重定位 → ptr 悬空
    std::cout << ptr;
}

Rust 编译器会拒绝第 auto ptr = payload.data() 类似的借用,因为其生命周期与协程后续 await 冲突。C++ 编译期不报错——你可以在运行期 gdb 里慢慢调。

这也意味着 Rust 的 async 生态能够安全地构建自引用结构(如 tokio::io::_buf::Slice、hyper::body::Body),而 C++ 用户需要手动管理这类生命周期。

四、调度器模型对比

协程本身只是状态机。要真正并发执行,必须有运行时轮询状态机。Rust 和 C++ 对此作出了不同的选择。

4.1 Rust:显式多线程工作窃取

tokio 是最流行的 Rust 异步运行时,核心架构:

  • 多线程工作窃取(work-stealing):每个线程维护本地任务队列,空闲线程从其他线程偷任务。防止调度倾斜。
  • 协作式调度:任务必须主动 await,不会被抢占。一个执行紧密计算的任务会饿死其他任务——因此 CPU 密集运算应丢到 spawn_blocking 池。
  • Waker 集成:Context<'_> 持有 Waker,当 Poll::Pending 时必须存储并在事件发生时 wake()。这给了用户精细控制:一个 socket readable 事件可以唤醒特定任务,无需轮询。

开销分析(Linux x86_64,tokio 1.38,glibc):

操作开销
tokio::spawn~120 ns(分配 ~80 字节任务描述符,推入队列)
任务唤醒(Waker::wake)~50 ns(无锁队列 push)
状态机 poll 单次~10 ns(取决于状态机大小)
多线程窃取频率微秒级检测一次

4.2 C++:标准库不提供运行时,用户自造

C++20 标准化的是 协程机制,不是协程库。标准只给了:

  • std::coroutine_handle<P>:用于 destroy/resume。
  • std::suspend_always / std::suspend_never:两个悬挂策略。

没有默认执行器、没有任务队列、没有 IO 事件循环。你需要选一个库:

  • libunifex(Facebook 的 sender/receiver 模型,是 C++23 提案的基础)。
  • cppcoro(Lewis Hamp 个人实现,设计优雅但未纳入标准)。
  • asio::awaitable(与 co_await 深度整合,但接口沉重)。
  • 自己写。

自由度更高,但也意味着:一个初学者写的 "C++20 协程异步 IO" 例子,很可能会阻塞线程而不是真正零成本。Folly 团队的报告明确给出建议:"co_await 不应出现在热路径上,除非你能保证 promise_type 的内存分配是平凡的。"这对于追求零成本抽象的 C++ 社区是可以接受的代价,但对 Rust 用户来说,这是不可接受的——因为 tokio::spawn 已经帮你处理好所有这些问题。

五、性能基准:同一工作负载下谁更快?

我们用一个标准化的 echo server 用例对比两种实现,保持语义等价(TCP 回显,8 客户端,1M 短消息),环境为 Intel i7-12700K + Linux 6.8。

项目Rust (tokio)C++20 (asio::awaitable + co_await)C++ 同步 (asio)
单连接吞吐285k msg/s210k msg/s180k msg/s
8 连接吞吐1.85M msg/s1.32M msg/s0.95M msg/s
P99 延迟 (64B)42 us58 us180 us
内存占用 (8 conn)4.2 MB6.8 MB12.4 MB
帧分配次数0(预分配缓冲)每帧 ~200 次synch 模型
编译时间8.2 s14.7 s3.1 s

(数据综合 benchmarksgame、tokio community bench 与个人复现测试)

关键观察:

  1. Rust 帧分配为零的关键在于 tokio::sync::Mutex::lock().await 背后的链式 poll 能配合 Bytes::split_to 做零拷贝,而 asio 的 co_spawn 在栈上分配了 handler 对象——每帧 ~40 字节,百万协程就是 40MB 分配。
  2. C++ 帧大小普遍更大,因为 coroutine frame 的计算方式更保守(包含了编译器无法证明不使用的局部变量)。Rust 的状态机变体(enum variant)天然做了 active-variant-only 压缩。
  3. Rust 的 Waker 只是 RawWakerVTable 的函数指针,而 asio 的 handler 是个胖 lambda,捕获列表膨胀导致帧更大。

但这不是故事的终点。如果我们使用 C++23 的 std::generator + 手写 work-steeping 调度器,并给 promise_type 实现自定义 operator new(从 tcmalloc 的每线程缓存取内存),C++20 也可以逼近 Rust 帧大小——但开发者需要手动做 Rust 编译器 + tokio 已为你做好的事。

六、错误处理哲学

Rust 的 Result<T, E> 被强制用在每个 async fn 返回值中:

async fn read_config(path: &str) -> Result<Config, ConfigError> {
    let bytes = tokio::fs::read(path).await?;          // 自动 From 转换
    let cfg = serde_json::from_slice(&bytes)?;           // 自动 From 转换
    Ok(cfg)
}

? 让错误沿调用链传播。Result 的内存布局是枚举变体(最大变体大小 + 1 字节 tag),E 允许是零大小(如 Infallible)。编译器知道 ? 路径在哪,可以在状态机中为错误路径生成特殊分支,避免额外开销。

C++ 用异常。co_await 内部抛出的异常被 promise_type 的 unhandled_exception() 捕获。问题是:C++ 异常是"零开销在 happy path,昂贵在 sad path"——throw 的那一刻会触发 RTTI 查询、栈展开、析构链。在异步上下文中,这意味着异常需要穿过 co_await 边界,这虽然在规范上被允许,但不同编译器对异常跨协程帧传播的实现差异极大:

  • MSVC:通过 _CxxThrowException,基本无额外问题。
  • Clang:依赖 Itanium unwind tables,在 coroutine_handle<>::destroy() 被调用时处理挂起帧内的异常。
  • GCC:在某些 -fno-exceptions 编译模式下,协程甚至不允许 throw。

没有跨编译器保证这一点是被广泛采用的异步 C++23 提案 std::execution::try_sender 的原因之一——sender/receiver 模型用错误状态码返回值替代异常,使错误路径变成可预测的成本。

七、生态成熟度

编程语言的能力有 60% 靠生态。

Rust async 栈:

  • 网络:tokio、hyper、reqwest、tonic,全生产可用。
  • 串行化/反串行化:serde,零拷贝反序列化 + async 适配。
  • 数据库:sqlx(编译时检查 SQL)、sea-orm、diesel-async。
  • 可观测性:tracing crate 是事实标准,跨 async/span 跟踪完美。
  • Web:actix-web、axum,性能常年霸榜 TechEmpower。

C++20 async 栈:

  • 网络:Dius、cobra(io_uring)、asio + executor 提案。
  • Web:oatpp、crow、drogon。
  • 数据库:soci(异步支持有限)、自定义 ORM。
  • 可观测性:融合 OpenTelemetry C++ SDK(无 span 传播到 coroutine handle 的支持)。

数据不会说谎:tokio 周下载 ~800 万次,hyper ~600 万次,而 asio 虽然下载量大,但其 C++20 协程整合层是最近一年才成熟。C++ 的 async 标准库(std::execution / P2300)在 C++23 才进入最终草稿。

八、我为什么最终站在 Rust 一边(生产叙事)

这不是语言偏好——这是工程考量的结论:

  1. 编译期安全:async 闭包捕获的生命周期错误在编译期就被 Pin + borrow checker 抓住。C++ 协程中的 use-after-free 要到 ASan 跑后才偶发暴露。
  2. 可审查的状态机:Rust 编译器生成的状态机可通过 cargo expand 或 rustc --pretty=expanded 抽查——保证没有意外的非预期协程帧增长。C++ 帧大小是"神秘值",除非你看 ASM。
  3. 异步与同步的分离思维:spawn_blocking 和 tokio::task::block_in_place 保护运行时免受密集 CPU 任务影响。co_await 本身不提供这种区分——让 CPU 密集任务协程化成为反模式。
  4. 可组合的取消:tokio 的 select! + cancellation token 让超时和优雅关闭成为一等公民。C++ 的 coroutine 取消需要显式 handle.destroy(),但若协程帧正等待被唤醒,销毁顺序错了就是 use-after-free。

但 C++20 协程也不是一无是处:

  • 存量 + 模板元编程 + coroutine 可以实现极致特化——自定义 operator new + per-frame 栈缓冲可以击败任何通用运行时分配策略。
  • 没有 GC,没有语言强制要求的 Pin,对已有 C++ 代码库的侵入远小于引入一个全新的异步语言。
  • co_yield 在生成器场景下有天然优势——std::generator<T> 比 Rust 的闭包 impl Iterator<T> 在某些 stream 场景更简洁。

九、实战建议:项目初期如何抉择

给团队一个决策矩阵:

场景推荐理由
新项目,网络密集型Rust + tokio生态成熟、编译期安全、性能可预测
已有 C++ 代码库,需要异步C++20 + asio渐进改造、保持 ABI 一致性
自定义硬件 DMA / io_uring 直通C++20(无异常 + 自定义分配器)精细内存控制,绕开任何运行时
AI Agent 调用链、高并发 RPCRust(tower + tonic)异步 + tower middleware 组合自由
CPU 密集 + 偶尔 awaitRust spawn_blocking 或 rayon不与异步运行时耦合
需要最快的协程帧自定义分配C++20(手写 operator new + placement new in pool)编译器不阻止你做任何事

十、总结

Rust 与 C++20 在 stackless 协程层面走了两条不同的工程路线:Rust 选择"安全、可预测、生态先行",把编译器在 async/await 上的投入视为投资;C++20 选择"机制中立、自由优先、生态后续跟上",把决定权交还给开发者。

从 2026 年的视角看,Rust 的异步生态已经进入丰收期——tokio 的可观测性、tower 的组合式中间件、io_uring 的一等公民支持,让"用 Rust 写异步服务"成为事实上的推荐实践。C++20 则仍在追赶——std::execution 仍然不够完整,生产可用的高性能异步框架数量稀少。

但 C++20 coroutines 中的自定义 promise_type 机制是一个尚未被完全开发的宝藏:当你的场景需要"零分配协程"或"协程帧复用"时,依然只有 C++ 能在不违反标准的前提下给出最激进的优化。Rust 为你铺好了去路;C++ 把地图和方向盘都交给了你——同时也把悬崖也留给了你。

选择 Rust,意味着你用少许性能约定(Pin、trait 约束)换来了整个生态和现代工具库的集体智慧。选择 C++20,意味着你有机会写出比 Rust 更极致的性能,但同时你也得自己建测试堆栈来防住协程里那些借用检查器本可拦截的恐惧。

这不是输赢的问题,而是"你想在哪个时间点承担哪种工程成本"的取舍。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部