浅说 c++20 coroutine

浅说cppcoro

上一篇《浅说c/c++ coroutine》介绍了stackful协程,举了win32 Fiber跟tencent/libco为例。

本篇https://www.cnblogs.com/bbqzsl/p/18659948内容则是stackless协程,具体实现为c++20 coroutine,其中代表的库是cppcoro。

先来看编译器将c++代码的协程函数做了什么处理,产生了哪些实际的代码。

就像lambda是一种新语义一样,lambda定义的函数返回一个lambda,它有自己的captured帧,执行体是operator()。协程也是一种新语义。定义它的函数名,变成了一个工厂方法,生成一个协程对象句柄std::coroutine_handle,实质是一个协程帧对象,它有一个执行体actor()函数,这个函数执行协程函数定义的函数体内的代码。我们要向协程对象std::coroutine_handle调用resume()方法或operator()方法,去执行协程帧对象的actor()函数。协程帧对象有一个函数指针__resume,它就是指向这个actor()函数的。

这就是为什么调用协程名()不是执行代码,却是返回一个协程对象的原因。这跟传统的函数定义方式,调用行为有违和感的地方。开始会在感到不适应,是因为不知道细节,用旧思想套新事物。知道细节后,思维切换后其实就很快适应了。

一个协程还依赖两个概念(接口),它们分别是Promise跟Awaitable,编译器使用它们编辑我们的协程代码。虽然没有明确地将它们定义成concept,但是可以等同于概念来看待。在上面的图可以看到编译器将协程函数的代码,用这两个概念的接口组装成一份新的函数代码。协程函数定义依赖Promise概念(接口),co_await关键字依赖Awaitable概念(接口)。

将Promise跟Awaitable定义成concept的话,(Promise跟Awaitable并非一个被定义的c++concept,这里借用concept来描述接口依赖,并且区别于基于继承的抽象类接口), 如下

复制代码
 1 // 定义 Awaitable 概念
 2 template <typename T>
 3 concept Awaitable = requires(T t, std::coroutine_handle<> h) {
 4     { t.await_ready() } -> std::convertible_to<bool>; // 必须有 await_ready 方法
 5     { t.await_suspend(h) };                          // 必须有 await_suspend 方法
 6     { t.await_resume() };                            // 必须有 await_resume 方法
 7 };
 8 
 9 // 定义 PromiseType 概念
10 template <typename T>
11 concept PromiseType = requires(T promise) {
12     { promise.get_return_object() }; // 必须有 get_return_object 方法
13     { promise.initial_suspend() } -> std::same_as<std::suspend_always>
14         || std::same_as<std::suspend_never>
15         || Awaitable;                // initial_suspend 的返回值必须满足要求
16     { promise.final_suspend() } -> std::same_as<std::suspend_always>
17         || std::same_as<std::suspend_never>
18         || Awaitable;                // final_suspend 的返回值必须满足要求
19     { promise.unhandled_exception() }; // 必须有 unhandled_exception 方法
20     { promise.return_void() } || { promise.return_value(42) }; // 必须有返回值处理方法
21 };

Promise,Awaitable,concept

编译器使用Promise概念生成的代码套间。

c++ coroutine采用stackless协程实现方式,基于状态机的闭包函数。

下面来看编译器如何将协程函数的代码生成状态机代码

编译器会在每一处co_await调用的地方记下一个新状态。co_await都是一个待定的地方,此处有可能挂起。挂起就意味着协程切换。所以此处必须有一个状态来标记。不同于stackful协程那样使用现场保护进行代码跳转切换,stackless走的另一条路,使用状态机切换代码线路。当协程代码遇到需要挂起时,协程只需要保存当前状态机状态,立即退出函数返回,就可以中止当前代码流实现挂起。恢复协程,则再次重入协程函数,然后根据状态机保存的状态就可以直接切换回原来挂起的代码线路,继续执行。stackful协程是在完全模拟线程切换。线程不但可以通过代码主动切换,还要受中断被切换。被动切换会发生在代码任意位置,所以要保留大量现场信息。但是主动切换的位置,都是代码确定的,所以协程可以用状态机函数来实现切换。上面示例图,example协程,代码流执行到co_await B(),因为B awaiter需要挂起,而挂起example协程。example协程当前状态__resuem_at设置成2,直接退出函数中止执行流。当你恢复example协程时,重入函数,直接跳到__resume_at == 2的地方,这个地方会安排挂起源B的await_resume()方法接收结果,然后继续后面的代码流。这里必需要注意,example恢复时,不会去关心或检测B协程是否完成,所以由挂起源B来控制向协程发出恢复。

然后我们来看协程的闭包实现。虽然stackless协程使用常规函数调用进行切换,但是协程函数的代码局部变量却不能够放在堆栈上。从上面刚介绍完挂起原理可以知道,stackless协程在挂起的情况,直接退出函数,销毁调用帧,平衡堆栈,传统函数放在堆栈的局部变量统统都要被析构。所以协程函数需要是闭包的有自己的局部变量空间。这样才能让函数重入后继续原来的线路执行下去。例如每次co_await的awaiter都是一个局部变量,下次恢复时,还要对awaiter询问状态。所以stackless协程有自己的private coro frame。这是由编译器去实现的。借助编译器,将协程函数的局部变量都分配在协程的private coro frame,在协程函数代码流终止的地方,才会销毁这个私有帧。示图如下

再来看反编译出来的私有帧

私有帧跟协程函数局部变量的对应

接下来看co_await是在做什么的。先来写一个co_await2函数来模拟编译器如何依赖Awaitable概念生成代码的。

co_await2函数,描述的是co_await是如何工作的。

co_await关键字,使用Awaitable概念的三个接口函数来实现await操作。这时要注意,co_await expr语义,跟操作子operator co_await()函数是不同的。await操作是前者语法,后者语法是一个函数用来获取一个awaiter。co_await expr语义,期望expr是一个awaiter对象,这个对象满足Awaitable概念接口。如果expr不是一个awaiter对象,就会调用操作子operator co_await()函数向它索取一个awaiter对象。有了awaiter对象,然后展开代码对其实施await操作。await操作具体包含三个步,awaiter.await_ready()检测,awaiter.await_suspend()挂起,awaiter.await_resume()被恢复前夕。

co_await expr语义,另一个重要的作用,就是告诉编译器设立切换点,生成状态机代码。确保下一次resume,执行流直接在co_await的下一句开始。这也是std::coroutine的局限性,本质上是不支持对称协程的。不同于stackful协程保留调用resume的现场。而stackless协程,用状态机逻辑来模拟一个恢复点,这个恢复点是逻辑决定好的。换句话来说,如果没有一个状态机的恢复点,你并不能够恢复到你切换其它协程的地方。当你恢复这样的一个协程时,actor()函数重入,却从头到尾去执行,或者是从上一个恢复点,而并非你调用resume()的地方。明确地说std::coroutine不可以离开co_await的加持,随意调用其它协程的resume进行切换。这点对于已经习惯写对称协程的你来说,或者你想用它来写对称协程,会是很蛋痛的。

一对比来看,是不是很蛋痛。所以用std::coroutine进行协程切换时,必须依赖co_await让代码生成一个切换点,因此应该谨慎直接使用resume()。尽可能在一个co_await操作里面使用resume(),co_await操作的对象的一个awaiter,所以要尽可能在一个awaitable里面的await_suspend()函数中使用resume()。并且使用void awiat_suspend()重载,让原来的协程中止执行流,因为resume()结束后还会回到原来协程caller/resumer的代码。除此外,我们还可以通过symmetric_transfer方式来避免直接调用resume()。symmetric_transfer方式就是使用awiat_suspend()重载返回一个std::coroutine_handle。由编译器生成代码来调用resume()。所以上面的例子就变成下面的样子

不同于stackful的resume切换cpu上下文,stackless的resume是状态机函数重入,因此每一次resume都要增加一层调用帧。并且每一次resume执行流中止或完成终止,要回到上一层resume的调用帧。这就是stackless协程的资源成本,每一次resume要占用一些堆栈,中止或终止后还逐帧退出而占用几条指令。

可以看到每次resume()调用,要消耗0x30个字节。以2MB的堆栈来看,用上面的对称协程例子,43690次切换就可以crash堆栈。

下面以cppcoro::task为例,它的实现思路是

如果是对称方式的实现如上,非对称方式的实现,final_awaitable::await_suspend() return false,直接回到caller/resumer。

cppcoro有一个编译选项CPPCORO_COMPILER_SUPPORTS_SYMMETRIC_TRANSFER,以供选择实现的方式。需要clang7以上。其它编译器默认不使用。一般来说,能支持c++20的编译器都支持这个选项。是否要开启提供下面样本作为参考。

看来用std::coroutine来搞对称协程,多少有些不自然。

cppcoro留待下篇,前置先介绍std::coroutine。

相关推荐
StudyWinter2 分钟前
【OpenCV(C++)快速入门】--opencv学习
c++·学习·计算机视觉·openev
van叶~1 小时前
算法妙妙屋-------2..回溯的奇妙律动
c++·算法
吾名招财3 小时前
open3d+opencv实现矩形框裁剪点云操作(C++)
c++·opencv·open3d·点云裁剪
诚丞成3 小时前
字符串算法篇——字里乾坤,算法织梦,解构字符串的艺术(下)
c++·算法
我想学LINUX4 小时前
【2024年华为OD机试】(C卷,100分)- 攀登者1 (Java & JS & Python&C/C++)
java·c语言·javascript·c++·python·游戏·华为od
Ring__Rain6 小时前
野指针bug
c++·bug
xqhoj8 小时前
C++学习指南(七)——stack/queue/priority_queue
开发语言·c++
埃菲尔铁塔_CV算法8 小时前
双线性插值算法:原理、实现、优化及在图像处理和多领域中的广泛应用与发展趋势(二)
c++·人工智能·算法·机器学习·计算机视觉
叫我龙翔9 小时前
【算法日记】从零开始认识动态规划(一)
c++·算法·动态规划·代理模式
Pafey9 小时前
c++ 中的容器 vector、deque 和 list 的区别
开发语言·c++