协程本质是函数加状态机——零基础深入浅出 C++20 协程

📅 2026/7/3 3:44:26
协程本质是函数加状态机——零基础深入浅出 C++20 协程
非对称表示协程控制权的转移是单向的即通过 co_await/co_yield 挂起时必需返回到调用者最初的上下文而不能随意切换到其它协程这样做逻辑清晰便于调试。C20 协程相对的缺点就是概念繁多、过于灵活特别是编译器在底层默默的做了很多工作使得调用链经常断掉不好理解之前的文章讲到原理就草草贴了几张流程图了事今天要把这个原理掰开了好好说道一番。讲 C20 协程除了协程本身的复杂性还有新标准带来的新特性每次新的标准面世就像是换了个语言各种语法糖能大大提升开发效率但也提升了理解成本。以插入 map 元素这个小功能为例看看各个标准是如何演化的。我们知道std::map 在 insert 时如果元素已经存在是不会替换元素的而是返回一个指示元素所在位置的 iterator 和是否插入成功的标志#include iostream #include map int main() { std::mapint, int mp; // mp.insert(std::make_pair(1, 2)); std::pairstd::mapint, int::iterator, bool result mp.insert(std::make_pair(1, 1)); if (result.second) std::cout inserted std::endl; for (std::mapint, int::iterator itr mp.begin(); itr ! mp.end(); itr) { std::cout { itr-first , itr-second } std::endl; } return 0; }输出inserted {1, 1}这是 C98 标准就支持的语法map::insert 返回值为 std::pair其 first 为容器 iterator 用于标识插入或已有元素位置其 second 为 bool 表示是否插入成功。下面看下 C11 的改进#include iostream #include map #include tuple int main() { std::mapint, int mp; // { {1,3} }; bool inserted; std::tie(std::ignore, inserted) mp.insert({1, 1}); if (inserted) std::cout inserted std::endl; // for (auto itr mp.begin(); itr ! mp.end(); itr) { for(auto itr : mp) { std::cout { itr.first , itr.second } std::endl; } return 0; }输出一致。主要改进在于通过 tie 将 inserted 变量绑定到返回的 tuple 结构中 (pair 也是 tuple 的一种)之后直接引用 inserted 变量而不是不明就里的 first second代码可读性更强了并且没有额外的对象拷贝。这个 demo 还展示了 C11 引入的其它特性如* 聚合初始化 std::mapint, int mp; // { {1,1} };mp.insert({1, 1});* 类型自动推导// for (auto itr mp.begin(); itr ! mp.end(); itr)* 范围 for 循环for(auto itr : mp)等。下面看下 C17 的改进#include iostream #include map int main() { std::mapint, int map; // { {1,4} }; auto [itr, inserted] map.insert({ 1, 1 }); if (inserted) std::cout inserted std::endl; for (auto [k, v] : map) std::cout { k , v } std::endl; }输出不变。相比 C17这里连 inserted 变量也不需要定义了通过结构化绑定直接原地定义返回的两个分量 (itr inserted)另外在遍历 map 元素时也通过结构化绑定直接获取 first second (k v)代码更简洁了。但对于一个不怎么关注新标准的老鸟这是不是就有阅读障碍了加之这种语言层面的变动多而细碎如果打算先了解语法再深入协程就很容易导致从入门到放弃的学习过程。为了将这个先有鸡先有蛋的乱麻问题破解掉本文遵循以下原则* 以协程为目标涉及到的新语法会简单说明不涉及的不旁征博引* 若语法的原理非常简单也会简单展开讲讲有利于了解其本质另外选取合适的 demo 也非常重要太复杂的一下讲不清容易有挫折感太简单的看了不知道有何用处也是一头雾水本文选取的 demo 将在贴合实际的基础上尽量简化以突出问题核心。最后说说工具的问题自己搭建环境费时费力现成的则不一定有合适的编译器版本这里推荐两个工具* Compile Explorer在线编译 C 代码工具查看汇编结果与运行结果可切换编译器及版本、增加编译选项* C Insights也是编译工具但不是生成汇编代码而是 C 表达的中间代码可以用来查看 C 编译器底层做的一些工作对于本文的主题 C20 协程至关重要其实好多语法糖丢这里可以一眼露馅比如上面的结构化绑定其实在底层用的还是 std::pair只不过编译器帮你省略了繁锁的细节这比看反汇编是直观多了。协程本质在进入 C20 协程之前有必要搞懂协程本身是什么它能让出控制权、能继续执行、没有线程栈的切换看起来似乎很神奇一般函数可没有这个能力。早年间 C17 的协程就是通过 duff device (switch case) 实现的void fn(){ int a, b, c; a b c; yield(); b c a; yield(); c a b; }其中 yield 就是协程让出控制权的点位转换后变为这样Struct fn{ int a, b, c; int __state 0; void resume(){ switch(__state) { case 0: return fn1(); case 1: return fn2(); case 2: return fn3(); } } void fn1(){ a b c; __state ; } void fn2(){ b c a; __state ; } void fn3(){ c a b; __state ; } };所以 yield 其实就是函数 return而协程本质就是函数状态机这个之前文章里都已经说过了那 C20 协程有本质不同吗答案是没区别。下面来看一个典型的 C20 协程例子并根据编译器中间结果来印证上面的结论。#include coroutine #include iostream struct Generator { struct promise_type { int current_value; auto get_return_object() { return Generator{this}; } auto initial_suspend() { return std::suspend_always{}; } auto final_suspend() noexcept { return std::suspend_always{}; } void unhandled_exception() {} auto yield_value(int value) { current_value value; return std::suspend_always{}; } }; std::coroutine_handlepromise_type handle; Generator(promise_type* p) : handle(std::coroutine_handlepromise_type::from_promise(*p)) {} ~Generator() { if (handle) handle.destroy(); } bool next() { return !handle.done() (handle.resume(), !handle.done()); } int value() { return handle.promise().current_value; } }; Generator range(int from, int to) { for (int i from; i to; i) { co_yield i; } } int main() { auto gen range(1, 5); while (gen.next()) { std::cout gen.value() std::endl; } }这个例子演示了一个数列生成器运行有如下输出1 2 3 4 5其中协程体range 十分短小精悍Generator range(int from, int to) { for (int i from; i to; i) { co_yield i; } }通过 co_yeild 不停的返回数列值。协程的返回类型Generator是关键称作返回对象它要实现一系列接口可以看做是 C20 协程与用户的一个约定这点就如同任意一个 C 类实现了operator()接口就能被当作函数对象一样。凡是写 C20 协程必离不开返回对象它内部又有两个约定*struct promise_type承诺对象。定义于返回对象内部的 traits 类型用于定制协程行为由用户实现会被协程体访问*std::coroutine_handlepromise_type handle协程句柄。用于控制协程体的运行由编译器实现用户访问这里暂不展开解释Generator的各个成员功用反正就把它当成一个模板写协程抄上就完事儿。先了解下 main 是如何运转起来的主要关注Generator::next方法int main() { auto gen range(1, 5); while (gen.next()) { std::cout gen.value() std::endl; } }它通过协程句柄的resumedone来驱动协程运转bool next() { return !handle.done() (handle.resume(), !handle.done()); }main 其实就是 next 的循环直到协程彻底完结因此 demo 实际上演示了协程的 5 次进入和 5 次离开。demo 底层是如何实现的循环变量是如何恢复的带着这些疑问有请 C Insights 上场看看这个 demo 的原形 (注意开启Show coroutine transformation选项)查看代码内容比较长从头到尾分块解析一下。