C++20 Coroutine
协程(coroutine)是能够暂停和恢复的函数。
协程是线程阻塞造成性能下降的最佳解决方案,尤其是应用在静态线程池中。
吐槽
GoLang 设计的 goroutine 简单好用,大名鼎鼎;虚拟机语言(例如 C#,Javascript,Java)的协程更是逆天改命,强势占领高性能并发的高地;机器队这边简直神速,而我们的人工队 C++ 在干什么呢,不会还没做完 STL network 吧,不会连 format
的编译器支持都没有吧,不会还没推广 import <module>
吧 🥵🥵🥵……
C++20 协程永远的神
C++的协程
C++的协程是:
- 对称的。一个协程暂停后,可返回 caller 或恢复任意协程。
- 语言级特性。编译器知道你在使用协程。然而不比库强到哪里去。
- 无栈(Stackless) 的。没有独立运行时栈,无惧爆栈,调度成本低。
一个协程在被命令「暂停」时,会保证将数据和当前运行位置保存在堆内存(以便恢复现场),然后转移运行权。
协程允许程序员更美观地编写异步代码,也使懒惰求值的算法成为可能。
当一个函数出现以下三种关键字之一,它就是协程:
co_await
暂停(直到被命令「恢复」)。co_yield
暂停同时返回一个值。co_return
结束整个协程并返回一个值。
使用协程的理由
- 相比于回调和 sender/receiver,协程的使用成本更低,性能下限更高。
- 降低使用者的心智负担和阅历要求,催化高质量工程,资本宠儿。
- 例如可以摆脱 asio 里常见的
std::shared_ptr
。
- 例如可以摆脱 asio 里常见的
C++协程的弱点
- 除非编译器优化,每个协程都需要通过
operator new
来分配 frame:- 动态内存分配可能引发性能问题;
- 在嵌入式或异构(例如 GPU)环境下,缺乏动态内存分配能力,难以工作。
- 除非编译器优化,协程的可定制点太多,需要大量间接调用/跳转(而不是内联),同样引起性能问题。
- 目前,编译器通常难以内联协程;
- HALO 优化理论:P0981R0。
- 动态分配和间接调用的存在,导致协程暂时无法成为异步框架的最优方法。
- Debug 的体验风评不佳。
协程的限制
- 不能使用可变参数(variadic arguments),但可以使用变参模板
- 不能使用
return
- 不能使用占位符返回类型 (
auto
或者 Concept) - 不能是 constexpr 函数
- 不能是构造函数或者析构函数
- 不能是
main
函数
协程的运行过程
所有协程必须关联着几个对象:
- promise object,在协程内部进行操作,协程向其写入结果或者异常。
- coroutine handle,在协程外部进行操作,用于恢复协程或者销毁协程帧(frame)。
- coroutine state,保存协程的信息,分配于堆内存上(除非被优化),对程序员不可见。具体保存着:
- promise object
- 所有协程参数(按值复制或移动)
- 记录暂停点的状态机
- 局部变量和临时变量
当协程「开始」时,它会:
- 使用
operater new
来构造 coroutine state。 - 将所有协程参数拷贝或移动到 coroutine state。小心发生「垂悬引用」,尤其在协程恢复后。
- 构造 promise object。优先调用接受所有协程参数的构造函数,否则调用默认构造函数。
- 调用
promise.get_return_object()
,其结果保存为本地变量。此结果在第一次暂停时会返回给 caller。包括这一步和以前的步骤,所有异常都会抛给 caller,而不是放入 promise object。 - 调用
promise.initial_suspend()
,紧接着co_await
它。常见的返回值是suspend_always
用于懒汉协程,或者suspend_never
用于饿汉协程 - 当
co_await promise.initial_suspend()
恢复后,协程开始运行其函数体。
当协程到达暂停点,它会:
- 将 return object 返回给执行权所有者,类型应为协程的返回类型,允许发生隐式转换。
当协程到达 co_return [expr]
语句,它会:
- 调用
promise.return_void()
,条件是:co_return;
co_return expr
而 expr 的类型是 void- 函数体结束
- 或者调用
promise.return_value(expr)
。 - 析构所有自动变量。
- 调用
promise.final_suspend()
,紧接着co_await
它。
当协程因未捕获异常而结束,它会:
- 捕获这个异常,并在
catch
块中调用promise.unhandled_exception()
- 调用
promise.final_suspend()
,紧接着co_await
它。此时恢复另一个协程是 UB。
当 coroutine state 被析构(要么遇到 co_return
,要么未捕获异常,在要么被 handle 销毁)时,它会:
- 析构 promise object。
- 析构所有协程参数。
- 使用
operator delete
来释放coroutine state。 - 转移执行权。
关于堆分配
动态内存分配可能成为严重性能瓶颈!
程序员可通过自定义 operator new
来控制 coroutine state 的分配。这部分暂时忽略不讲。
Promise类型推导
这部分暂时忽略不讲。
co_await
一元运算符 co_await
会暂停协程并将转移执行权。其操作数必须定义 operator co_await
,或者能通过当前协程的 Promise::await_transform
转换成这种类型。
1 | co_await expr |
首先,expr
要被转换为 awaitable,规则如下:
- 如果
expr
是由 initial suspend point,final suspend point 或者 yield expression 生成的,那么 awaitable 就是expr
本身。 - 否则,如果当前协程有定义
Promise::await_transform
,那么 awaitable 就是promise.await_transform(expr)
。 - 否则,awaitable 就是
expr
本身。
然后生成一个 awaiter object,规则如下:
- 根据重载解析的结果调用
opeartor co_await(awaitable)
或者awaitable.operator co_await()
- 如果重载解析找不到函数,那么 awaiter 就是 awaitable 本身。
- 如果重载解析有歧义,那么程序是 ill-formed。
然后,调用 awaiter.await_ready()
并判断,决定是否暂停协程:
- 若
false
,协程暂停,必要的信息存放于 coroutine state。然后调用awaiter.await_suspend(handle)
。在这个函数中,通过handle
可以访问 coroutine state,也是这个函数有责任安排协程在某个 executor 上恢复(甚至立即就地恢复),或者干脆销毁协程:- 若
awaiter.await_suspend(handle)
返回 void,执行权立即转移(给 caller/resumer)。 - 否则若返回 bool,
- 若
true
则转移执行权(给 caller/resumer)。 - 若
false
则恢复当前协程。
- 若
- 否则返回一个 coroutine handle(对应其他协程),调用这个
other_handle.resume()
。注意链式调用可能最终恢复当前协程。 - 若
awaiter.await_suspend()
抛出一个异常,则立即恢复协程,并由该协程接收这个异常。 - 当前协程恢复后,返回
awaiter.await_resume()
作为co_await expr
的结果。
- 若
- 若
true
,协程直接返回awaiter.await_resume()
作为co_await expr
的结果。
注意在进入 awaiter.await_suspend(handle)
之前,当前协程已经完全暂停,此时当前 handle 可以在线程之间自由传递,并由其他调度者恢复。在这种情况下,协程可能已经被恢复,awaiter 随之已经被析构,所以 await_suspend()
不应再访问 *this
。
co_yield
1 | co_yield expr |
等价于
1 | co_await promise.yield_value(expr) |