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

前言

之前写过一篇 C++20 协程入门的文章:《使用 C++ 20 协程降低异步网络编程复杂度》,谈到了协程在消除异步编程 callback hell 方面的重要贡献------使代码更清晰,易于维护;以及 C++20 协程具有无栈、非对称等特性。无栈协程 具有不受预分配栈空间约束、切换类似函数开销更小的优点,符合 C++ 语言设计原则中的 no payload 理念 (不因新增加的语言特性而增加额外性能负担);非对称表示协程控制权的转移是单向的,即通过 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 底层是如何实现的?循环变量是如何恢复的?带着这些疑问,有请 Insights C++ 上场,看看这个 demo 的原形 (注意开启 Show coroutine transformation 选项):
查看代码

复制代码
 /*************************************************************************************
 * NOTE: The coroutine transformation you've enabled is a hand coded transformation! *
 *       Most of it is _not_ present in the AST. What you see is an approximation.   *
 *************************************************************************************/
#include <coroutine>
#include <iostream>

struct Generator
{
  struct promise_type
  {
    int current_value;
    inline Generator get_return_object()
    {
      return Generator{this};
    }
    
    inline std::suspend_always initial_suspend()
    {
      return std::suspend_always{};
    }
    
    inline std::suspend_always final_suspend() noexcept
    {
      return std::suspend_always{};
    }
    
    inline void unhandled_exception()
    {
    }
    
    inline std::suspend_always yield_value(int value)
    {
      this->current_value = value;
      return std::suspend_always{};
    }
    
    // inline constexpr promise_type() noexcept = default;
  };
  
  std::coroutine_handle<promise_type> handle;
  inline Generator(promise_type * p)
  : handle{std::coroutine_handle<promise_type>::from_promise(*p)}
  {
  }
  
  inline ~Generator() noexcept
  {
    if(this->handle.operator bool()) {
      this->handle.destroy();
    } 
    
  }
  
  inline bool next()
  {
    return !this->handle.done() && (this->handle.resume() , !this->handle.done());
  }
  
  inline int value()
  {
    return this->handle.promise().current_value;
  }
  
};


struct __rangeFrame
{
  void (*resume_fn)(__rangeFrame *);
  void (*destroy_fn)(__rangeFrame *);
  std::__coroutine_traits_impl<Generator>::promise_type __promise;
  int __suspend_index;
  bool __initial_await_suspend_called;
  int from;
  int to;
  int i;
  std::suspend_always __suspend_24_11;
  std::suspend_always __suspend_26_9;
  std::suspend_always __suspend_24_11_1;
};

Generator range(int from, int to)
{
  /* Allocate the frame including the promise */
  /* Note: The actual parameter new is __builtin_coro_size */
  __rangeFrame * __f = reinterpret_cast<__rangeFrame *>(operator new(sizeof(__rangeFrame)));
  __f->__suspend_index = 0;
  __f->__initial_await_suspend_called = false;
  __f->from = std::forward<int>(from);
  __f->to = std::forward<int>(to);
  
  /* Construct the promise. */
  new (&__f->__promise)std::__coroutine_traits_impl<Generator>::promise_type{};
  
  /* Forward declare the resume and destroy function. */
  void __rangeResume(__rangeFrame * __f);
  void __rangeDestroy(__rangeFrame * __f);
  
  /* Assign the resume and destroy function pointers. */
  __f->resume_fn = &__rangeResume;
  __f->destroy_fn = &__rangeDestroy;
  
  /* Call the made up function with the coroutine body for initial suspend.
     This function will be called subsequently by coroutine_handle<>::resume()
     which calls __builtin_coro_resume(__handle_) */
  __rangeResume(__f);
  
  
  return __f->__promise.get_return_object();
}

/* This function invoked by coroutine_handle<>::resume() */
void __rangeResume(__rangeFrame * __f)
{
  try 
  {
    /* Create a switch to get to the correct resume point */
    switch(__f->__suspend_index) {
      case 0: break;
      case 1: goto __resume_range_1;
      case 2: goto __resume_range_2;
      case 3: goto __resume_range_3;
    }
    
    /* co_await insights.cpp:24 */
    __f->__suspend_24_11 = __f->__promise.initial_suspend();
    if(!__f->__suspend_24_11.await_ready()) {
      __f->__suspend_24_11.await_suspend(std::coroutine_handle<Generator::promise_type>::from_address(static_cast<void *>(__f)).operator std::coroutine_handle<void>());
      __f->__suspend_index = 1;
      __f->__initial_await_suspend_called = true;
      return;
    } 
    
    __resume_range_1:
    __f->__suspend_24_11.await_resume();
    for(__f->i = __f->from; __f->i <= __f->to; ++__f->i) {
      
      /* co_yield insights.cpp:26 */
      __f->__suspend_26_9 = __f->__promise.yield_value(__f->i);
      if(!__f->__suspend_26_9.await_ready()) {
        __f->__suspend_26_9.await_suspend(std::coroutine_handle<Generator::promise_type>::from_address(static_cast<void *>(__f)).operator std::coroutine_handle<void>());
        __f->__suspend_index = 2;
        return;
      } 
      
      __resume_range_2:
      __f->__suspend_26_9.await_resume();
    }
    
    goto __final_suspend;
  } catch(...) {
    if(!__f->__initial_await_suspend_called) {
      throw ;
    } 
    
    __f->__promise.unhandled_exception();
  }
  
  __final_suspend:
  
  /* co_await insights.cpp:24 */
  __f->__suspend_24_11_1 = __f->__promise.final_suspend();
  if(!__f->__suspend_24_11_1.await_ready()) {
    __f->__suspend_24_11_1.await_suspend(std::coroutine_handle<Generator::promise_type>::from_address(static_cast<void *>(__f)).operator std::coroutine_handle<void>());
    __f->__suspend_index = 3;
    return;
  } 
  
  __resume_range_3:
  __f->destroy_fn(__f);
}

/* This function invoked by coroutine_handle<>::destroy() */
void __rangeDestroy(__rangeFrame * __f)
{
  /* destroy all variables with dtors */
  __f->~__rangeFrame();
  /* Deallocating the coroutine frame */
  /* Note: The actual argument to delete is __builtin_coro_frame with the promise as parameter */
  operator delete(static_cast<void *>(__f), sizeof(__rangeFrame));
}


int main()
{
  Generator gen = range(1, 5);
  while(gen.next()) {
    std::cout.operator<<(gen.value()).operator<<(std::endl);
  }
  
  return 0;
}

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

承诺对象部分,一对一,比较直观,不多做解释了。

返回对象部分,也是如此,其中包含了协程句柄和一些自定义的接口。

注意返回对象的构造函数,接收一个承诺对象指针,并将其设置到协程句柄,该参数将由编译器构造并传入。

复制代码
struct __rangeFrame
{
  void (*resume_fn)(__rangeFrame *);
  void (*destroy_fn)(__rangeFrame *);
  std::__coroutine_traits_impl<Generator>::promise_type __promise;
  int __suspend_index;
  bool __initial_await_suspend_called;
  int from;
  int to;
  int i;
  std::suspend_always __suspend_24_11;
  std::suspend_always __suspend_26_9;
  std::suspend_always __suspend_24_11_1;
};

__rangeFrame称为**协程状态,**是由编译器生成的,没有对应的用户代码,用于保存协程体相关的必要信息:

* resume_fn&destroy_fnresume&destroy 回调,用于继续执行或销毁协程体

* __promise:用户提供的承诺对象,用于定制协程行为

* __suspend_index:duff device 状态机的状态值

* __initial_await_suspend_called:不重要忽略

* from&to&i:协程体参数 & 栈变量

* __suspend_24_11&__suspend_26_9&__suspend_24_11_1:协程挂起点的等待对象

下面看看它是如何初始化的:

复制代码
Generator range(int from, int to)
{
  /* Allocate the frame including the promise */
  /* Note: The actual parameter new is __builtin_coro_size */

构建协程状态

复制代码
  __rangeFrame * __f = reinterpret_cast<__rangeFrame *>(operator new(sizeof(__rangeFrame)));

初始化状态机

复制代码
  __f->__suspend_index = 0;
  __f->__initial_await_suspend_called = false;

初始化协程参数

复制代码
  __f->from = std::forward<int>(from);
  __f->to = std::forward<int>(to);

原地构建承诺对象 (只调构建函数不分配内存)

复制代码
  /* Construct the promise. */
  new (&__f->__promise)std::__coroutine_traits_impl<Generator>::promise_type{};

初始化协程控制接口

复制代码
  /* Forward declare the resume and destroy function. */
  void __rangeResume(__rangeFrame * __f);
  void __rangeDestroy(__rangeFrame * __f);
  
  /* Assign the resume and destroy function pointers. */
  __f->resume_fn = &__rangeResume;
  __f->destroy_fn = &__rangeDestroy;

协程的第一次进入,就交给 resume 吧

复制代码
  /* Call the made up function with the coroutine body for initial suspend.
     This function will be called subsequently by coroutine_handle<>::resume()
     which calls __builtin_coro_resume(__handle_) */
  __rangeResume(__f);

协程的第一次离开,需要 return 返回对象

复制代码
  return __f->__promise.get_return_object();
}

注意原协程函数已经被掏空,实际上放置的是协程状态等各种对象的初始化代码,协程的第一次进入是通过直接调用协程体的 resume 完成的,因此重点其实转移到了 __rangeResume 这个编译器生成的方法中,这个稍后详述。接着往下就是离开协程的 return 语句,它会通过promise_type::get_return_object来返回返回对象:

复制代码
        auto get_return_object() { return Generator{this}; }

这里用到了聚合初始化,将 this 代表的承诺对象传递给了返回对象:

复制代码
    Generator(promise_type* p) : handle(std::coroutine_handle<promise_type>::from_promise(*p)) {}

返回对象会将承诺对象保存在协程句柄中以待后用,这个代码之前贴过就不赘述了。

上面这张图展示了协程体、协程状态、承诺对象、返回对象、协程句柄之间的关系,这里补充一下协程句柄coroutine_handle的代码:

复制代码
template<> struct coroutine_handle<void> {
    void*    m_ptr;
    bool done() { return __builtin_coro_done(m_ptr); }
    void resume() { __builtin_coro_resume(m_ptr); }
    void operator()() { resume(); }
    void destroy() { __builtin_coro_destroy(m_ptr); }
    operator bool() { return bool(m_ptr); }
    void* address() { return m_ptr; }
    static coroutine_handle from_address(void*);
    coroutine_handle();
    coroutine_handle(std::nullptr_t);
    coroutine_handle& operator=(std::nullptr_t);
};

代码做了简化。与想象的不同,这里没有直接调用协程状态的 resume_fndestroy_fn,实际上也无法调用它们,毕竟协程句柄只有一个承诺对象还拿不到协程状态。

不过这也不是什么不可逾越的难事,基于结构体成员__promise的相对位置做计算是行得通的,想想看offsetof这种设施就明白了,只是就有点依赖编译器生成的结构体成员尺寸和顺序了。为了屏蔽这些细节,clang 中done``resume``destroy是委托给 __builtin_coro_done``__builtin_coro_resume``__builtin_coro_destroy这些内置函数的,MSVC 中是委托给_coro_done``_coro_resume``_coro_destroy,看得出来,这些实现是编译器相关的,不具备可移植性。

网上搜了一下,没有找到相关源码来一窥究竟,不过开个脑洞想象一下:resumedestroy比较简单,一对一调用__rangeResume__rangeDestroy就好了;done难搞一些,可能需要判断状态机当前值__suspend_index,贴个协程状态的定义回忆一下:

复制代码
struct __rangeFrame
{
  void (*resume_fn)(__rangeFrame *);
  void (*destroy_fn)(__rangeFrame *);
  std::__coroutine_traits_impl<Generator>::promise_type __promise;
  int __suspend_index;
  bool __initial_await_suspend_called;
  int from;
  int to;
  int i;
  std::suspend_always __suspend_24_11;
  std::suspend_always __suspend_26_9;
  std::suspend_always __suspend_24_11_1;
};

__suspend_index 被初始化为0,每切换一次状态递增 1,所以只要这个值达到上限,就能说明整个协程结束了。

下面来看协程的核心__rangeResume是否和预测的一样:

复制代码
/* This function invoked by coroutine_handle<>::resume() */
void __rangeResume(__rangeFrame * __f)
{

开幕雷击,这不就是个 duff device 吗,与 C++17 不同的是这里使用了 goto 分散代码

复制代码
  try 
  {
    /* Create a switch to get to the correct resume point */
    switch(__f->__suspend_index) {
      case 0: break;
      case 1: goto __resume_range_1;
      case 2: goto __resume_range_2;
      case 3: goto __resume_range_3;
    }

启动时挂起?是的话会发生第一次退出,本例中 promise_type::inistialze_suspend 返回的 suspend_always 会导致协程挂起,这个发生在 range 内部直调 __rangeResume 时

复制代码
    /* co_await insights.cpp:24 */
    __f->__suspend_24_11 = __f->__promise.initial_suspend();
    if(!__f->__suspend_24_11.await_ready()) {
      __f->__suspend_24_11.await_suspend(std::coroutine_handle<Generator::promise_type>::from_address(static_cast<void *>(__f)).operator std::coroutine_handle<void>());
      __f->__suspend_index = 1;
      __f->__initial_await_suspend_called = true;
      return;
    } 

__resume_range_1 标签,这个发生在 while 循环中的 Generator::next 恢复协程运行时,注意循环中使用的变量已经放在了协程状态 _f 中,因此循环是无缝恢复的

复制代码
    __resume_range_1:
    __f->__suspend_24_11.await_resume();
    for(__f->i = __f->from; __f->i <= __f->to; ++__f->i) {

co_yield 将调用 promise_type::yield_value 将生成结果保存在返回对象中,同时 yield_value 返回的 suspend_always 会导致协程挂起,从而让外部访问 value 内容。注意这个阶段 __suspend_index 一直保持 2 不变

复制代码
      /* co_yield insights.cpp:26 */
      __f->__suspend_26_9 = __f->__promise.yield_value(__f->i);
      if(!__f->__suspend_26_9.await_ready()) {
        __f->__suspend_26_9.await_suspend(std::coroutine_handle<Generator::promise_type>::from_address(static_cast<void *>(__f)).operator std::coroutine_handle<void>());
        __f->__suspend_index = 2;
        return;
      } 

__resume_range_2 标签,这个也发生在 while 循环中的 Generator::next 恢复协程运行时

复制代码
      __resume_range_2:
      __f->__suspend_26_9.await_resume();
    }
    
    goto __final_suspend;

整个 for 循环期间抛出的任何异常都会被编译器捕获,并回调 promise_type::unhandled_exception 处理

复制代码
  } catch(...) {
    if(!__f->__initial_await_suspend_called) {
      throw ;
    } 
    
    __f->__promise.unhandled_exception();
  }

结束时挂起?是的话会发生最后一次退出,本例中 promise_type::final_suspend 给的 suspend_always 会导致协程挂起

复制代码
  __final_suspend:
  
  /* co_await insights.cpp:24 */
  __f->__suspend_24_11_1 = __f->__promise.final_suspend();
  if(!__f->__suspend_24_11_1.await_ready()) {
    __f->__suspend_24_11_1.await_suspend(std::coroutine_handle<Generator::promise_type>::from_address(static_cast<void *>(__f)).operator std::coroutine_handle<void>());
    __f->__suspend_index = 3;
    return;
  } 

__resume_range_3 标签,理论上没机会执行。若走到这里,会调用 __rangeDestroy 做一些清理工作

复制代码
  __resume_range_3:
  __f->destroy_fn(__f);
}

先补充承诺对象的两个接口定义:

复制代码
        auto initial_suspend() { return std::suspend_always{}; }
        auto final_suspend() noexcept { return std::suspend_always{}; }

它们分别用于控制协程开始、结束前是否挂起,返回的类型称为等待对象,主要由三个接口组成:

复制代码
struct suspend_always   // 永远挂起协程
{
  constexpr bool await_ready() const noexcept { return false; }
  constexpr void await_suspend(coroutine_handle<>) const noexcept {}
  constexpr void await_resume() const noexcept {}
};

struct suspend_never    // 永远继续协程
{
  constexpr bool await_ready() const noexcept { return true; }
  constexpr void await_suspend(coroutine_handle<>) const noexcept {}
  constexpr void await_resume() const noexcept {}
};

await_suspend & await_resume 用于穿插一些协程挂起前、恢复后的工作,一般配合 co_awaitco_yeild食用;上面列的这两个结构是特殊的等待对象,它们只实现了一个接口await_ready,用于指示是否挂起协程:std::suspend_always总是挂起,std::suspend_never总是不挂起;有了等待对象的铺垫,再来看看通读 __rangeResume的收获:

  1. C++20 协程本质仍是个 duff device

  2. 承诺对象的 initial_suspend 决定是否在初始化后挂起协程

  3. 承诺对象的 final_suspend 决定是否在结束前挂起协程

  4. 等待对象的 await_ready 表示异步结果是否就绪,若未就绪,需要挂起协程进行等待;否则继续协程

  5. co_yield 对应承诺对象的 yield_value 接口,接口参数表示当前需要保存的数据

  6. 协程挂起前会调用等待对象的 await_suspend,一般在这里发起异步调用

  7. 协程恢复后会调用等待对象的 await_resume,一般在这里获取异步调用结果

  8. 等待对象需要跨越协程的挂起状态进行访问,因此是放在协程状态中用于生命周期保持 ,这就是成员__suspend_xx_xx存在的底层逻辑

  9. 协程挂起前会更新 __suspend_index,以便下次进入时在新位置执行代码

第 4) 点补充一个承诺对象的 yield_value 实现:

复制代码
        auto yield_value(int value) {
            current_value = value;
            return std::suspend_always{};
        }

生成值被保存到返回对象中,方便后续通过 value 接口访问:

复制代码
    int value() { return handle.promise().current_value; }

以上就是协程 resume 的逻辑了,下面来看下 destroy

复制代码
/* This function invoked by coroutine_handle<>::destroy() */
void __rangeDestroy(__rangeFrame * __f)
{
  /* destroy all variables with dtors */
  __f->~__rangeFrame();
  /* Deallocating the coroutine frame */
  /* Note: The actual argument to delete is __builtin_coro_frame with the promise as parameter */
  operator delete(static_cast<void *>(__f), sizeof(__rangeFrame));
}

用于销毁协程状态。有两个点会走到协程销毁,一个是上面展示过的 __rangeResume末尾,一个是返回对象的析构:

复制代码
    ~Generator() { if (handle) handle.destroy(); }

那到底是走哪个销毁的呢?增加一些输出看看:

复制代码
class Generator {
    struct promise_type {
        ~promise_type() { std::cout << "promise destroy" << std::endl; }
        ...
    }
    ...
    ~Generator() { if (handle) { std::cout << "before destroy handle" << std::endl; handle.destroy(); std::cout << "after destroy handle" << std::endl; } }
}

1
2
3
4
5
before destroy handle
promise destroy
after destroy handle

承诺对象是协程状态的一个成员,它的析构意味着协程状态的销毁即__rangeDestroy 的调用,承诺对象析构的输出是包围在返回对象析构的输出中,这说明协程句柄的 destroy 直接调用了 __rangeDestroy,在 __rangeResume 末尾的自行销毁的代码__resume_range_3应该是没机会执行了,否则后面返回对象访问协程句柄时该崩溃了。

整个销毁顺序可以参考图上标的序号。

后记

把这个 demo 生成数列的过程通过表格完整模拟一遍:

|-----------------------|-----------------|------|----|---|-----------------|----------------|-------------------|------------------------------------|
| 语句 | __suspend_index | from | to | i | __suspend_24_11 | __suspend_26_9 | __suspend_24_11_1 | 说明 |
| auto gen = range(1,5) | 0 -> 1 | 1 | 5 | - | suspend_always | - | - | __resume_range_1 前退出 |
| gen.next() | 1 -> 2 | 1 | 5 | 1 | suspend_always | suspend_always | - | __resume_range_2 前退出 |
| gen.next() | 2 | 1 | 5 | 2 | suspend_always | suspend_always | - | 同上 |
| gen.next() | 2 | 1 | 5 | 3 | suspend_always | suspend_always | - | 同上 |
| gen.next() | 2 | 1 | 5 | 4 | suspend_always | suspend_always | - | 同上 |
| gen.next() | 2 | 1 | 5 | 5 | suspend_always | suspend_always | - | 同上 |
| gen.next() | 2 -> 3 | 1 | 5 | 6 | suspend_always | suspend_always | suspend_always | __resume_range_3 前退出,next 返回 false |
| gen.~Generator | - | - | - | - | - | - | - | 清理资源释放内存 |

while 循环共 5 次;实际调用 next 6 次,因为最后一次 next 时 done 返回了 false 导致 while 循环退出,没有打印 value 值;加上初始化进入 range 的一次,共 7 次,你看懂了吗。

不过表内有一点标红数据与实际有出入,即最后一次 value 仍为 5 而不是 6,这是测试代码:

复制代码
#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{};
        }
    };

    int n = 0; 
    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(), n++, !handle.done()); }
    int value() { return handle.promise().current_value; }
    int N() { return n; }
};

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.N() << ": " << gen.value() << std::endl; 
    }
  
    std::cout << gen.N() << ": " << gen.value() << std::endl; 
}

和输出:

复制代码
1: 1
2: 2
3: 3
4: 4
5: 5
6: 5

看起来像是编译器优化但我没有证据,感兴趣的读者可以研究汇编代码告诉我为什么。

总结

本文通过一个最基础的例子说明了 C++20 协程相关的概念:

* 协程体

* 协程状态

* 承诺对象

* 返回对象

* 协程句柄

和它们之间的关系:

另外通过两个在线工具,解析了编译器在底层所做的穿针引线工作,并阐释了 C++20 协程就是函数+状态机这一实质,并简单说明了接入 C++20 协程时用户需要实现的类型与接口,及其含义。

用户只需要提供一个返回对象 、一个承诺对象 ,若干等待对象,就可以像写函数一样写协程啦!相比 C++17 需要自己维护变量的生命期,C++20 直接将协程所需参数和变量搬到协程状态中,为用户节省了大量的心智负担,使协程达到真正好用的水平。这样做的好处是变量存放于堆上,可大可小,不受固定栈尺寸限制,避免爆栈问题侵扰;缺点是当协程数量多时,动态内存分配频繁,容易引发内存碎片。

然而本例并不是一个能拿得出手的 C++20 协程例子,毕竟没有用户会手动 resume 协程!所以下一篇准备加入协程调度器,看看协程在真实场景中是怎么自己运转起来的,敬请期待~

参考

1\]. [深入浅出 C++ Lambda表达式:语法、特点和应用](https://blog.csdn.net/m0_60134435/article/details/136151698) \[2\]. [C++ Lambda表达式的完整介绍](https://zhuanlan.zhihu.com/p/384314474) \[3\]. [C++11:lambda表达式](https://www.cnblogs.com/shawyxy/p/17264688.html "发布于 2023-03-09 21:42") \[4\]. [C++11中的std::bind和std::function](https://www.cnblogs.com/tuapu/p/14167159.html) \[5\]. [现代C++的回调技术--std::bind+std::function](https://blog.csdn.net/a943368093/article/details/98499938) \[6\]. [std::function的原理以及实现](https://zhuanlan.zhihu.com/p/681383322) \[7\]. [C++代码优雅之道:std::function使用全攻略(附性能优化技巧)](https://wenku.csdn.net/column/6c3ewezd0d)[](https://www.cnblogs.com/lidabo/p/17223367.html "发布于 2023-03-16 17:06") \[8\]. [C++17结构化绑定](https://zhxilin.github.io/post/tech_stack/1_programming_language/modern_cpp/cpp17/structured_bindings/) \[9\]. [【C++ 17 新特性 结构化绑定】深入理解C++ 17 结构化绑定\[key, value\] 的处理](https://zhuanlan.zhihu.com/p/679006784) \[10\]. [C++ 17 结构化绑定](https://github.com/balloonwj/CppGuide/blob/master/articles/C++%E5%BF%85%E7%9F%A5%E5%BF%85%E4%BC%9A%E7%9A%84%E7%9F%A5%E8%AF%86%E7%82%B9/C++17%E7%BB%93%E6%9E%84%E5%8C%96%E7%BB%91%E5%AE%9A.md) \[11\]. [掌握 C++17:结构化绑定与拷贝消除的妙用](https://www.cnblogs.com/liudw-0215/p/18410226 "发布于 2024-09-12 15:13") \[12\]. [c++17之std::optional,std::variant以及std::any](https://blog.csdn.net/weixin_44537992/article/details/122725192) \[13\]. [如何优雅的使用 std::variant 与 std::optional](https://zhuanlan.zhihu.com/p/366537214) \[14\]. [17. 使用std::optional和std::variant进行重构](https://www.cppguide.cn/pages/cpp17indetail20/#_17-%E4%BD%BF%E7%94%A8std-optional%E5%92%8Cstd-variant%E8%BF%9B%E8%A1%8C%E9%87%8D%E6%9E%84) \[15\]. [理解C++折叠表达式(Fold Expression)](https://zhuanlan.zhihu.com/p/670871464) \[16\]. [C++ 折叠表达式:优雅处理可变参数模板](https://chengxumiaodaren.com/docs/cpp-new-feature/cpp-collapse-expression/) \[17\]. [【C++ 17 新特性 折叠表达式 fold expressions】理解学习 C++ 17 折叠表达式 的用法](https://zhuanlan.zhihu.com/p/679064198) \[18\]. [C++中的聚合初始化](https://blog.guorongfei.com/2016/03/13/cpp-aggregate-initlization/) \[19\]. [C++20 中使用括号进行聚合初始化:新特性与实践指南](https://cloud.tencent.com/developer/article/2502206) \[20\]. [漫谈C++类型擦除(Type Erasure)](https://zhuanlan.zhihu.com/p/624199149) \[21\]. [C++语法糖(coroutine)详解以及示例代码](https://zhuanlan.zhihu.com/p/644405655) \[22\]. [【并发编程二十一:终章】c++20协程( co_yield、co_return、co_await )](https://www.cnblogs.com/lidabo/p/17223367.html "发布于 2023-03-16 17:06") \[23\]. [从无栈协程到C++异步框架](https://zhuanlan.zhihu.com/p/568518673) \[24\]. [从无栈协程到C++异步框架---多线程环境下的协程调度](https://zhuanlan.zhihu.com/p/628304599) \[25\]. [gcc里的coroutine_handle](https://www.cnblogs.com/funwithwords/p/15733190.html "发布于 2021-12-26 15:50") \[26\]. [21. C++快速入门--协程 Coroutine 入门](https://www.cnblogs.com/easify/articles/18642077 "发布于 2024-12-30 18:11") \[27\]. [Exploring the C++ Coroutine](https://luncliff.github.io/coroutine/ppt/[Eng]ExploringTheCppCoroutine.pdf) \[28\]. [C++那些事之C++20协程](https://zhuanlan.zhihu.com/p/607510054) \[29\]. [C++协程小记-2](https://blog.debao.me/2024/04/notes-on-cpp-coroutines-2/) \[30\]. [浅谈C++20 协程那点事儿](http://www.uml.org.cn/c++/202401044.asp) \[31\]. [一篇文章搞懂 C++ 20 协程 Coroutine](https://www.cnblogs.com/blizzard8204/p/17563217.html "发布于 2023-07-18 15:50") \[32\]. [C++ 20 协程 Coroutine(3,剖析)](https://zhuanlan.zhihu.com/p/561884301) \[33\]. [C++20 Coroutines: operator co_await](https://www.cppmore.com/2020/04/09/cpp20-coroutines-operator-co_await/) \[34\]. [\[翻译\] 为什么 C++20 是最 awesome 的网络编程语言](https://www.bluepuni.com/archives/why-cpp20-is-awesome/) \[35\]. [从HelloWold开始,深入浅出C++ 20 Coroutine TS](https://www.modb.pro/db/160918)

相关推荐
goodcitizen1 个月前
使用 C++ 20 协程降低异步网络编程复杂度
coroutine·cpp20
氦客2 个月前
kotlin知识体系(五) :Android 协程全解析,从作用域到异常处理的全面指南
android·开发语言·kotlin·协程·coroutine·suspend·functions
bbqz0074 个月前
浅说 c++20 cppcoro (三)
c++·c++20·协程·coroutine·co_await·co_yield·cppcoro·co_return
goodcitizen5 个月前
你所不知道的 C/C++ 宏知识——基于《C/C++ 宏编程的艺术》
macro·cpp20·meta-programing
bbqz0075 个月前
浅说 c++20 coroutine
c++·c++20·协程·coroutine·co_await·stackless
bbqz0075 个月前
浅说c/c++ coroutine
c++·协程·移植·epoll·coroutine·libco·网络事件库·wepoll
键盘会跳舞6 个月前
Lua : Coroutine(协程)
lua·协程·coroutine
命运之手8 个月前
【Coroutines】Implement Python Generator by Kotlin Coroutines
python·kotlin·generator·coroutine
命运之手8 个月前
【Coroutines】Deep and Deep Into Kotlin Coroutines
android·kotlin·coroutine·deep-understand