百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 技术分析 > 正文

聊聊C++20最大的变革之一 —— Coroutine,看不懂你打我(二)

liebian365 2025-02-06 15:35 16 浏览 0 评论

上一篇文章中我们介绍了C++ Coroutine的基本原理和用法,并且在最后实现了一个通过promise_type来传递参数的例子,本文作为本系列的第2篇,我们将探索一个简单的问题:如何更优雅的实现将counter函数里的参数传递给调用者,并且正确的释放coroutine的资源。

从co_await到co_yield

熟悉python的朋友对yield关键字应该非常的熟悉了,简直是创建一个可迭代对象的神器,也是用于异步编程的神器。C++过去一直缺少类似的能力,stackoverflow有很多咨询“如何在C++中实现类似Python yield的能力?”类似的问题。现在来到C++20,我们终于可以说:是的,C++终于具备的这个能力。

这一章我们将继续改写上一篇文章中最后的一个例子(已经忘记的朋友可以先点回去复习一下),我们将引入一个新的关键字co_yield,实现一个非常简洁的版本。

co_yield和co_await的作用类似,除了具备co_await相同的能力和行为以外,还有一个特别的点就是co_yield会调用promise_type::yield_value函数,并根据这个函数的返回值来判定是否要暂停执行。简单的说co_yield i;基本等价于:

// co_yield i;基本等价于
co_await promise.yield_value(i);

promise_type::yield_value的返回值是一个满足awaiter约束的对象,你可以通过这个值来自定义co_yield调用后的行为(暂停执行 or 继续执行等)。下面我们来看完整的代码:

#include 
#include 
#include 
#include 

class ReturnType {
 public:
  // Define the promise type
  class Promise {
   public:
    ReturnType get_return_object() {
      return {
          .handle_ = std::coroutine_handle::from_promise(*this),
      };
    }
    // Look at here: each call of 'co_yield i' equals to 'co_await promise.yield_value(i)'
    std::suspend_always yield_value(size_t value) {
      value_ = value;
      return {};
    }
    std::suspend_never initial_suspend() noexcept { return {}; }
    std::suspend_never final_suspend() noexcept { return {}; }
    void unhandled_exception() {}

    size_t value_;
  };

  using promise_type = Promise;  // Required by c++ standard

  std::coroutine_handle handle_;  // Use 'Promise' type argument
};

ReturnType counter() {
  std::cout << "counter: start" << std::endl;
  for (size_t i = 0;; ++i) {
    co_yield i;
  }
  std::cout << "counter: end" << std::endl;
}

int main() {
  auto return_object = counter();
  auto& promise = return_object.handle_.promise();
  for (size_t i = 0; i < 3; ++i) {
    std::cout << "main:" << promise.value_ << std::endl;
    return_object.handle_();
  }
  return_object.handle_.destroy();
  return 0;
}

/*
Outputs:
counter: start
main:0
main:1
main:2
*/

我们观察看到代码又大大缩减了!GetPromiseAwaiter类不见了,非常好!除了这个差异以外还有一个关键的差异就是第17到20行:promise_type::yield_value的实现,这一步将co_yield的值写入到promise_type实例的value_变量中,然后返回suspend_always表示暂停执行(当然要暂停执行,因为此时promise::value_已经有了新的值,需要切换到main继续执行来读取这个值)。

其他部分的代码变化不大,就不特别的解释了。这一步的关键就是在利用co_yield会调用promise_type::yield_value的特点,简化了大量代码。

inital_suspend 和 final_suspend

观察上面的代码,counter部分的逻辑以及非常简略了,精简到其实只需要两行。我们接下来将继续改进counter函数,让他从无限循环变成有限循环,同时引出有两个细节点需要讲清楚,否则大家会感到难以理解。这两个细节点就是之前刻意忽略的两个概念:

  1. promise_type::initial_suspendpromise_type::final_suspend 是什么意思?
  2. coroutine_handle::destroy 什么时候调用?

这两个问题处理不好是很容易发生崩溃或者逻辑错误的,他们涉及到coroutine资源回收职责和coroutine何时中断等问题。


promise_type::initial_suspend
的返回值告诉编译器是否要在coroutine函数开始时暂停执行;
promise_type::final_suspend
的返回值告诉编译器是否要在coroutine函数即将结束时暂停执行。用一个伪代码来表示那么整个coroutine的生存周期就是这样的:

// coroutine函数编译器实际生成的逻辑
{
  编译器:构造promise_type的实例: promise
  try {
    co_await promise.initial_suspend();
    【进入你定义的函数体执行】
  } catch (...) {
    if (promise.initial_suspend没有暂停函数执行)
      throw;
    promise.unhandle_exception();
  }
  // 执行回收操作
  co_await promise.final_suspend();
  编译器:回收coroutine_handle对应的所有资源
}

从上面的伪代码看出,实际上你在coroutine函数中定义的逻辑只是最终编译器生成的代码中的一部分。

initial_suspend的目的是确保在任何coroutine函数中定义的代码执行之前,暂停coroutine执行,以便于编译器可以保证在coroutine函数中定义的代码抛出的任何异常都会被
promise_type::unhandle_exception所处理。
这一点很容易理解,因为在coroutine函数第一次暂停执行之前,其和正常函数没有任何差异,依然是在调用者的栈上继续执行的,如果此时抛出了异常并且代码并没有catch住那么异常就会在栈上回溯。如果调用者同时调用了多个coroutine函数,那么当其中一个抛出异常时如何正确的清理其它coroutine的资源就会相对麻烦一些,并且容易出现bug。

因此一个比较安全的做法就是initial_suspend返回suspend_always来暂停执行(相对应的返回suspend_never就是不暂停)。这里需要注意的是需要额外一次恢复执行(调用coroutine_handle)才能让函数真正执行到你定义的函数体。如果你对coroutine的代码相当自信那么也可以忽略这个约束。

final_suspend的目的是明确在函数执行完成后是否还需要一次暂停来更新coroutine_handle对应的状态,这个细节需要非常小心,其涉及到你在调用coroutine时判断coroutine是否执行完毕以及coroutine的资源需要被谁释放的问题,如果处理错误就会造成程序崩溃。不过你也不需要担心,我总结了一个简单的判断方法:

  1. 如果你需要判断coroutine函数是否执行完成 —— 那么你需要让final_suspend返回一个暂停信号,比如suspend_always并且在coroutine执行完成后调用coroutine_handle::destory()来释放资源
  2. 如果你不需要判断coroutine函数的执行状态 —— 那么你可以让final_suspend返回不暂停信号,比如suspend_never。此时你不需要考虑何时释放coroutine的资源,当其执行完毕后会自动释放。

怎么样,这个判断方法非常简单了吧?为了让大家更好的理解这个细节以及引入 return_void / return_value 两个函数,我们来看一段有bug的代码:

#include 
#include 
#include 
#include 

class ReturnType {
 public:
  // Define the promise type
  class Promise {
   public:
    ~Promise() { std::cout << "Promise destroyed" << std::endl; }
    ReturnType get_return_object() {
      return {
          .handle_ = std::coroutine_handle::from_promise(*this),
      };
    }
    // Look at here: each call of 'co_yield i' equals to 'co_await promise.yield_value(i)'
    std::suspend_always yield_value(size_t value) {
      value_ = value;
      return {};
    }
    std::suspend_never initial_suspend() noexcept { return {}; }
    std::suspend_never final_suspend() noexcept { return {}; }
    void unhandled_exception() {}
    // void return_void() {} // Without this function, the behaviour is undefined...

    size_t value_;
  };

  using promise_type = Promise;  // Required by c++ standard

  std::coroutine_handle handle_;  // Use 'Promise' type argument
};

ReturnType counter(size_t num) {
  std::cout << "counter: start" << std::endl;
  for (size_t i = 0; i < num; ++i) {
    co_yield i;
  }
  std::cout << "counter: end" << std::endl;
}

int main() {
  auto return_object = counter(3);
  auto& promise = return_object.handle_.promise();
  while (!return_object.handle_.done()) {
    std::cout << "main:" << promise.value_ << std::endl;
    return_object.handle_();
  }
  return_object.handle_.destroy();
  return 0;
}

/*
Outputs:
counter: start
main:0
main:1
main:2
counter: end
Promise destroyed
main:0
[1]    xxxx segmentation fault
*/

这段代码最后将会导致一个段错误,看到段错误相信大家都会非常抓狂,造成段错误的原因我稍后解释。还是先看这一段代码的变化:

  1. counter函数增加了一个参数,表示循环i变量的最大值。之前例子中的counter函数是无限循环的,这里增加了循环退出条件,使得函数可以执行到末尾
  2. main函数改成通过coroutine_handle::done来判断coroutine是否执行完毕

我们看控制台输出可以看到 counter: end ,说明counter函数确实跳出了循环执行到了末尾。这里我需要介绍一个(大概也是我介绍的最后一个coroutine的概念了)C++ coroutine的约束:

当coroutine通过 co_return关键字返回 或者 执行到用户定义的函数体末尾 时,需要promise_type上定义函数:

  1. 当co_return没有返回值,或者自然执行到末尾时,需要调用promise_type::return_void函数
  2. 当co_return具有返回值,需要调用promise_type::return_value函数

如果没有定义return_void / return_value并且代码执行到了需要调用它们的时候会发生什么?答案是行为是未定义,这句话基本上C++标准里的必杀技,一旦触发基本等于崩溃(当然这并不绝对,和不同的编译器实现和运气有关,绝对不要触发这种情况),我们之前所有的例子都没问题是因为counter从来没有执行到末尾。

所以上面的代码问题很明确了,我们让counter函数执行到了末尾,但是promise_type没有定义return_void(我注释掉了),因此大家可以尝试一下把注释去掉、定义return_void空函数。

那么恭喜你,你会发现即使你修了这个bug,这个程序还是有问题!而且依然是段错误!

实际上我非常推荐你去运行一下这个代码,因为这一步对于你理解final_suspend是非常重要的,只看我写的是不够的。如果你运行一下你会发现程序的输出很奇怪:

counter: start
main:0
main:1
main:2
counter: end
Promise destroyed
main:0
[1]    xxxx segmentation fault

我们仔细看会发发现 counter: end 之后,又输出了 main:0 ,这是什么意思?这代表第46行main函数里判断coroutine是否执行完毕这个逻辑是失效的!按照正常人理解,counter执行完毕,coroutine_handle::done 返回true,那么while循环就退出了,程序正常结束。但事实上不是这样的,while循环继续执行,最终执行到48行,再次尝试继续counter执行时程序崩溃(因为此时counter已经执行完毕,资源都被回收了,不可能继续执行了)。

那么问题究竟出在哪里了?我们搬出 cppreference 查阅一下 coroutine_handle::done 的详细定义:

Checks if a suspended coroutine is suspended at its final suspended point.

1) Returns true if the coroutine to which *this refers is suspended at its final suspend point, or false if the coroutine is suspended at other suspend points. The behavior is undefined if *this does not refer to a suspended coroutine.

2) Always returns false.

我们可以清楚的看到,coroutine_handle::done 的实际作用是判断coroutine是否停止在了final_suspend处!这种情况下会返回true,否则其它任何请求都会返回false,而当coroutine_handle本身已经不合法时返回值是未定义的。我们上面遇到的情况就是 coroutine_handle 已经不合法了(资源被释放掉了)但是依然调用了done方法,此时返回了false导致程序继续执行,从而发生崩溃。

看到这里,请大家再次耐心的回忆一下我上面提到的“如何判断final_suspend是否需要暂停”的原则。对!因为我们要检查coroutine是否执行完毕,因此必须让final_suspend返回一个暂停标志,但是实际上代码的23行返回了suspend_never,表示不暂停执行,main函数无法正确判断coroutine是否执行完毕进而导致崩溃。

修复这个bug很简单,在第23行返回suspend_always即可。如同开始我说的,修改这个bug很简单,但是理解为什么这么修复就需要搞清楚上面提到的这些原理了。以下是正确的代码:

#include 
#include 
#include 
#include 

class ReturnType {
 public:
  // Define the promise type
  class Promise {
   public:
    ~Promise() { std::cout << "Promise destroyed" << std::endl; }
    ReturnType get_return_object() {
      return {
          .handle_ = std::coroutine_handle::from_promise(*this),
      };
    }
    // Look at here: each call of 'co_yield i' equals to 'co_await promise.yield_value(i)'
    std::suspend_always yield_value(size_t value) {
      value_ = value;
      return {};
    }
    std::suspend_never initial_suspend() noexcept { return {}; }
    std::suspend_always final_suspend() noexcept { return {}; }  // Haha, here is the issue.
    void unhandled_exception() {}
    void return_void() {}  // Without this function, the behaviour is undefined...

    size_t value_;
  };

  using promise_type = Promise;  // Required by c++ standard

  std::coroutine_handle handle_;  // Use 'Promise' type argument
};

ReturnType counter(size_t num) {
  std::cout << "counter: start" << std::endl;
  for (size_t i = 0; i < num; ++i) {
    co_yield i;
  }
  std::cout << "counter: end" << std::endl;
}

int main() {
  auto return_object = counter(3);
  auto& promise = return_object.handle_.promise();
  while (!return_object.handle_.done()) {
    std::cout << "main:" << promise.value_ << std::endl;
    return_object.handle_();
  }
  return_object.handle_.destroy();  // NOTE: Please try to comment out this line and run again. You will find it's
                                    // necessary to destroy the handle explicitly
  return 0;
}

/*
Outputs:
counter: start
main:0
main:1
main:2
counter: end
Promise destroyed
*/

注意看23行和25行,修复了上面提到的两个问题,运行这个程序我们将得到一个满意的结果。

实际上没有定义 return_void 在我测试的环境里(clang 15)并不会引发什么错误,但是触发未定义的行为依然是不推荐的。

这里额外留一个练习题:第50行代码是在coroutine执行完毕后释放coroutine资源的逻辑。如果我们不执行这一步会有什么结果?程序在终止(main函数结束)后会自动释放资源吗?你可以自己试试看。

好,这就是C++ coroutine系列第2篇文章的全部内容了,我们基本介绍完了全部的coroutine概念(co_return没有细讲,但是很简单了,大家应该能自己理解),并且实现了一个简单的Generator的功能。

不过虽然counter的代码看起来实现的很简洁了,但是main仍然比较冗余,在下一篇中我们将实现一个真正有用的Generator模版类,并让main看起来“非常的简洁”然后再实现一个结合Generator和Iterator机制的类型让用法更加的简单(对,就是for它就行!),并且展示如何实现类似python generator send一样的机制,即在迭代generator的过程中向coroutine传递参数。

喜欢大家就点个赞、收个藏、关个注吧!

相关推荐

4万多吨豪华游轮遇险 竟是因为这个原因……

(观察者网讯)4.7万吨豪华游轮搁浅,竟是因为油量太低?据观察者网此前报道,挪威游轮“维京天空”号上周六(23日)在挪威近海发生引擎故障搁浅。船上载有1300多人,其中28人受伤住院。经过数天的调...

“菜鸟黑客”必用兵器之“渗透测试篇二”

"菜鸟黑客"必用兵器之"渗透测试篇二"上篇文章主要针对伙伴们对"渗透测试"应该如何学习?"渗透测试"的基本流程?本篇文章继续上次的分享,接着介绍一下黑客们常用的渗透测试工具有哪些?以及用实验环境让大家...

科幻春晚丨《震动羽翼说“Hello”》两万年星间飞行,探测器对地球的最终告白

作者|藤井太洋译者|祝力新【编者按】2021年科幻春晚的最后一篇小说,来自大家喜爱的日本科幻作家藤井太洋。小说将视角放在一颗太空探测器上,延续了他一贯的浪漫风格。...

麦子陪你做作业(二):KEGG通路数据库的正确打开姿势

作者:麦子KEGG是通路数据库中最庞大的,涵盖基因组网络信息,主要注释基因的功能和调控关系。当我们选到了合适的候选分子,单变量研究也已做完,接着研究机制的时便可使用到它。你需要了解你的分子目前已有哪些...

知存科技王绍迪:突破存储墙瓶颈,详解存算一体架构优势

智东西(公众号:zhidxcom)编辑|韦世玮智东西6月5日消息,近日,在落幕不久的GTIC2021嵌入式AI创新峰会上,知存科技CEO王绍迪博士以《存算一体AI芯片:AIoT设备的算力新选择》...

每日新闻播报(September 14)_每日新闻播报英文

AnOscarstatuestandscoveredwithplasticduringpreparationsleadinguptothe87thAcademyAward...

香港新巴城巴开放实时到站数据 供科技界研发使用

中新网3月22日电据香港《明报》报道,香港特区政府致力推动智慧城市,鼓励公私营机构开放数据,以便科技界研发使用。香港运输署21日与新巴及城巴(两巴)公司签署谅解备忘录,两巴将于2019年第3季度,开...

5款不容错过的APP: Red Bull Alert,Flipagram,WifiMapper

本周有不少非常出色的app推出,鸵鸟电台做了一个小合集。亮相本周榜单的有WifiMapper's安卓版的app,其中包含了RedBull的一款新型闹钟,还有一款可爱的怪物主题益智游戏。一起来看看我...

Qt动画效果展示_qt显示图片

今天在这篇博文中,主要实践Qt动画,做一个实例来讲解Qt动画使用,其界面如下图所示(由于没有录制为gif动画图片,所以请各位下载查看效果):该程序使用应用程序单窗口,主窗口继承于QMainWindow...

如何从0到1设计实现一门自己的脚本语言

作者:dong...

三年级语文上册 仿写句子 需要的直接下载打印吧

描写秋天的好句好段1.秋天来了,山野变成了美丽的图画。苹果露出红红的脸庞,梨树挂起金黄的灯笼,高粱举起了燃烧的火把。大雁在天空一会儿写“人”字,一会儿写“一”字。2.花园里,菊花争奇斗艳,红的似火,粉...

C++|那些一看就很简洁、优雅、经典的小代码段

目录0等概率随机洗牌:1大小写转换2字符串复制...

二年级上册语文必考句子仿写,家长打印,孩子照着练

二年级上册语文必考句子仿写,家长打印,孩子照着练。具体如下:...

一年级语文上 句子专项练习(可打印)

...

亲自上阵!C++ 大佬深度“剧透”:C++26 将如何在代码生成上对抗 Rust?

...

取消回复欢迎 发表评论: