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

C++ 协程篇一:co_yield和co_return

itomcoil 2025-02-17 12:30 11 浏览

更多互联网精彩资讯、工作效率提升关注【飞鱼在浪屿】(日更新)

这篇博文是两部分系列之一。

  • 第 1 部分:co_yield和co_return
  • 第 2 部分:co_await

介绍

与其他编程语言相比,C++ 加入协程较晚,从C++20开始支持。在协程出现之前,C++ 程序员有两种选择:

  • 同步代码更容易理解但效率较低。
  • 异步代码(例如回调)更高效(让您在等待事情的同时做其他工作)但也更复杂(手动保存和恢复状态)。

协程,“可以暂停执行的函数”,旨在兼顾两全其美:看起来像同步代码但执行起来像异步代码的程序。

一般来说,C++ 语言设计倾向于效率、可定制性和零开销原则, 而不是易用性、安全性之类的东西。

这些既不是“好”也不是“坏”的设计原则,由于 C++ 没有垃圾收集器,也没有运行时系统。这也导致C++ 协程有着陡峭的学习曲线。

这两篇博文并不旨在面面俱到,而是旨在快速浏览三种基本机制(C++20 中新增的协程相关运算符)。这两篇博文都通过一个完整、简单的程序,介绍co_yield,co_return和co_await。


初筛

Eratosthenes 筛法是最早记录的算法之一,已有两千多年的历史,生成了一系列素数:2、3、5、7、11 等。

上个世纪,Doug McIlroy 和 Ken Thompson发明了 Unix 管道作为连接并发进程的一种方式。McIlroy 编写了一页的 C 版本的 Sieve,它使用 Unix 进程和管道。该程序也出现在Tony Hoare颇具影响力的通信顺序过程(CSP) 论文中。最近,Go 也有一个 36 行的 Go 版本的 Sieve 。

该设计可以移植到 C++ 协同程序。CSP 中的“进程”与 Unix 进程不同。我们的程序(与 McIlroy 的程序不同)是单线程和单进程的(在 Unix 进程意义上)。

这里以素数筛选举例,但协程不一定是在 C++ 中实现素数筛选的最佳(最简单、最快等)方式。


输出

构建并运行完整的 C++ 文件,如下所示:

"-fno-exceptions"标志简化了一些 C++程序使用异常的流程。


co_yield

这是一个协同程序(而不是常规函数),因为它的主体中至少有一个显式co_yield或co_return。

虽然常规函数只能返回(比如RType),并且最多只能返回一次,但协程也可以这样做,但在return(CRType)之前可以co_yield零个或多个东西(CYType)。正如常规函数可以永远循环而不返回一样,协程也可以永远循环,可能会执行co_yield某些操作,也可能不会执行co_yield任何操作,而不会co_return。

在这个例子中,source co_yields(生成)整数序列 2、3、4、5 等。因为是协程,所以在它的source末尾有一个隐式语句。co_return;其中RType, CYType和CRType分别是Generator, int 和void。


return和co_return

source返回Generator(即使函数主体从未提及return Generator)。main函数保存调用source结果 ,就像调用常规函数一样。从调用者的角度,以及从“文件中的函数签名.h”的角度来看,它确实只是一个常规函数。与其他编程语言不同,C++ 协程不需要关键字async。

source(40)调用物理上返回(汇编CALL和 RET指令,逻辑上完成后到达 最后一个'}'右半大括号隐式co_return)。这里继续并发运行。对于多线程程序,两者可以并行运行(使用互斥锁、原子或类似)但我们的示例程序是单线程的。concurrency is not parallelism.

从逻辑上讲,source它正在自行运行它的循环for (int x = 2; x < end; x++),偶尔co_yielding 一个东西。物理上,source被调用一次,暂停,返回,然后重复恢复和 co_yielding/suspending 直到以最终的co_return/suspend 结束。

正如我们将在下面进一步看到的,在我们的程序中,恢复是在方法内部显式触发的Generator::next(并且resume只是一个方法调用)。我们的“拉式”生成器协程是“按需”安排的,这在这里工作得很好,因为我们从不等待 I/O。


Promise类型

在常规函数调用中,调用者和被调用者协作(根据调用约定)为堆栈保留一些内存,例如保存函数参数、局部变量、返回地址和返回值。被调用者返回后,栈帧就不再需要了。

对于协程调用,即使在物理返回之后也需要这样的状态(函数参数、局部变量等)。因此,它保存在堆分配的协程框架中。协程框架还包含一些“在协程体内从哪里恢复”的概念,以及一个定制的帮助对象来驱动协程。在 C++ 中,指向协程帧的指针表示为一个std::coroutine_handle.

CustomizedHelper对象被称为“promise”(但它的类型不是std::promise )并且 CustomizedHelper类型通常是RType::promise_type,RType协程的返回类型在哪里。

一些文档谈论“协程状态”而不是“协程框架”,如:promise 对象与“协程框架”(包含参数和局部变量)并存(而不是在其中),两者都在“协程状态”中”。但我更喜欢用“协程框架”来表示整个事情。另请参见frame_ptr下文,作为指向(协程)框架的指针。


Generator::promise_type

在我们的程序中,编译器知道source和filter是协程(因为它们有co_yield表达式)。它们也被声明为返回Generator,因此编译器查找Generator::promise_type并期望它具有某些方法。

例如,我们的协程主体说co_yield x 和CYType (变量x的类型) 是int类型,所以我们的 promise 类型需要有一个yield_value函数带int参数. 它还有一个(隐式)co_return语句(但不是 co_return foo语句),因此它还需要一个return_void不带参数的方法。它还需要get_return_object方法, initial_suspend方法和 final_suspend方法。

这是完整的Generator::promise_type定义:

get_return_object生成Generator对象。我们将 在下面进一步讨论std::coroutine_handle,但它本质上是一个指向协程框架的美化指针。我们会将其传递给构造函数,以便Generator::next 在必要时可以使用协程。

initial_suspend返回一个 awaitable(在篇二中介绍),它控制协程是急切的(也称为“热启动”)还是惰性的(“冷启动”)。协程是直接开始运行还是需要先单独踢一脚?我们的程序返回一个std::suspend_always意思是惰性的,因为这将更好地与“Generator::next总是调用resume以提取下一个值”一起工作,我们将在下面进一步看到。

final_suspend同样控制是否在之后暂停(可能隐含的)co_return。如果它不挂起,协程框架将被自动销毁,从“不要忘记清理”的角度来看这很好,但销毁协程框架也会销毁promise 对象

在我们的程序中,Generator::next需要在co_return之后检查promise 对象(调用 promise 对象的方法仅在协程被挂起时才有效),所以我们挂起(通过final_suspend 返回 a std::suspend_always)。Generator将负责显式销毁协程框架(剧透警报:它将在其析构函数中完成,通过std::coroutine_handle传递给其构造函数)。

yield_value和return_void方法已经提到,yield_value将其参数保存到成员变量( 然后Generator::next将加载)。这就是生成器协程将它产生(产生)的东西传递回消费者的方式。我们的实现一次只缓冲一个值,但其他实现可以做一些不同的事情。至少,如果程序是多线程的,它必须做一些线程安全的事情。


Generator::next

这是Generator::next方法(和Generator构造函数)。它 resume协程,运行到下一次暂停(在显式co_yield或final_suspend隐式之后co_return;后者意味着协程是done)。


资源获取即初始化

要正确清理,我们应该destroy一次std::coroutine_handle。我们将在Generator析构函数中执行此操作(并且该m_cohandle字段是私有的)。当我们将Generator从main传递给filter时,我们必须std::move它,就好像它是一个std::unique_ptr.

g = filter(std::move(g), prime);

调试

在接下来的几个月和几年里它可能会变得更好,但今天调试协程可能有点粗糙,至少在 Debian 稳定版 (Bullseye) 上是这样。断点有效,但局部变量有问题。

例如,我们可以co_yield x在source 协程函数中设置一个断点,但x值似乎没有改变(打印x 总是说 2)并且使断点成为条件意味着x == 5,在实践中,断点不再触发。奇怪的是,info breakpoints还将断点放在_Z6sourcei.actor(_Z6sourcei.frame *)函数中,大概是普通source(int)函数的编译器转换版本。


手动断点

我们可以在源代码中插入手动断点(甚至是条件断点),而不是通过gdb.

在x == 5循环迭代中(但在 之前co_yield),我们的流程(在 CSP 意义上)应该像这样链接main - filter(3) - filter(2) - source:在调试器中重新编译和运行证实了这一点:从下往上,堆栈跟踪显示main,filter两次然后source。

Recall that logically (and in the source code), the filter function takes two arguments (a Generator and an int) but physically (in the stack trace), after the compiler transformed it, filter (or perhaps _Z6filter9Generatori.actor, which c++filt demangles as filter(Generator, int) [clone .actor]) takes only one (what g++ calls the frame_ptr). This pointer value turns out to be the same address as what the std::coroutine_handle::address() method would return. For g++, the frame_ptr address is also a small, constant offset from the promise’s address (what this is inside promise_type methods).


回想一下,从逻辑上(在源代码中),该filter函数有两个参数(Generator和int),但在物理上(在堆栈跟踪中),在编译器转换它之后,filter(或者可能是
_Z6filter9Generatori.actor,c++filt分解为filter(Generator, int) [clone .actor])只接受一个(g++调用的)frame_ptr。这个指针值原来是与该 std::coroutine_handle::address()方法返回的地址相同。对于g++,frame_ptr地址也是相对于promise的地址(promise_type函数)的一个小的常量偏移量。


结论

协程在某种意义上是神奇的,因为它需要编译器支持,并且不是您可以在纯 C++ 中轻松完成的事情(例如,boost 协程依赖于 boost 上下文,并且需要特定于 CPU 体系结构的汇编代码)。但这篇博文有望揭开 C++20 协程co_yield和 co_return运算符的神秘面纱:

  • 如果一个函数的函数体至少包含一个co_yield, co_return或co_await表达式,那么它就是一个协程。
  • 编译器将协程的主体转换为动态分配协程框架的东西。
  • 指向协程框架的指针称为std::coroutine_handle。
  • 该协程框架包含挂起/恢复点、参数和局部变量的副本以及连接调用者和被调用者世界的可自定义帮助器对象(称为承诺对象)。
  • co_yield协程被调用者中的ing(或co_returning)将状态保存在 promise 对象中(通过调用yield_blah或return_blah方法)。调用者(或其他代码)可以稍后加载此状态。
  • co_yielding(或co_returning)是 C++ 语言和标准库的一部分,通常也会暂停协程。
  • 由程序(或其非标准库)明确挂起 resume协程。

最后一个要点掩盖了许多潜在的细节。我们的示例程序相对简单,但总的来说,调度是一个难题。C++20 不提供一刀切的解决方案。它只提供机制,不提供政策。

这部分是因为前面提到的可定制性和“无运行时”设计目标,还因为高性能协程调度实现可能是 OS(操作系统)特定的(你甚至可能没有操作系统

C++20 没有为您提供符合人体工程学的高级协程 API。这不是“撒上一些asyncs 和awaits 就大功告成了”。它为您提供了一个低级 协程 API 构建工具包。需要一些进一步的 C++(但不是汇编)。

Baker 是这样说的:“C++ Coroutines TS [Technical Specification] 在语言中提供的设施可以被认为是协程的低级汇编语言[原文强调]。这些工具很难以安全的方式直接使用,主要供库编写者使用,以构建应用程序开发人员可以安全使用的更高级别的抽象。”

它为您提供了 a 的协程等效项,goto由您(或您使用的库)来构建更好的抽象,例如 if-else 的等效项、while 循环和函数调用。事实上,有些人主张结构化并发,甚至说“Go 语句被认为是有害的”,但更大的讨论超出了本文的范围。


co_await

我要说的最后一件事是co_yield表达式基本上是co_await promise.yield_value(expr)的语法糖。或者,当您可以通过其他方式访问协程的隐式对象,co_await是什么以及它是如何工作的?在第 2 部分中了解更多信息 :co_await。敬请期待。。。

相关推荐

Excel新函数TEXTSPLIT太强大了,轻松搞定数据拆分!

我是【桃大喵学习记】,欢迎大家关注哟~,每天为你分享职场办公软件使用技巧干货!最近我把WPS软件升级到了版本号:12.1.0.15990的最新版本,最版本已经支持文本拆分函数TEXTSPLIT了,并...

Excel超强数据拆分函数TEXTSPLIT,从入门到精通!

我是【桃大喵学习记】,欢迎大家关注哟~,每天为你分享职场办公软件使用技巧干货!今天跟大家分享的是Excel超强数据拆分函数TEXTSPLIT,带你从入门到精通!TEXTSPLIT函数真是太强大了,轻松...

看完就会用的C++17特性总结(c++11常用新特性)

作者:taoklin,腾讯WXG后台开发一、简单特性1.namespace嵌套C++17使我们可以更加简洁使用命名空间:2.std::variant升级版的C语言Union在C++17之前,通...

plsql字符串分割浅谈(plsql字符集设置)

工作之中遇到的小问题,在此抛出问题,并给出解决方法。一方面是为了给自己留下深刻印象,另一方面给遇到相似问题的同学一个解决思路。如若其中有写的不好或者不对的地方也请不加不吝赐教,集思广益,共同进步。遇到...

javascript如何分割字符串(javascript切割字符串)

javascript如何分割字符串在JavaScript中,您可以使用字符串的`split()`方法来将一个字符串分割成一个数组。`split()`方法接收一个参数,这个参数指定了分割字符串的方式。如...

TextSplit函数的使用方法(入门+进阶+高级共八种用法10个公式)

在Excel和WPS新增的几十个函数中,如果按实用性+功能性排名,textsplit排第二,无函数敢排第一。因为它不仅使用简单,而且解决了以前用超复杂公式才能搞定的难题。今天小编用10个公式,让你彻底...

Python字符串split()方法使用技巧

在Python中,字符串操作可谓是基础且关键的技能,而今天咱们要重点攻克的“堡垒”——split()方法,它能将看似浑然一体的字符串,按照我们的需求进行拆分,极大地便利了数据处理与文本解析工作。基本语...

go语言中字符串常用的系统函数(golang 字符串)

最近由于工作比较忙,视频有段时间没有更新了,在这里跟大家说声抱歉了,我尽快抽些时间整理下视频今天就发一篇关于go语言的基础知识吧!我这我工作中用到的一些常用函数,汇总出来分享给大家,希望对...

无规律文本拆分,这些函数你得会(没有分隔符没规律数据拆分)

今天文章来源于表格学员训练营群内答疑,混合文本拆分。其实拆分不难,只要规则明确就好办。就怕规则不清晰,或者规则太多。那真是,Oh,mygod.如上图所示进行拆分,文字表达实在是有点难,所以小熊变身灵...

Python之文本解析:字符串格式化的逆操作?

引言前面的文章中,提到了关于Python中字符串中的相关操作,更多地涉及到了字符串的格式化,有些地方也称为字符串插值操作,本质上,就是把多个字符串拼接在一起,以固定的格式呈现。关于字符串的操作,其实还...

忘记【分列】吧,TEXTSPLIT拆分文本好用100倍

函数TEXTSPLIT的作用是:按分隔符将字符串拆分为行或列。仅ExcelM365版本可用。基本应用将A2单元格内容按逗号拆分。=TEXTSPLIT(A2,",")第二参数设置为逗号...

Excel365版本新函数TEXTSPLIT,专攻文本拆分

Excel中字符串的处理,拆分和合并是比较常见的需求。合并,当前最好用的函数非TEXTJOIN不可。拆分,Office365于2022年3月更新了一个专业函数:TEXTSPLIT语法参数:【...

站长在线Python精讲使用正则表达式的split()方法分割字符串详解

欢迎你来到站长在线的站长学堂学习Python知识,本文学习的是《在Python中使用正则表达式的split()方法分割字符串详解》。使用正则表达式分割字符串在Python中使用正则表达式的split(...

Java中字符串分割的方法(java字符串切割方法)

技术背景在Java编程中,经常需要对字符串进行分割操作,例如将一个包含多个信息的字符串按照特定的分隔符拆分成多个子字符串。常见的应用场景包括解析CSV文件、处理网络请求参数等。实现步骤1.使用Str...

因为一个函数strtok踩坑,我被老工程师无情嘲笑了

在用C/C++实现字符串切割中,strtok函数经常用到,其主要作用是按照给定的字符集分隔字符串,并返回各子字符串。但是实际上,可不止有strtok(),还有strtok、strtok_s、strto...