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

非对称表示协程控制权的转移是单向的,即通过 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 选项):

查看代码

复制代码

内容比较长,从头到尾分块解析一下。

相关推荐
精明的身影1 小时前
C++自学之路1:Hello world
开发语言·c++
旖-旎1 小时前
《LeetCode 64 最小路径和 || LeetCode 174 地下城游戏》
c++·算法·leetcode·动态规划
森林古猿12 小时前
再论斜率优化
c++·学习·算法
万法若空2 小时前
【数学-简单数论】同余中的逆
线性代数·算法
水利行业RTU手艺人2 小时前
RTU固件中的“数据保险箱”——离线数据补发系统设计
c++·stm32·单片机·物联网
凯瑟琳.奥古斯特3 小时前
力扣1009补码解法C++实现
开发语言·c++·算法·leetcode·职场和发展
KaMeidebaby3 小时前
卡梅德生物技术快报|蛋白的叠氮基修饰:实操解析:核酸模板耦合蛋白的叠氮基修饰实现靶蛋白定点共价标记
前端·人工智能·物联网·算法·百度
通信仿真爱好者4 小时前
第【60期】--大规模MIMO系统信号检测算法误码率比较 --matlab完整代码+参考文章
算法·matlab·mimo·信号检测
AI科技星4 小时前
全域谱分析:无穷维超复数信息场分形统一场论——自然、量子、金融多重分形第一性原理完整体系(中英双语终稿)
人工智能·python·算法·金融·乖乖数学·全域数学