C++20协程从原理到实战:挂起恢复、生成器与异步封装
当年第一次看到 C20 协程提案时我第一反应是这语法也太多了吧又是 co_await 又是 co_yield连标准库的 task/generator 都不给学它干嘛。直到后来有一次要把回调嵌套三层的异步逻辑改成顺序写法才发现协程真正解决的不是语法好看而是把“挂起和恢复”这件事以零成本抽象的方式还给了程序员。这篇内容就把 C 中的协程编程从原理到暗坑再到怎么把回调接口改造成协程接口完整过一遍。C20 的协程属于无栈协程注意它跟 Python 的 generator 不是一个层级的东西语言层只给了三个关键字和一套编译器需要去调用的接口约定线程、调度、任务类型统统不帮你做。这意味着你可以基于这套机制写一个 generator也可以写一个完整的异步 task 系统甚至可以写一个惰性求值管道——但前提是你得搞明白每一行代码背后编译器到底做了什么。1. 协程不是语法糖先弄清编译器把函数改成了什么很多人以为协程就是能暂停的函数这个理解不算错但容易让人误判它的成本模型。一个普通函数被调用时会在当前线程的调用栈上压一个栈帧而一个协程函数被调用时除非编译器能确定不需要堆分配否则它会在堆上创建一个“协程帧”。这个帧和普通栈帧最大的区别在于栈帧随函数返回自动销毁生命周期由调用栈管理协程帧则是独立的里面装着参数、局部变量、Promise 对象以及编译器为每个挂起点安排的内部状态。协程挂起时帧保留协程恢复时直接从原来的挂起点继续执行。所以它才不依赖线程上下文切换也不需要一个独立的调用栈。C 协程真正的好处是让你用同步的顺序代码写异步逻辑而编译期就把这些代码转换成一台有限状态机。每次挂起点都是一个状态转移。你可以用普通函数栈帧的思维方式去理解它但从内存分配、生命周期和恢复方式看它已经是另一种东西了。1.1 Promise type 是协程的“内部协议”每个协程函数的返回类型里必须有一个嵌套的promise_type。这个类型不负责业务逻辑它是协程和外部世界打交道的协议层。编译器在任何协程体开始执行前会先创建 Promise 对象然后调用它的一系列方法。协程被调用后get_return_object()返回的对象会作为协程函数的返回值交给调用者。接下来编译器会问initial_suspend()协程体要不要一开始就挂起如果返回std::suspend_always协程创建后是“惰性”的挂起点在函数体第一行之前如果返回std::suspend_never则协程会立刻跑到第一个挂起点。这个决定直接影响后续代码风格。协程体结束时return_void()或return_value()会被调用。如果协程体内抛出异常unhandled_exception()会被调用来保存异常。最后final_suspend()决定协程结束后协程帧是否保留。这个细节非常关键后面讲生命周期暗坑时会专门展开。1.2 co_await、co_yield、co_return 三个关键字的关系很多新手记不住这三个关键字的语义我一般用等价关系来记co_yield expr本质上等价于co_await promise.yield_value(expr)。它先把当前值写入 Promise然后挂起。co_return expr等价于调用promise.return_value(expr)并跳到final_suspend()。co_await expr则是要求expr满足“awaiter”接口await_ready()、await_suspend()、await_resume()。所以不必把co_yield当成一个独立机制它只是co_await的一种特殊用法。理解了这层关系再去看别人写的协程库思路会清晰很多。2. 从零实现一个最小 Generator接口、状态与调用循环标准库不提供现成的生成器所以自己写一个最小实现是理解协程调用约定最好的练习。下面这段代码可以作为一个入门骨架它不依赖任何第三方库只在 C20 编译环境中就能跑。2.1 一个既能产出又能遍历的 Generator#include coroutine #include exception #include iostream #include utility template typename T class Generator { public: struct promise_type { T current_value; std::exception_ptr eptr; Generator get_return_object() { return Generator{std::coroutine_handlepromise_type::from_promise(*this)}; } std::suspend_always initial_suspend() noexcept { return {}; } std::suspend_always final_suspend() noexcept { return {}; } void unhandled_exception() { eptr std::current_exception(); } void return_void() {} std::suspend_always yield_value(T value) { current_value std::move(value); return {}; } }; Generator(std::coroutine_handlepromise_type h) : h_(h) {} Generator(const Generator) delete; Generator operator(const Generator) delete; Generator(Generator other) noexcept : h_(other.h_) { other.h_ std::coroutine_handlepromise_type{}; } ~Generator() { if (h_) h_.destroy(); } bool next() { if (!h_) return false; h_.resume(); if (h_.promise().eptr) std::rethrow_exception(h_.promise().eptr); return !h_.done(); } T value() const { return h_.promise().current_value; } private: std::coroutine_handlepromise_type h_; }; Generatorint count_to(int n) { for (int i 1; i n; i) { co_yield i; } } int main() { auto gen count_to(5); while (gen.next()) { std::cout gen.value() ; } }这段代码的输出是1 2 3 4 5。我故意没有用 range-for 之类的语法目的是让next()、value()这种最原始的循环关系暴露出来。你把协程当作一个可以被外部手动驱动恢复的执行体一切就都说得通了。2.2 编译器在背后依次调用了哪些方法这段代码的执行顺序很多人第一次看会猜错。我建议你按下面这个时间线去读调用count_to(5)构造函数运行到第一个挂起点也就是co_yield i之前。在碰到任何挂起点之前编译器先调用promise.initial_suspend()。因为它返回suspend_always协程帧创建后立即挂起get_return_object()返回的 Generator 被交给main函数。main调用gen.next()内部调用h_.resume()。协程从初始挂起点继续循环执行到第一次co_yield i调用promise.yield_value(1)把current_value设为 1然后挂起。h_.resume()返回后h_.done()为 falsenext()返回 true。main读取gen.value()得到 1。第二次调用next()协程从上次挂起点继续i 递增到 2再次遇到co_yield 2再次挂起。循环结束后协程执行co_return编译器调用return_void()然后走到final_suspend()。由于它是suspend_always协程挂起在最终挂起点h_.done()变成 true。此时next()返回 false循环结束。main结束时Generator 析构调用h_.destroy()协程帧被释放。如果你在某一步断点处查看调用栈会发现next()里的resume()返回后协程的局部变量i并没有消失它活在那个堆上的协程帧里。这是无栈协程最常见的认知突破点。2.3 手写 Generator 时最常见的三个编译错误第一忘记声明promise_type。协程函数的返回值类型里必须能查到一个嵌套的promise_type否则编译器直接报错。第二co_yield出现了但promise_type里没有yield_value方法。第三同时定义了return_void()和return_value()编译器不允许你同时选择两种结束方式。这些错误通常不会在一开始暴露而是当你改动协程体时突然冒出来所以记住它们的典型报错形式排查会快很多。写 Generator 还有一个容易忽略的点value()返回的是T值如果T是拷贝昂贵的对象建议改成const T。但改成引用后必须保证协程帧的生命周期覆盖这个引用的使用区间否则就是悬空引用。小类型的值拷贝反而更安全。3. co_await 挂起与恢复的完整链路从 awaiter 到跨线程调度如果说co_yield让你理解了“挂起”的概念那么co_await才是异步协程真正的主角。一个表达式能作为co_await的操作数必须满足 awaiter 三件套。这三件套就是编译器与异步运行时之间的握手协议。3.1 awaiter 接口的三步握手await_ready()决定协程是否需要真正挂起。如果异步结果已经准备好了返回 true那么co_await直接跳过挂起就好像从来没有发生过一样。这就是某些异步 API 可能同步完成时的优化手段省掉一次无意义的挂起和恢复。如果await_ready()返回 false编译器调用await_suspend(handle)。这里的handle是当前协程的std::coroutine_handle。你可以把它保存下来交给线程池、事件循环或者回调函数等时机到了再通过它恢复协程。await_suspend()返回后控制权就回到了当初resume()协程的那个地方。协程被挂起直到某个地方再次调用handle.resume()。协程被恢复后编译器调用await_resume()。这个函数的返回值就是co_await expr这个表达式整体的值。如果异步操作需要把结果带给协程就存放在某个共享位置然后在await_resume()里取出来返回。这三步和普通函数调用完全不同。普通函数返回值是在函数返回时交给调用者的而co_await的返回值是在协程被以不同线程、不同时间点恢复后才计算出来的。这正是协程异步模型的核心差异。3.2 一个最简单的“延迟挂起”示例我写一个简化到只展示原理的 DelayAwaiter它模拟协程被挂起后交给一个后台线程执行等一段时间后再恢复。这里用std::thread只是为了演示真实项目里请务必使用线程池或事件循环不要每次挂起都 untaught 创建线程。struct DelayAwaiter { std::chrono::milliseconds delay; bool await_ready() const noexcept { return delay.count() 0; } void await_suspend(std::coroutine_handle h) const { std::thread([h, delay this-delay]() { std::this_thread::sleep_for(delay); h.resume(); }).detach(); } void await_resume() const noexcept {} }; Generatorint delayed_count() { co_await DelayAwaiter{std::chrono::milliseconds(100)}; co_yield 42; }调用next()时协程在co_await处挂起控制权从h_.resume()返回。100 毫秒后后台线程调用h.resume()协程从挂起点恢复继续执行co_yield 42然后再次挂起。这里最值得注意的一点是恢复协程所在线程和挂起协程所在线程完全可以是不同的。协程体不会感知到这个变化因为它的状态都存在协程帧里。跨线程恢复不意味着线程安全自动成立。如果协程体内访问一个从外部栈上捕获的引用变量就可能有两个线程交替访问同一块栈内存如果访问的是帧内局部变量则帧只被当前恢复者独占只要没有并发 resume 同一个句柄就是安全的。这也是协程框架中“帧内共享状态需要加锁”的本质原因。3.3 await_suspend 三种返回值的区别await_suspend可以返回void、bool或者另一个std::coroutine_handle。返回void最简单挂起后把控制权交还给恢复者返回bool可以让协程在挂起前再检查一次状态false 表示不挂起直接继续true 表示正常挂起返回一个协程句柄时编译器会立即恢复那个句柄对应的协程这种机制称为对称转移。对称转移适合用来实现协程之间的显式让权避免用嵌套 resume 把调用栈堆得太高。如果你只是想挂起当前协程不要轻易返回别的句柄否则恢复链会变得难调试。4. 协程生命周期暗坑悬空句柄、final_suspend 和递归协程语法本身不复杂复杂的是帧和句柄的存活期。我在自己写的协程实验里踩过很多崩溃几乎都集中在生命周期问题上。下面这几个坑是任何协程框架都绕不开的。4.1 参数按引用传进协程别以为已经被复制了协程帧会保存参数但这个“保存”遵循普通函数的参数传递规则。如果形参是const T那么帧里保存的是引用而不是对象的副本。比如下面的写法Generatorint range(const int n) { for (int i 0; i n; i) { co_yield i; } } int main() { for (int k 0; k 5; k) { auto gen range(k); // ... } }隐患很明显k是main函数栈上的局部变量协程帧里保存的是对k的引用。如果你在k的下一个生命周期阶段才恢复协程读到的值可能已经变了甚至引用的内存已经失效。修复方案很简单形参按值传递或者在协程体最开头把引用参数复制进一个局部变量。局部变量会保存在协程帧中生命周期和帧一致。4.2 final_suspend 返回 suspend_always 到底为了什么很多第一次写 task 的人会把final_suspend()写成suspend_never理由是“协程结束了就该立刻清理”。但这样做会让协程在完成后立即销毁协程帧。如果异步 task 需要消费者在协程完成之后从 Promise 里读取返回值帧已经没了读到的就是悬空数据。标准库至今没有给出现成的 task一部分原因就在这里库作者必须自己决定帧释放权归谁。业内常见的做法是final_suspend()返回suspend_always让协程完成时挂起在最终点由持有者的 RAII 类在合适的时机调用destroy()。这时候读取返回值、检查异常都是安全的。如果返回suspend_never协程帧会在协程结束时自动销毁省了一次显式 destroy但你必须保证没有人在销毁后还访问帧内的任何对象。这个前提在异步框架里很难成立所以大多数人选择显式管理。4.3 resume 与 destroy 顺序错乱引起的崩溃协程句柄是一个轻量指针它本身没有引用计数。同一个句柄被复制到多处任何一处调用destroy()另外一处的句柄都变成悬空句柄。如果再有人用这个悬空句柄调用resume()程序基本会直接崩溃。我的建议是句柄只存放在唯一的 RAII 拥有者中你不要随手把裸句柄往回调里塞。上面 Generator 示例里把句柄封装进类就是出于这个原因。如果你的场景确实需要多处共享可以给协程帧加一个引用计数壳或者在句柄两侧做好严格的有效性标记。4.4 递归协程仍然有可能爆栈无栈协程的“无栈”指的是每个协程没有独立调用栈而不是说协程递归调用不会撑爆系统栈。协程 A 通过 co_await 依赖协程 BB 再依赖 C在恢复链路上仍然是一层一层嵌套的resume()调用函数调用深度会累积。所以如果你写了一个递归遍历目录的协程深目录下照样可能栈溢出。协程帧本身在堆上但恢复时的调用关系仍然消耗系统栈。遇到这种情况要么改成显式栈结构要么用有栈协程要么限制递归深度。5. 实战把回调式异步接口封装成 co_await 接口只看理论不够我拿一个具体的场景说假设你手上有一个第三方异步接口回调风格设计如下void async_read(int fd, char* buffer, size_t size, std::functionvoid(int) callback);它读取完成后调用回调回调参数是实际读取的字节数。用回调嵌套写多个步骤很快会变成“回调地狱”。协程接口的设计目标是让调用方这样写int n co_await AsyncRead{fd, buffer, sizeof(buffer)};你不需要修改那个第三方接口只需要写一个 awaiter把回调世界和协程世界衔接起来。5.1 一个通用 CallbackAwaiter 的骨架template typename TResult struct CallbackAwaiter { TResult result; std::coroutine_handle handle; bool completed false; bool await_ready() const noexcept { return completed; } void await_suspend(std::coroutine_handle h) { handle h; } TResult await_resume() { return result; } void set_result(TResult value) { result std::move(value); completed true; if (handle) { handle.resume(); } } };一个典型用法是在协程体内创建CallbackAwaiterint调用async_read(..., callback)把回调绑定到awaiter.set_result。这里有个细节很关键awaiter是协程体内部的局部变量它本身存放在协程帧里。异步接口持有这个 awaiter 的引用只要协程帧还没被销毁这个引用就是安全的。如果异步接口在co_await awaiter执行到await_suspend之前就同步完成了回调那么set_result会先把completed置为 true此时handle还没被赋值所以不会提前 resume。等协程真正执行到co_await时await_ready()发现结果已就绪直接不挂起马上取回结果。这个设计让同一个 awaiter 同时兼容同步完成和异步完成。5.2 多线程回调的注意点真实项目中异步接口的回调可能来自任意线程所以set_result和协程体内的await_resume之间会有数据竞争。答案不是“用不用协程”而是“回调线程在写协程恢复后在读”这本身就是并发读写。常规做法是给result/completed/handle加锁或用原子变量加消息队列。协程只是把控制流表达顺了没有自动解决线程同步问题。5.3 异常如何穿过协程边界如果异步操作失败回调通常传一个错误码你可以自己转成异常。处理方式是在 Promise 里保存std::exception_ptr在unhandled_exception()里保存当前异常然后在await_resume()里重新抛出。这样异常会出现在触发恢复的那个协程的co_await表达式处看起来就像同步调用在出错处直接抛出异常。当然也可以绕过异常改用expectedT, Error之类的返回式错误处理。哪种顺手用哪种。协程不强制你选异常但语法上给了你完整的传播通道需要调用方认真设计传播规则。6. 动手之前先把这几件事权衡清楚C20 协程的设计思路是编译器提供挂起和恢复的原语但调度器、任务类型、生命周期策略全部由库作者决定。标准库给的是零件不是整机。开始正式项目前有四个方向值得花时间想清楚。6.1 你可以用协程做什么以及标准库还缺什么标准库目前能直接依赖的协程组件非常少。C23 加入了std::generator但异步 task、调度器、io_uring 封装这种东西仍然要靠第三方库或自己实现。这不是标准委员会偷懒而是因为调度方式没有统一答案桌面平台、嵌入式、游戏引擎的线程模型完全不同无法用一套调度器通吃。如果你需要的是一个最简单的惰性生成器std::generator已经够用。如果你需要的是高性能异步 I/O 框架那就要看库的调度模型和你的线程池策略是否匹配。无论哪种理解帧生命周期都是绕不开的基础功。6.2 无栈协程和有栈协程怎么选有栈协程由第三方库提供每个协程有自己的调用栈挂起时可以把正在执行的任意函数调用链都保存下来。无栈协程则只能在声明为协程的函数体内部挂起普通函数调用链不会随协程挂起而保存。我列一个简单对比表维度无栈协程有栈协程挂起粒度只能在协程函数体内 co_await 点挂起调用链上的任意函数都可整体挂起切换开销低通常只是状态切换与寄存器保存略高需要完整栈上下文切换内存占用协程帧按需分配通常较小每个协程需要独立栈几十 KB 起步实现复杂度生命周期管理在帧需要自己设计所有权栈管理由库内部完成使用相对简单适用场景异步 I/O、生成器、流水线脚本系统、复杂递归现场保存C20 的无栈协程在大多数异步场景下是更合理的选择但如果你需要挂起一个深度递归函数有栈协程是更直接的工具。6.3 调试、栈回溯与验证手段调试协程和调试普通函数不一样。协程帧在堆上你在initial_suspend()打上断点能看到帧的地址但看不到调用栈里协程调用方的完整上下文。我调试协程时常用两个办法第一在await_suspend()和resume()的入口各自打条件断点观察句柄地址是否重复第二用地址监测工具盯住协程帧一旦destroy()后再访问立刻能定位崩溃点。性能验证也不能只看“用了协程就快”。首帧堆分配是有成本的如果每个请求都创建一个新帧高频场景下分配开销可能吃掉协程切换省下的时间。先测量再优化不急着给帧写对象池。对象池如果没处理好帧释放时机反而引入更难查的悬空 bug。如果让我给后来者一个建议那就是别急着封装华丽的任务库先从一个最小 Generator 开始用两三周时间把帧、句柄和恢复链路揉碎了再回去看别人的实现会轻松很多。协程这套机制代码量不大但它把“控制流所有权”交到了你手里理解这一点比记住任何语法细节都重要。