龙空技术网

C++ 协程篇一:co_yield和co_return

飞鱼在浪屿 950

前言:

现在你们对“c语言多进程编程实例”大概比较注意,咱们都需要学习一些“c语言多进程编程实例”的相关文章。那么小编在网摘上收集了一些关于“c语言多进程编程实例””的相关文章,希望各位老铁们能喜欢,我们一起来学习一下吧!

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

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

第 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>.

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<Generator::promise_type>::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<Generator::promise_type>::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。敬请期待。。。

标签: #c语言多进程编程实例