浅说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。