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

浅说 c++20 coroutine的更多相关文章

  1. Lua 协程coroutine

    协程和一般多线程的区别是,一般多线程由系统决定该哪个线程执行,是抢占式的,而协程是由每个线程自己决定自己什么时候不执行,并把执行权主动交给下一个线程. 协程是用户空间线程,操作系统其存在一无所知,所以 ...

  2. (zt)Lua的多任务机制——协程(coroutine)

    原帖:http://blog.csdn.net/soloist/article/details/329381 并发是现实世界的本质特征,而聪明的计算机科学家用来模拟并发的技术手段便是多任务机制.大致上 ...

  3. Tornado源码分析系列之一: 化异步为'同步'的Future和gen.coroutine

    转自:http://blog.nathon.wang/2015/06/24/tornado-source-insight-01-gen/ 用Tornado也有一段时间,Tornado的文档还是比较匮乏 ...

  4. 学习tolua#·20多个例子

    初始项目搭建 clone官方库 新建unity工程 依次把官方库里的Assets和Unity5.x/Assets拷贝到项目Assets里 打开unity工程, 开始逐个学习例子,例子目录: 1. he ...

  5. Lua的多任务机制——协程(coroutine)

    并发是现实世界的本质特征,而聪明的计算机科学家用来模拟并发的技术手段便是多任务机制.大致上有这么两种多任务技术,一种是抢占式多任务(preemptive multitasking),它让操作系统来决定 ...

  6. python enhanced generator - coroutine

    本文主要介绍python中Enhanced generator即coroutine相关内容,包括基本语法.使用场景.注意事项,以及与其他语言协程实现的异同. enhanced generator 在上 ...

  7. asyncio异步IO--协程(Coroutine)与任务(Task)详解

    摘要:本文翻译自Coroutines and Tasks,主要介绍asyncio中用于处理协程和任务的方法和接口.在翻译过程中,译者在官方文档的基础上增加了部分样例代码和示意图表,以帮助读者对文档的理 ...

  8. C++20 要来了!

    867 人赞同了该文章 C++的新标准又双叒叕要到来了,是的,C++20要来了! 图片来源:udemy.com 几周前,C++标准委会历史上规模最大的一次会议(180人参会)在美国San Diego召 ...

  9. Python高级编程之生成器(Generator)与coroutine(一):Generator

    转载请注明出处:点我 这是一系列的文章,会从基础开始一步步的介绍Python中的Generator以及coroutine(协程)(主要是介绍coroutine),并且详细的讲述了Python中coro ...

  10. skynet coroutine 运行笔记

    阅读云大的博客以及网上关于 skynet 的文章,总是会谈服务与消息.不怎么看得懂代码,光读这些文字真的很空洞,不明白说啥.网络的力量是伟大的,相信总能找到一些解决自己疑惑的文章.然后找到了这篇讲解 ...

随机推荐

  1. Netty 如何自动探测内存泄露的发生

    本文基于 Netty 4.1.112.Final 版本进行讨论 本文是 Netty 内存管理系列的最后一篇文章,在第一篇文章 <聊一聊 Netty 数据搬运工 ByteBuf 体系的设计与实现& ...

  2. 爱科微AIC8800D80P Wi-Fi6模块驱动移植

    1. 简介 开发环境Ubuntu20.04 目标平台:瑞芯微RK356X 目标平台内核版本:4.19.234 wifi模块型号:AIC8800D80P Wi-Fi6/BT5.0 2. 硬件 wifi模 ...

  3. manim边做边学--圆锥

    Cone是Manim中专门用于创建和操控锥形几何对象的类. Cone允许用户定义锥体的底面半径.高度.颜色.不透明度等属性,并提供了一系列方法来操控这个锥体,如移动.缩放.旋转等. 通过这些属性和方法 ...

  4. golang之验证器validator

    快速安装 使用之前,我们先要获取validator这个库. # 第一次安装使用如下命令 go get github.com/go-playground/validator/v10 # 项目中引入包 i ...

  5. ClickHouse之物化MySQL

    Creates ClickHouse database with all the tables existing in MySQL, and all the data in those tables. ...

  6. vue 创建一个项目

    1.npm init -y 2.vue create 名称  3.manuall select features   [手动配置v2,v3] 4.选版本 5 6 启动:npm run serve

  7. 从源码分析 GMP 调度原理

    本身涉及到的 go 代码 都是基于 go 1.23.0 版本 传统 OS 线程 线程是 CPU 的最小调度单位,CPU 通过不断切换线程来实现多任务的并发.这会引发一些问题(对于用户角度): 线程的创 ...

  8. 使用pjsip封装自定义软电话sdk

    环境: window10_x64 & vs2022pjsip版本: 2.14.1python版本: 3.9.13 近期有关于windows环境下软电话sdk开发的需求,需要开发动态库给上层应用 ...

  9. 叮咚~ 你的Techo大会云存储专场邀请函到了!

    12月19日至20日,由腾讯主办的2020 Techo Park开发者大会将于北京召开.Techo Park 开发者大会是由腾讯发起的面向全球开发者和技术爱好者的年度盛会,作为一个专注于前沿技术研讨的 ...

  10. 跨平台交叉编译 Native AOT

    如何将.NET 应用程序发布到鸿蒙上,肯定是很多人感兴趣的话题,目前.NET完全具备可以在OpenHarmony系统上运行的能力,.NET 现在有很多选项CoreCLR.Mono和NativeAOT. ...