非对称表示协程控制权的转移是单向的,即通过 co_await/co_yield 挂起时,必需返回到调用者最初的上下文,而不能随意切换到其它协程,这样做逻辑清晰,便于调试。
C++20 协程相对的缺点就是概念繁多、过于灵活,特别是编译器在底层默默的做了很多工作,使得调用链经常断掉不好理解,之前的文章讲到原理就草草贴了几张流程图了事,今天要把这个原理掰开了好好说道一番。
讲 C++20 协程,除了协程本身的复杂性,还有新标准带来的新特性,每次新的标准面世,就像是换了个语言,各种语法糖能大大提升开发效率,但也提升了理解成本。以插入 map 元素这个小功能为例,看看各个标准是如何演化的。
我们知道,std::map 在 insert 时如果元素已经存在是不会替换元素的,而是返回一个指示元素所在位置的 iterator 和是否插入成功的标志:
#include <iostream>
#include <map>
int main() {
std::map<int, int> mp;
// mp.insert(std::make_pair(1, 2));
std::pair<std::map<int, int>::iterator, bool> result = mp.insert(std::make_pair(1, 1));
if (result.second)
std::cout << "inserted" << std::endl;
for (std::map<int, int>::iterator itr = mp.begin(); itr != mp.end(); ++itr) {
std::cout << "{" << itr->first << ", " << itr->second << "}" << std::endl;
}
return 0;
}
输出:
inserted
{1, 1}
这是 C++98 标准就支持的语法,map::insert 返回值为 std::pair,其 first 为容器 iterator 用于标识插入或已有元素位置,其 second 为 bool 表示是否插入成功。下面看下 C++11 的改进:
#include <iostream>
#include <map>
#include <tuple>
int main() {
std::map<int, 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 还展示了 C++11 引入的其它特性,如:
* 聚合初始化 :std::map<int, int> mp; // = { {1,1} }; & mp.insert({1, 1});
* 类型自动推导:// for (auto itr = mp.begin(); itr != mp.end(); ++itr)
* 范围 for 循环:for(auto itr : mp)
等。下面看下 C++17 的改进:
#include <iostream>
#include <map>
int main()
{
std::map<int, 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;
}
输出不变。相比 C++17,这里连 inserted 变量也不需要定义了,通过结构化绑定,直接原地定义返回的两个分量 (itr & inserted);另外在遍历 map 元素时,也通过结构化绑定直接获取 first & second (k & v),代码更简洁了。但对于一个不怎么关注新标准的老鸟,这是不是就有阅读障碍了?加之这种语言层面的变动多而细碎,如果打算先了解语法再深入协程,就很容易导致从入门到放弃的学习过程。
为了将这个先有鸡先有蛋的乱麻问题破解掉,本文遵循以下原则:
* 以协程为目标,涉及到的新语法会简单说明,不涉及的不旁征博引
* 若语法的原理非常简单,也会简单展开讲讲,有利于了解其本质
另外选取合适的 demo 也非常重要,太复杂的一下讲不清容易有挫折感,太简单的看了不知道有何用处也是一头雾水,本文选取的 demo 将在贴合实际的基础上尽量简化,以突出问题核心。
最后说说工具的问题,自己搭建环境费时费力,现成的则不一定有合适的编译器版本,这里推荐两个工具:
* Compile Explorer:在线编译 C++ 代码工具,查看汇编结果与运行结果,可切换编译器及版本、增加编译选项

* C++ Insights:也是编译工具,但不是生成汇编代码而是 C++ 表达的中间代码,可以用来查看 C++ 编译器底层做的一些工作,对于本文的主题 C++20 协程至关重要

其实好多语法糖丢这里可以一眼露馅,比如上面的结构化绑定,其实在底层用的还是 std::pair,只不过编译器帮你省略了繁锁的细节,这比看反汇编是直观多了。
协程本质
在进入 C++20 协程之前,有必要搞懂协程本身是什么,它能让出控制权、能继续执行、没有线程栈的切换,看起来似乎很神奇,一般函数可没有这个能力。

早年间 C++17 的协程就是通过 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,而协程本质就是函数+状态机,这个之前文章里都已经说过了,那 C++20 协程有本质不同吗?答案是没区别。下面来看一个典型的 C++20 协程例子,并根据编译器中间结果来印证上面的结论。
#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_handle<promise_type> handle;
Generator(promise_type* p) : handle(std::coroutine_handle<promise_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 是关键,称作返回对象 ,它要实现一系列接口,可以看做是 C++20 协程与用户的一个约定,这点就如同任意一个 C++ 类,实现了 operator() 接口就能被当作函数对象一样。凡是写 C++20 协程,必离不开返回对象,它内部又有两个约定:
* struct promise_type,承诺对象。定义于返回对象内部的 traits 类型,用于定制协程行为,由用户实现,会被协程体访问
* std::coroutine_handle<promise_type> handle,协程句柄。用于控制协程体的运行,由编译器实现,用户访问
这里暂不展开解释 Generator 的各个成员功用,反正就把它当成一个模板,写协程抄上就完事儿。
先了解下 main 是如何运转起来的,主要关注Generator::next 方法:
int main() {
auto gen = range(1, 5);
while (gen.next()) {
std::cout << gen.value() << std::endl;
}
}
它通过协程句柄的resume&done来驱动协程运转:
bool next() { return !handle.done() && (handle.resume(), !handle.done()); }
main 其实就是 next 的循环,直到协程彻底完结,因此 demo 实际上演示了协程的 5 次进入和 5 次离开。
demo 底层是如何实现的?循环变量是如何恢复的?带着这些疑问,有请 C++ Insights 上场,看看这个 demo 的原形 (注意开启 Show coroutine transformation 选项):
查看代码
内容比较长,从头到尾分块解析一下。
