跳转到正文
zeno's blog
返回

C++ 协程:语言机制、陷阱与实现边界

Table of contents

Open Table of contents

TL;DR

C++20 协程的本质是编译器把带 co_await/co_yield/co_return 的函数重写成状态机,把跨 suspend 存活的变量打包到一个堆上的 frame——这是语言级 stackless 协程,不是 Goroutine 式 stackful。标准库只给了 coroutine_handle / suspend_always / suspend_never 这几块地基,不给 Task<T>、不给 Generator(C++23 才加)、不给 executor、不给调度器。要在网络服务器里用,必须自己实现 Task<T>(严格遵守 symmetric transfer 否则链式 co_await 直接栈溢出)+ IoUringAwaiter(绑定到 reactor)。三大杀手级陷阱:协程参数必须按值传(引用参数跨 suspend 必悬挂)final_suspend 必须 suspend_always(否则 double-free)热路径不能依赖 HALO,需要自定义 operator new

一句警示:C++20 协程看起来像 Go 的 goroutine,实际上它不是运行时特性而是语言特性——没有调度器、没有取消、没有线程池,裸 C++20 写异步服务器比写 epoll reactor 更容易踩坑。cppcoro / Asio awaitable / libunifex / C++26 std::execution 是四条合理外挂路线。


1. Why:C++20 之前的 C++ 异步有多惨

C++20 之前写高性能异步 I/O 只有四条路,每条在某个维度上破产:

方案破产点
回调 reactor(epoll + lambda 链)一次逻辑请求散落到十几个 handler;生命周期靠 shared_ptr<Session> + enable_shared_from_this;异常无法穿透,只能透传 error_code;栈回溯跨不过回调边界
手写状态机(Protothreads、旧 Asio stackless)switch/case + duff's device,局部变量必须提升到类成员,嵌套调用不可行,可读性灾难
有栈协程(Boost.Context / Fiber、libco、bthread)每 fiber 一个栈,10w 并发 × 64KB = 6.4GB 地址空间;ABI 敏感(汇编 swapcontext);ASan/TSan 支持不完整
线程(1:1 threading)单线程 ~8MB 栈地址空间;context switch 走内核 ~1–2μs;C10K 以上破产

为什么必须是语言级方案:库层 stackful coroutine 无法避免栈预分配;库层 stackless(手写状态机)无法自动提升局部变量。只有编译器能在语法层面把函数切成若干 resume point,自动判断哪些变量需要跨 suspend 存活、打包到单一 frame。P0912R5(Nishanov)把这件事正式做成 C++20 语言特性。


2. 核心概念(大部分博客讲错或跳过的部分)

2.1 Stackless 是设计核心

C++20 协程是 stackless,这不是”没状态”而是一条铁律:协程只能在自己函数体的 co_await/co_yield/co_return 处挂起,不能在嵌套调用内部挂起co_await foo() 要求 foo 要么自己也是协程、要么必须返回后再让当前协程挂起。

好处:挂起时不保存整个 C 调用栈,只保存当前函数自己的 frame。这个 frame 按函数特化、大小编译期已知。

2.2 Coroutine Frame 里装了什么

跨 suspend 存活(通常堆分配):

仅运行中存在(普通栈帧):

2.3 HALO(Heap Allocation eLision Optimization)

P0981R0 的优化:若编译器能证明协程 frame 生命周期严格嵌套在调用者内,可以把 frame 放到调用者栈上,彻底消除 operator new

现实极其骨感

工程结论:热路径(百万 QPS)上不能假设 HALO 触发。三条对策:

  1. -O2 后用 -fdump-tree-* / objdump -d 验证是否真的消除了 new
  2. 自定义 operator new(thread-local bump arena)
  3. 每个会话共用少数长生命期协程,避免 per-request 新协程

2.4 三个关键字的真实含义

关键字等价展开
co_await expr走 awaiter 协议,必要时挂起当前协程
co_yield exprco_await promise.yield_value(expr)
co_return exprpromise.return_value(expr) → 走 final_suspend
co_return;promise.return_void() → 走 final_suspend

不能出现协程的地方constexpr/consteval 函数、构造函数、析构函数、mainauto 推导返回类型、variadic 函数。co_await 还不能出现在 catch 块、默认参数、if/switch/for 的 init-statement 里。

2.5 promise_type 协议(真正的 API 面)

编译器通过 std::coroutine_traits<R, Args...>::promise_type 查 promise,默认是 R::promise_type。最少要实现:

struct promise_type {
    auto get_return_object();          // 返回给调用者
    auto initial_suspend() noexcept;   // 进入函数体前是否挂起
    auto final_suspend() noexcept;     // co_return 后的挂起点(必须 noexcept)
    void return_void();                // 或 return_value(T),二选一
    void unhandled_exception();        // 抓所有从函数体抛出的异常

    // 可选:
    auto yield_value(T);               // 支持 co_yield
    auto await_transform(T);           // 拦截所有 co_await(用于 sandbox)
    void* operator new(size_t);        // 自定义 frame 分配
    static X get_return_object_on_allocation_failure();  // 不走 bad_alloc
};

2.6 Awaiter 协议

bool await_ready();                                       // true 跳过挂起
<ret> await_suspend(std::coroutine_handle<> h);           // 挂起时做什么
T     await_resume();                                     // 被 resume 后返回值

await_suspend 的返回类型决定挂起语义:

返回类型语义
void无条件挂起,控制权返回最近的 resumer
booltrue = 挂起并 return;false = 不挂起继续执行
std::coroutine_handle<>symmetric transfer:尾调用恢复该 handle,当前栈帧先弹出再跳

2.7 coroutine_handle:类型擦除的非 owning 句柄

sizeof == sizeof(void*),trivially copyable。关键 API:

void resume();                 // == operator()
void destroy();                // 析构 frame、释放内存
bool done() const;             // final_suspend 挂起后为 true
Promise& promise() const;      // 访问 promise
static coroutine_handle from_promise(Promise&);
void* address() const;
static coroutine_handle from_address(void*);

另有 std::noop_coroutine():永远 suspended 的占位 handle,symmetric transfer “终结回到 event loop” 时用。

2.8 Symmetric Transfer —— 避免链式 co_await 栈溢出的唯一方法

P0913R0(Nishanov)的关键洞见。场景:协程 A co_await 协程 B,B 同步完成后需要 resume A。朴素实现 continuation.resume() 会在原生栈上形成 B.resume(A) → A.resume(B) → ... 的递归,循环 co_await 直接栈溢出。

正确姿势await_suspend 返回 coroutine_handle<>,编译器保证生成尾调用——当前栈帧先弹出再跳。整条 co_await 链原生栈深度保持 O(1)。

// final_awaiter 里 ——
std::coroutine_handle<> await_suspend(std::coroutine_handle<promise_type> h) noexcept {
    return h.promise().continuation;   // ★ 必须 return,不能 .resume()
}

3. 编译器具体做了什么

Task<int> foo(int x) { int a = co_await bar(); co_return a + x; } 被重写为大致如下(概念模型):

Task<int> foo(int x) {
    // 1. 分配 frame(operator new,或 HALO 到栈)
    auto* frame = operator new(sizeof(FooFrame));
    frame->params.x = x;                           // 按值参数拷贝
    new (&frame->promise) Task<int>::promise_type();
    auto ret = frame->promise.get_return_object(); // 给调用者的返回值

    // 2. initial suspend
    co_await frame->promise.initial_suspend();
    try {
        // 3. body 改写成状态机
        frame->a = co_await bar();                 // suspend point 1
        frame->promise.return_value(frame->a + frame->params.x);
    } catch (...) { frame->promise.unhandled_exception(); }

    // 4. final suspend
    co_await frame->promise.final_suspend();

    // 5. 析构 + operator delete
    return ret;
}

每个 co_await 被展开为:取 awaiter(operator co_awaitpromise.await_transform)→ await_ready → 若 false,保存恢复点、调 await_suspend、return → 恢复时跳到下一个 label 调 await_resume


4. 标准库给了什么,没给什么

标准提供实现版本
std::coroutine_handle<P>C++20
std::suspend_always / std::suspend_neverC++20
std::noop_coroutine()C++20
std::coroutine_traitsC++20
std::generator<Ref, V, Alloc>C++23(P2502R2),libstdc++ 需 GCC 14+
std::execution (senders/receivers, P2300)C++26

标准库没给Task<T>、executor、调度器、sync_waitwhen_all / when_any、取消机制(C++20 无,P2175 推进中)、协程线程池。

工程现实:在 C++20 里裸写异步服务器,这些全部自己实现或用第三方库。


5. C++20 可编译示例(GCC 13+ / Clang 17+)

5.1 Generator:Fibonacci

// g++ -std=c++20 -O2 fib.cpp
#include <coroutine>
#include <cstdio>

template<typename T>
struct Generator {
    struct promise_type {
        T current;
        Generator get_return_object() {
            return Generator{std::coroutine_handle<promise_type>::from_promise(*this)};
        }
        std::suspend_always initial_suspend() noexcept { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        std::suspend_always yield_value(T v) { current = v; return {}; }
        void return_void() {}
        void unhandled_exception() { std::terminate(); }
    };
    std::coroutine_handle<promise_type> h;
    ~Generator() { if (h) h.destroy(); }

    bool next() { h.resume(); return !h.done(); }
    T    value() const { return h.promise().current; }
};

Generator<long> fib() {
    long a = 0, b = 1;
    while (true) { co_yield a; auto t = a + b; a = b; b = t; }
}

int main() {
    auto g = fib();
    for (int i = 0; i < 10 && g.next(); ++i) std::printf("%ld ", g.value());
}

5.2 Task:lazy + symmetric transfer(服务器 runtime 的基石)

// g++ -std=c++20 -O2 task.cpp
#include <coroutine>
#include <exception>
#include <utility>

template<typename T>
struct Task {
    struct promise_type {
        T value;
        std::exception_ptr exc;
        std::coroutine_handle<> continuation = std::noop_coroutine();

        Task get_return_object() {
            return Task{std::coroutine_handle<promise_type>::from_promise(*this)};
        }
        std::suspend_always initial_suspend() noexcept { return {}; }  // lazy

        struct final_awaiter {
            bool await_ready() noexcept { return false; }
            std::coroutine_handle<> await_suspend(
                std::coroutine_handle<promise_type> h) noexcept {
                return h.promise().continuation;   // ★ symmetric transfer
            }
            void await_resume() noexcept {}
        };
        final_awaiter final_suspend() noexcept { return {}; }

        void return_value(T v) { value = std::move(v); }
        void unhandled_exception() { exc = std::current_exception(); }
    };

    std::coroutine_handle<promise_type> h;
    ~Task() { if (h) h.destroy(); }

    // 让 Task 本身可被 co_await
    bool await_ready() { return false; }
    std::coroutine_handle<> await_suspend(std::coroutine_handle<> caller) {
        h.promise().continuation = caller;
        return h;                                   // ★ 启动也是 symmetric
    }
    T await_resume() {
        if (h.promise().exc) std::rethrow_exception(h.promise().exc);
        return std::move(h.promise().value);
    }
};

关键设计决策:

5.3 IoUringAwaiter:与 reactor 集成(本项目核心)

// g++ -std=c++20 -O2 server.cpp -luring
#include <liburing.h>
#include <coroutine>
#include <functional>

struct IoUringAwaiter {
    io_uring* ring;
    std::function<void(io_uring_sqe*)> prep;
    int result = 0;
    std::coroutine_handle<> waiter;

    bool await_ready() noexcept { return false; }
    void await_suspend(std::coroutine_handle<> h) noexcept {
        waiter = h;
        auto* sqe = io_uring_get_sqe(ring);
        prep(sqe);
        io_uring_sqe_set_data(sqe, this);   // ★ 把 awaiter 自己塞进 user_data
        io_uring_submit(ring);
    }
    int await_resume() noexcept { return result; }
};

// reactor 循环(单线程)
void run(io_uring* ring) {
    io_uring_cqe* cqe = nullptr;
    while (io_uring_wait_cqe(ring, &cqe) == 0) {
        auto* a = static_cast<IoUringAwaiter*>(io_uring_cqe_get_data(cqe));
        a->result = cqe->res;
        io_uring_cqe_seen(ring, cqe);
        a->waiter.resume();                  // 回到 coroutine
    }
}

// 业务代码:像写阻塞 IO 一样
Task<int> echo_once(int fd, char* buf, size_t len, io_uring* ring) {
    IoUringAwaiter r{ring, [=](auto* sqe){ io_uring_prep_recv(sqe, fd, buf, len, 0); }};
    int n = co_await r;
    if (n <= 0) co_return n;

    IoUringAwaiter w{ring, [=](auto* sqe){ io_uring_prep_send(sqe, fd, buf, (size_t)n, 0); }};
    co_return co_await w;
}

业务代码是完全线性的,co_await 挂起后等 CQE 回来再 resume,写起来像阻塞调用但全程非阻塞。回调写法需要 3–5 个 lambda,协程写法 1 个函数。


6. 协程与异步 I/O 的结合(本项目重点)

6.1 为什么协程特别适合 async I/O

  1. 线性代码 —— 写成像阻塞一样,行为上完全非阻塞
  2. 无 callback 生命周期噩梦 —— 业务的所有局部变量、栈上 buffer、RAII guard 都在 coroutine frame 里,跨 co_await 自动存活。对比 lambda 链必须手动 shared_ptr + enable_shared_from_this
  3. 异常正常工作 —— co_await 可以抛(由 await_resume 抛出),业务可以 try/catch。回调模式只能透传 error_code
  4. 栈回溯部分可用 —— 单次 resume 时原生栈只有”reactor → handle.resume()“一层,但协程内部调用链(协程调协程)通过 symmetric transfer 仍能部分重建

6.2 executor / scheduler 问题(必须自己解决)

C++20 只给语言机制,没有调度器。必须决定:

本项目推荐:per-thread io_uring reactor + thread-local task queue。每个 worker 线程绑定一个 ring 和一组协程,协程永不跨线程迁移。SETUP_SINGLE_ISSUER | DEFER_TASKRUN(内核 6.1+)进一步锁定。


7. 库作者应掌握的模式

模式要点
支持 continuation 的 Task§5.2。必须 symmetric transfer,否则链式 co_await 栈溢出
sync_wait(task)非协程环境等协程完成。内部 receiver coroutine + binary_semaphore / condition_variable
when_all(tasks...)原子递减计数器,归零时 resume 父协程
when_any / race首个完成即继续,剩余任务需要 cancellation
CancellationC++20 无原生。ad-hoc:task 持 stop_source,awaiter 在 await_suspend 注册 stop_callback 触发 IORING_OP_ASYNC_CANCEL
Async scope(cppcoro::async_scope)结构化并发,防止 fire-and-forget 协程逃逸父作用域

8. Pitfalls(至少 12 条,每条都见过人踩)

  1. 引用参数悬挂(#1 footgun)Task<void> h(const std::string& req) + co_spawn(h(make_req()))make_req() 是临时量,co_spawn return 后销毁,但 frame 里只存了 const string&——第一次 suspend 后引用就悬挂。规则:协程参数一律按值传。clang-tidy 有 cppcoreguidelines-avoid-reference-coroutine-parameters 可开。
  2. 临时量跨 suspend 死亡co_await foo(make_temp()),临时量只活到完整表达式结束,但 co_await 表达式可能早于协程 resume。把临时量拷进 awaiter 内部或协程函数体。
  3. Lambda capture 死亡[&x]() -> Task<int> { co_await ...; co_return x; },capture 存在 closure object 里,closure 跟 lambda 同生死,协程 frame 的 this 是 closure,closure 死了 this 悬挂。按值捕获 string_view 这类”引用语义”类型同样悬挂。
  4. eager vs lazy 选错initial_suspend = suspend_never 立即运行到第一个 suspend;suspend_always 完全 lazy。前者适合 fire-and-forget,后者适合可组合 Task。选错直接改变控制流语义。
  5. final_suspend 必须 suspend_always。若返回 suspend_never,frame 在协程结束瞬间 deallocate,RAII Task 析构里再 h.destroy() 就是 double-free / UB。这条规则 99% 情况下没有例外
  6. 每个协程一次 operator new。热路径(百万 QPS)上 new/delete 是瓶颈。GCC 上 HALO 基本指望不上。对策:(a) 自定义 operator new(thread-local arena);(b) get_return_object_on_allocation_failure 免 bad_alloc;(c) 会话级长生命期协程,避免 per-request 新协程。
  7. unhandled_exception 里 rethrow。等于在 final_suspend 前再次抛出,行为 UB。标准做法:current_exception() 存起来,await_resume 里 rethrow。
  8. naive 链式 co_await 栈溢出。不用 symmetric transfer 写 Task 的人会在 final_suspend 里直接 continuation.resume(),同步完成 + 循环 await 立刻栈爆。务必在 final_awaiter::await_suspend 返回 continuation 而不是 .resume() 它。
  9. 调试极其困难。gdb bt 看到的栈只有 reactor → resume,看不到逻辑调用链。LLDB 17+ / GDB 15+ 有部分协程支持(frame variable 可看 frame),但 coro bt 未标准化。生产靠人工 correlation id + trace span。
  10. 跨 DLL / ABI 边界。frame 布局 implementation-defined,两个 TU 用不同编译器版本就 UB。不要在库公共头文件暴露协程类型,只暴露 type-erased coroutine_handle<> 或传统回调接口。
  11. 线程迁移。协程在 T1 挂起(提交 SQE),T2 上 CQE 触发 resume。thread_local、TLS 指针全变了。frame 内非 atomic 共享状态要加 memory fence(resume() 本身保证 happens-before,但依赖此细节很脆)。对策:pin 协程到提交线程的 reactor,per-thread ring。
  12. 模板膨胀Task<Struct1>Task<Struct2> … 每个实例化一份 promise/awaiter/final_awaiter 代码。大项目编译时间和二进制体积显著膨胀。减缓:Task<void> + 共享状态,或 type-erased UniqueTask

9. 编译器与库支持

编译器核心 P0912R5<coroutine>std::generator (C++23)
GCC10 partial,11+ full(10.x 需 -fcoroutines,11+ 默认)libstdc++ 11+GCC 14+
Clang8 partial,17+ stable-std=c++20libc++ 14+libc++ 18–19 起
MSVC19.10 实验,19.29 (VS 2019 16.11)+ 完整/std:c++20MSVC STLVS 2022 17.13+

Feature test 宏:


10. 生态库

维护者提供适用
cppcoroLewis Baker(半停滞)task/shared_task/generator/async_generatorwhen_all/sync_waitasync_mutex/scopestatic_thread_poolio_service(epoll/IOCP)参考实现、学习
libunifexMetaP2300 前身 sender/receiver + 协程适配想用 sender 模型
folly::coroMetaTask、AsyncGenerator、blockingWait、collectAllMeta 栈
Boost.CobaltKlemens Morgensterntask、promise、generator、channelBoost 栈
Asio awaitable<T>Chris Kohlhoffawaitable<T> + co_spawn + use_awaitable,内置 epoll/io_uring生产级
QCoroDaniel VrátilQt signal/slot 桥接Qt 应用
stdexecNVIDIAP2300 reference前瞻 C++26

本项目(raw-server)建议路线


11. 横向对比:协程 vs 线程 vs 回调 vs Goroutine vs Rust async

维度ThreadsC++20 coroutinesCallbacksStackful fibersGo goroutinesRust async/await
每 ctx 内存~8MB 虚拟 + 栈frame ~100B–KB0(无独立 ctx)栈 4KB–1MB初始 2KB segmentedFuture ~KB
创建成本数 μs(syscall)operator new 一次分配 closure栈分配极低(runtime 优化)仅构造 Future
切换成本~1 μs(kernel)~ns(函数调用 + resume)无切换30–100 ns(汇编)~几十 ns~几 ns
调试完整栈回溯弱(改善中)回调链不可见特殊 gdb patchruntime 支持runtime 支持
语言集成原生原生 C++20原生原生(需 runtime)
生态成熟需第三方 runtime成熟减少标配tokio / async-std
Stackless/fulstackfulstacklessstackfulstackful-ishstackless

协程的独特优势:stackless + 语言集成 + 零切换成本。 代价:需 runtime 胶水、HALO 不可靠、工具链不成熟。


12. 关键提案与路线图

提案状态内容
P0912R5C++20 合入Coroutines TS:三关键字 + promise/awaiter 协议
P0913R0C++20 合入Symmetric Transfer,await_suspend 返回 coroutine_handle
P0981R0编译器语义基础HALO(但实现不一致)
P2502R2C++23 合入std::generator,含 ranges::elements_of 嵌套 O(1) amortized
P2300R10C++26 合入(2024-06 St. Louis plenary)std::execution:sender/receiver/scheduler;as_awaitable 让 sender 可 co_await
P2175推进中stop_token 与协程深度集成(取消)
P3801讨论中(2025 Hagenberg)std::execution::task 的设计 concerns

P2300 与协程的关系互补不替代。协程是”函数怎么挂起”的语言机制;sender 是”异步计算怎么组合”的库机制。C++26 里可以 co_await sender,也可以 just(val) | then(...) 管道。

工程选型:2026 年的新项目,短期仍以协程 + 自己的 reactor 为主;C++26 铺开后(大概 2028 左右)可以迁移到 std::execution 生态。本项目的 raw-server 此时此刻应该走自主路线,见 §10。


13. 生产 Checklist


参考(authoritative sources)


分享这篇文章:

上一篇
Go PostgreSQL:pgx 的架构设计与生产实践
下一篇
Linux I/O(二):io_uring 的双环模型与工程边界