最近在做一个数据采集服务先叫它“某采集服务”吧需要用 C 处理大量异步网络请求。最初用的是三层嵌套回调请求完成回调里再发下一步请求下一步请求回调里再做重试判断代码很快就变成了一团只能靠注释才能看懂的意大利面。后来我改用 C20 协程重写了核心流程代码量直接降了一半可读性和调试体验都上了个台阶。但这个过程并不顺畅——协程的关键词就那么几个真正难的是理解编译器在背后生成的状态机、协程帧、promise_type 和句柄之间的关系。这篇文章就把我从零把 C 协程摸清楚的过程、踩过的坑、以及最终写出来的可用框架完整记录下来希望能让后来的人少走几步弯路。协程不是银弹它不能自动让你的程序变快、变并发。它提供的是一种“可以暂停、可以恢复的函数执行体”把异步流程从“回调倒金字塔”改写成“顺序代码”同时不引入线程栈那样昂贵的内存和切换成本。明白这一点之后再看 C20 的协程设计会发现它的语法层其实很小真正的复杂度全部落在库作者身上。1. 先把几个概念放在台面上线程、回调和协程各自解决什么1.1 三层回调带来的问题不只是“丑”我在重构前写过类似这样的逻辑先查配置再拉数据最后写缓存。用回调描述就是 A 完成之后回调 BB 完成之后回调 C。代码在逻辑上是线性的但在源码里却是嵌套的。一旦中间要做超时控制、重试、分支判断嵌套深度就失控了。更麻烦的是错误处理被拆散在每个回调里同一个错误的传播路径散落在好几个函数中。你当然可以用状态机硬撑把每个阶段写成枚举加 switch用一个巨大的 while 循环驱动。但状态机维护的“数据在哪里、下一步跳到哪里”完全是手工劳动和 C20 协程编译器自动生成的状态机相比既容易出错又难以复用。协程的意义就在这里它在语言层面支持挂起和恢复编译器负责把函数体转换为状态机程序员仍然按照顺序思路写代码。IO 还没就绪就把协程挂起IO 完成了再恢复这个协程继续往下执行。看起来像同步代码实际上是非阻塞执行。1.2 无栈协程到底“无”了什么C20 提供的是无栈协程。这里的“无栈”指的是它没有独立维护一套完整的调用栈。线程切换时要保存和恢复整套寄存器上下文、栈指针、调度信息成本在微秒级别而协程挂起时只需要保存当前执行位置、局部变量、临时对象等少量数据然后退出当前函数调用栈把控制权交还给调用者。恢复时不是回到原来的栈帧里而是从新的栈顶开始跳到之前保存的执行位置继续执行。理解这一点很重要协程函数一旦挂起它当前的函数调用栈就会展开栈上的局部变量会销毁。协程能继续执行依赖的是存放在堆上的协程帧coroutine frame里那些被“搬走”的局部状态。这也是初学者最容易踩坑的地方——拿着栈上对象的引用传给 co_await等协程恢复时对象早就没了。1.3 为什么 C20 只给了语言机制没给完整封装很多人刚接触 C20 协程时会疑惑std 里怎么没有 task、future、scheduler原因很简单C 委员会选择把协程的最低层语义定下来而把策略层面的事情交给库作者。语言层提供了 co_await、co_yield、co_return 和协程帧机制但“协程什么时候开始执行”“执行完怎么把结果交给外部”“谁负责销毁协程帧”这些问题都通过一个可定制的 promise_type 暴露给你。这样的设计很灵活代价是不友好。Python 和 Go 的协程是语言运行时帮你想好了一切C20 协程则要求你自己实现调度逻辑。所以我在学习时给自己定了一个目标不只看语法而是亲手写一个最小但完整的协程任务类型和调度器把所有接线点都走一遍。接下来我把这个过程中最关键的几个机制拆开讲。2. 编译器藏在代码背后的三样东西协程帧、promise_type 和句柄2.1 协程帧就是状态机的“内存化身”任何一个函数只要函数体里出现了 co_await、co_yield、co_return 中的任意一个就会变成协程。编译器会把这个函数改造成一个状态机并把运行过程中需要跨挂起点存活的数据都放到一个协程帧中。协程帧大致包含这几类内容promise_type 对象函数参数有些编译器会移动或拷贝到帧内所有跨挂起点存活的局部变量当前挂起点的编号、状态机索引等内部信息。协程帧通常在首次调用协程时通过堆分配产生。编译器会计算这个帧需要的总大小然后调用 promise_type 的 operator new如果没有就调用全局 operator new。你可以在 promise_type 里自定义 operator new使用内存池、arena 等手段控制分配行为后面我会专门说。因为协程帧在堆上所以协程的生命周期可以比创建它的函数调用更久。这个特性既带来了异步能力也带来了内存管理责任协程帧什么时候释放由谁触发是库作者必须回答的问题。2.2 promise_type 的“约法三章”编译器不知道你的协程返回什么语义它只负责按约定调用你定义的方法。一个最小的 promise_type 通常需要提供这些成员成员作用get_return_object()生成协程对外返回的句柄对象比如 Taskinitial_suspend()协程创建后是否立即挂起final_suspend()协程执行完后是否挂起以及如何转移控制权return_value() / return_void()接收 co_return 的结果unhandled_exception()接收协程体内逃逸的异常yield_value()配合 co_yield 产出值一个容易被忽略的细节initial_suspend 返回 suspend_always 时协程调用后不会立刻执行函数体而是先返回 get_return_object() 的结果让你持有。如果你想写“自启动”的协程可以返回 suspend_never但这意味着调用协程时函数体会一直执行到第一个真正挂起点很多没有准备的调用方会被这种隐式执行吓到。final_suspend 则关乎协程帧的销毁时机。如果它返回 suspend_always协程执行结束后帧还会保留在挂起状态外部可以通过句柄查看结果再手动销毁如果返回 suspend_never协程结束时编译器就会自动销毁帧外部再操作句柄就是悬空行为。框架设计里这是一个决定所有权模型的关键分叉。2.3 coroutine_handle所有恢复和销毁操作的总入口协程帧本身对用户是不透明的你无法直接拿到一个指向帧的指针。对外交互要通过std::coroutine_handlepromise_type它是一个非常轻量的句柄本质上可以看作对协程帧的封装。coroutine_handle提供的主要操作有resume()从当前挂起点继续执行协程destroy()销毁协程帧并释放内存done()判断协程是否已执行完promise()获取关联的 promise_type 对象引用from_address()从底层地址恢复句柄。每个协程函数调用的那一刻系统就会得到一个 coroutine_handle而 get_return_object() 能通过coroutine_handlepromise_type::from_promise(*this)拿到同样的句柄。所以 Task 内部其实就保存着一个句柄外部通过它来驱动协程。2.4 await_suspend 的三种返回值决定“下一步去哪”很多人学协程卡在 awaiter 的编写上。一个 awaiter 要实现三个方法await_ready()、await_suspend()、await_resume()。其中 await_suspend 的返回值类型有三种常见选择返回void挂起当前协程控制权交还给调用方之后需要外部某处调用句柄的 resume()返回booltrue 表示挂起false 表示立即恢复当前协程继续执行返回std::coroutine_handle当前协程挂起并立即恢复返回的那个协程。第三种返回方式叫“对称转移”symmetric transfer。它最大的价值是避免协程链过度递归。如果我们在 final_suspend 里返回下一个要执行的协程句柄编译器就能直接切换到目标协程而不是在调用栈上层层 resume、再返回来 resume栈深度不会随着协程链条增长。后面我自己写的 Task 就依赖这个机制。3. 手写一个可用的 Task 从 promise_type 到 awaiter 再到微调度器3.1 把 promise_type 和句柄绑定起来光看理论容易飘我建议你也跟我一样从零写一个最小的 Task 。目标很简单支持co_await TaskT并能拿到返回值。先看 promise_type 部分的骨架#include coroutine #include exception #include utility template typename T struct Task { struct promise_type; using handle_t std::coroutine_handlepromise_type; struct promise_type { T value{}; std::exception_ptr error{}; handle_t continuation{}; Task get_return_object() noexcept { return Task{handle_t::from_promise(*this)}; } std::suspend_always initial_suspend() noexcept { return {}; } struct FinalAwaiter { bool await_ready() noexcept { return false; } handle_t await_suspend(handle_t self) noexcept { return self.promise().continuation; } void await_resume() noexcept {} }; FinalAwaiter final_suspend() noexcept { return {}; } void return_value(T val) { value std::move(val); } void unhandled_exception() { error std::current_exception(); } }; // 后面补充 Awaiter 和外壳 };这里的关键是把 continuation 存在 promise 里。协程挂起时父协程把自身的句柄写进子任务的 continuation子任务执行完final_suspend 里的 FinalAwaiter 会返回 continuation也就是父协程句柄于是控制权自动交回父协程。3.2 写 Awaiter让“co_await 一个任务”真正可用接下来定义 Awaiter 和 Task 外部行为struct Awaiter { handle_t h; bool await_ready() noexcept { return h.done(); } handle_t await_suspend(handle_t caller) noexcept { h.promise().continuation caller; return h; } T await_resume() { if (h.promise().error) { std::rethrow_exception(h.promise().error); } return std::move(h.promise().value); } }; Awaiter operator co_await() noexcept { return Awaiter{h}; } explicit Task(handle_t handle) : h(handle) {} Task(Task o) noexcept : h(std::exchange(o.h, {})) {} ~Task() { if (h) h.destroy(); } Task(const Task) delete; Task operator(const Task) delete; Task operator(Task o) noexcept { if (this ! o) { if (h) h.destroy(); h std::exchange(o.h, {}); } return *this; } bool done() const noexcept { return h.done(); } T result() { if (h.promise().error) std::rethrow_exception(h.promise().error); return std::move(h.promise().value); } private: handle_t h; };写这段代码时我重点解释一下 await_suspend 的逻辑父协程执行co_await childTask会先调用 child 的 Awaiter。此时 child 协程大概率还没开始执行所以 await_ready 返回 false。await_suspend 收到父协程句柄 caller把它存进 child 的 continuation然后返回 child 的句柄。对称转移会让父协程挂起、立即恢复 child。child 完成后final_suspend 又把控制权转移回父协程父协程从 await_resume 里拿返回值继续往下走。3.3 用最小的调度器验证整条链路有了 Task 还差一个“启动机制”。因为 initial_suspend 返回 suspend_always所以拿到 Task 后协程体还没有运行。写一个简单的同步等待函数用来在测试里驱动根协程template typename T T sync_wait(TaskT t) { auto h t.handle(); while (!h.done()) { h.resume(); } return t.result(); } Taskint add(int a, int b) { co_return a b; } Taskint add_three(int a, int b, int c) { int ab co_await add(a, b); co_return ab c; } int main() { int ret sync_wait(add_three(10, 20, 30)); // 期望得到 60 }这个循环看起来简单实际上已经覆盖了协程最重要的几个阶段首次 resume 让根协程跑起来中间的 co_await 形成父子关系最后的 final_suspend 挂起保留结果帧直到 sync_wait 拿到结果后由 Task 析构函数 destroy。真实项目里这里不会是一个 while 循环而是一个事件循环 就绪队列但机制完全一致。实际编译时如果用的是 GCC需要加-fcoroutinesGCC 10 及以后版本才支持Clang 15 以上默认支持MSVC 也在默认开着的状态。版本带来的坑比我预想的多建议你写协程代码前先确认编译器和标准库版本不要假设所有环境都支持。4. 协程的内存和生命周期看不见的分配、悬空引用与废弃帧4.1 一个协程函数调用背后可能有多次分配协程帧的分配决定性能下限。普通函数调用只动栈协程首次调用往往伴随一次动态内存分配。如果你的框架又创建了一个 promise 对象、一个 Task 对象堆上还会多出一些零碎对象。对于高吞吐场景这些分配成本不可忽视。定制分配器的入口是 promise_type 里的 operator new。下面是个简单示例用固定容量的内存池减少高频分配struct promise_type { static void* operator new(std::size_t size) { // 这里可以从 arena 分配 return ::operator new(size); } static void operator delete(void* ptr) noexcept { // 这里可以归还 arena ::operator delete(ptr); } // ... 其余成员 };值得注意只有协程函数对应的 promise_type 提供 operator new编译器才会调用你的版本。所有协程帧的内部大小由编译器决定你在 operator new 里只能拿到 size 参数。想优化就把它映射到固定大小池上。不过对大多数业务代码来说先把功能写对再优化分配也不迟。协程真正节省的是线程栈内存一个线程默认栈可能占用几 MB而一个协程帧往往只有几十到几百字节。夸张点说你可以在内存里开上万个协程等 IO但要开一万个线程机器基本就罢工了。4.2 悬空引用协程框架下最隐蔽的崩溃源我曾经在项目里遇到过协程在恢复后读到一个完全离谱的值某个局部变量被外部修改了但这协程从头到尾就没碰过它。排查半天根因是一个经典的引用悬空。协程挂起后函数栈上的局部对象会析构但协程帧里保存的局部变量仍然存活。问题出在“保存引用”上。看这个反面示例Taskint bad_await(const std::vectorint v, int index) { // 如果 v 是调用方临时构造的或者 v 在 await 期间被释放 // 等协程恢复后读 v[index] 就是未定义行为 co_await std::suspend_always{}; co_return v[index]; }如果把一个临时 vector 绑定到这个引用类型参数再让协程挂起协程帧里保存的是引用本身而不是 vector 的数据。挂起期间临时 vector 已经销毁恢复后访问的就是一块已经归还的堆内存。编译器和 sanitizer 不一定能立刻抓到因为协程帧本身没有越界越界的是引用指向的外部对象。规范做法是需要跨 await 存活的参数尽量按值传递或者把对象移动/拷贝到协程帧内的局部变量里Taskint good_await(std::vectorint v, int index) { co_await std::suspend_always{}; co_return v[index]; }按值传递之后参数会作为局部变量保存在协程帧里生命周期一直持续到协程完成这就安全了。4.3 final_suspend 再挂起一次的理由另一个生命周期相关的常见困惑是既然协程已经执行完了为什么 final_suspend 还要挂起一下不直接销毁帧因为 final_suspend 挂起后外部仍然可以通过句柄访问 promise 里的返回值。我写的 Task 就是这么做的co_return 先把值放进 promise.value然后进入 final_suspend帧保持存活sync_wait 可以安全取结果最后 Task 析构时调用 destroy()。如果 final_suspend 返回 suspend_never协程结束瞬间帧就被销毁外部再去访问 promise 就是未定义行为。当然final_suspend 挂起也有代价它留了一个必须被 destroy 的帧。如果忘记销毁会产生协程帧泄漏。所以你的框架最好把销毁责任绑定到 RAII 对象上我上面的 Task 就是把执行权交给析构函数来保证异常路径下也不泄漏。5. 某采集服务重构实录把回调链改成协程链踩到了哪些真实问题5.1 从回调到协程的三个关键设计决定重构时我做了几个关键决策每个都有理由第一返回值类型统一用自己封装的 Task 而不是直接返回 coroutine_handle。直接操作句柄容易漏掉 destroy()封装进 RAII 对象后异常路径也能正常销毁。第二所有 async 操作都先转换成 awaiter。旧代码里的异步回调封装成一个 Awaitable让内部回调触发句柄的 resume() 即可。这样整个业务流程的每个步骤都能写成顺序代码。第三网络层没有用自研阻塞方案仍然是事件驱动。协程只负责把控制流转起来真正等待网络事件还是依赖事件循环。协程和事件循环是配合关系不是替代关系。5.2 线程池并没有完全退出舞台有人以为协程可以取代线程池实际上两类技术解决问题的方式不同。协程适合大量 IO 密集型等待等待网络包、等待磁盘完成、等待定时器到期。线程适合 CPU 密集型计算或者调用真正阻塞的系统函数。我在重构中保留了少量线程用于两个场景一是某些第三方 SDK 只提供阻塞接口直接放进协程里会卡住整个事件循环二是把计算密集的解析任务交给工作线程避免拖慢 IO 协程的调度。协程恢复后的代码跑在对应执行器上如果执行器只由一个线程驱动那么 CPU 密集任务就可能抢占其他协程的恢复时间。5.3 调度循环里最常见的错误反复 resume 同一个已完成的协程我在测试调度器时遇到过一个非常隐蔽的问题协程已经执行到最终挂起点但某个 pending 消息还在循环队列里于是调度器对同一个句柄再次调用了 resume()。看似无害实则是未定义行为轻则触发 assertion重则直接崩溃。修复方法是在 resume 前检查handle.done()。每次调度器从队列里取出协程句柄时都要先确认它还没执行完再决定是 resume 还是丢弃。写成代码就是while (!queue.empty()) { auto h queue.front(); queue.pop_front(); if (h !h.done()) { h.resume(); } }这个检查看起来多余但真的发生过而且发生在协程执行逻辑已经完成、却又被外部重复强制恢复的情况下。5.4 体感上的收益和代价重构完成后同样的业务逻辑代码量少了一半原来的回调层级消失了错误处理也从“每个回调里 try-catch”变成了协程体内的统一 try-catchfinally 执行也更自然。内存占用也有明显下降之前为了并发同时持有很多线程每个线程预留的栈空间即使没用也占用虚拟内存协程版本把并发单位从线程降为了协程整体内存压力小了很多。代价也真实存在编译期和模板错误信息变得更难看一旦 promise_type 写错编译器报错能铺满一屏。调试逻辑也不能再靠简单的调用栈看全部流程往往需要熟悉协程帧内部结构才能定位问题。6. 生产环境里的调试手段、标准库进展和最后的避坑表6.1 调试器显示不了协程调用栈时的对策协程挂起恢复这个动作对调试器很不友好。你一步步单步调试时resume 之后看到的调用栈可能只有当前函数带的一小段父协程、子协程的关系不会像普通函数调用那样自然呈现在 backtrace 里。我的经验是用日志换栈信息。在协程的每个关键节点打印协程 ID 和阶段状态例如coroutine begin、awaiting child、resumed from child、completed。调试线上问题时这种方式比单步调试高效得多。如果一定要单步也可以用地址检查工具配合比如在关键逻辑处加上断言防止协程帧被过早销毁。另外编译时最好保留调试符号和优化等级之间做个折中。太高的优化会把状态机搞得几乎不可读建议使用 O0 或 O1 做调试构建。6.2 从 C20 到 C23std::generator 能帮你写什么C20 标准库几乎没有提供现成的协程类型C23 总算补上了 std::generator。它适合处理生成器式迭代函数每次产出一个值暂停等调用者取走后再恢复。手写一个生成器十分繁琐但用标准库写就简洁多了#include generator #include iostream std::generatorint range(int n) { for (int i 0; i n; i) { co_yield i; } } int main() { for (int v : range(5)) { std::cout v ; } }C23 的 std::generator 解决的是“惰性生产一组值”的问题不是“异步任务调度”的问题。想要 task、scheduler、io_context 这些仍然需要第三方库或自己实现。我的建议是新项目可以先评估是否能用 std::generator 简化迭代逻辑再把异步控制流交给协程任务类型。6.3 一张避坑表写给刚接触 C20 协程的人问题现象对策引用悬空协程恢复后读到脏数据或崩溃跨挂起点存活的参数按值传递避免保存外部引用忘记销毁协程帧内存持续增长封装 RAII 持有 coroutine_handle在析构里 destroy重复 resume 已完成协程运行时期错误或崩溃恢复前检查 handle.done()final_suspend 返回 suspend_never外部访问结果时帧已销毁根据框架需要返回 suspend_always延迟销毁异常未捕获协程静默结束promise_type 里实现 unhandled_exception()保存 exception_ptr调度器没有考虑对称转移resume 链过深栈溢出final_suspend 中返回目标句柄而不是层层 resume回调转协程封装不当数据竞争或死锁把回调触发 resume 的时序梳理清楚避免重复触发6.4 再补一个调度器设计的进阶思路最后说一个真实调度器比微调度器复杂的地方。前面 sync_wait 是自己循环 resume那是同步驱动异步场景下的协程调度器本质上是一组“触发点”的集合每个触发点知道自己的协程句柄等待的事件发生后调度器把句柄加入就绪队列。事件循环不断从就绪队列取出句柄、resume、跑协程直到协程再次挂起或结束。这里有一个很多人容易想歪的点如果 await_suspend 里直接调用另一个协程的 resume()很容易造成栈递归因为 resume 后协程又可能继续 co_await又触发下一个 resume。灾难级代码是这样的void bad_await_suspend(std::coroutine_handle caller) { child.resume(); // 不要让 await_suspend 主动 resume 子协程 caller.resume(); }正确做法是把目标句柄返回给编译器使用对称转移让编译器调配控制权转移避免一层层嵌套的 plain resume 调用。C20 的标准委员会之所以引入返回句柄的语义就是给库作者留好了优化路径。在实际项目中我把任务链上的每个环节都尽量改成“返回目标句柄”运行栈深度稳定在很浅的水平不会随着协程链条增多而爆炸。如果你在自己的项目中只使用 void 返回的 await_suspend遇到每个子任务都要由某个调度者来恢复那么协程一多就会出现难以察觉的递归栈增长。这点做到位协程的性能和稳定性才会有质的差别。