c++20协程简介
C++20 协程的接口、无栈协程原理与开源生态
C++20 协程简介
参考:
C++20 协程通过 Promise 和 Awaitable 接口的十余个函数,向程序员暴露出定制协程流程和行为的入口。实现一个最简单的协程,通常需要用到其中的 8 个(5 个 Promise 函数 + 3 个 Awaitable 函数)。下面先从 Awaitable 的 3 个函数说起。
如果希望写出形如 co_await blabla; 的调用形式,blabla 就必须实现 Awaitable。co_await 是 C++20 引入的新运算符。Awaitable 主要包含 3 个函数:
await_ready:返回该 Awaitable 实例是否已经就绪。协程开始时会调用此函数;若返回true,表示期待的结果已经到手,协程无需挂起,因此大多数场景下它的实现都是return false。await_suspend:挂起 awaitable。该函数会传入一个coroutine_handle类型的参数,这是由编译器生成的句柄。在此函数中调用handle.resume(),即可恢复协程。await_resume:当协程被重新唤醒时会调用该函数,其返回值也就是co_await表达式的返回值。
函数的返回值需要满足 Promise 的规范。最简单的 Promise 如下:
#include <coroutine>
#include <cstdlib>
struct Task
{
struct promise_type {
auto get_return_object() { return Task{}; }
auto initial_suspend() { return std::suspend_never{}; }
auto final_suspend() noexcept { return std::suspend_never{}; }
void unhandled_exception() { std::terminate(); }
void return_void() {}
};
};注:C++20 标准把
suspend_never、suspend_always、coroutine_handle等组件放在<coroutine>头文件的std::命名空间下;早期 Coroutines TS 草案中位于<experimental/coroutine>的std::experimental::命名空间,迁移到正式标准时应一并替换。
无栈协程原理
参考某知乎答主的回答,可以从应用倒推原理:
- 无栈协程既可以只开一个,也可以开几十万个,这说明它依赖动态内存分配,协程的局部变量是分配在堆空间的。
- 无栈协程没有运行时栈,这意味着每个协程必须「知道自己是协程」。举例:当协程 a 引用一个局部变量
local_v时,需要被编译为类似(*a_frame).local_v的形式,也就是从协程 a 占有的堆内存中取出local_v;同一种协程的多个实例之间的堆内存互不相干。 - 当协程 a 调用一个普通函数 b 时,函数 b 并不知道调用者 a 是不是协程,也只需遵循普通的函数调用约定,在栈上开辟栈帧。在概念上,除非函数 b 结束并返回到协程 a,否则协程 a 永远没有机会暂停。
- 当普通函数 b 调用一个协程 c 时,函数 b 同样不需要知道 c 是不是协程。在 C++20 中,普通函数 b 只需调用
c.resume(),就好像这是一个普通函数。 - 延续第 4 点,当
c.resume()返回后,协程 c 可能已经结束,也可能只是被挂起了,这不重要。随后你也许希望调用d.resume()、e.resume()等等。实际上普通函数 b 在这里扮演了一个调度者的角色,决定着哪个协程应该运行,此时可将它视为「调度器」。这种「协程暂停 -> 回到调度器 -> 另一个协程运行 -> ……」的循环模式叫做「非对称协程」。另一种模式是「协程暂停 -> 另一个协程运行」,即c.resume()结束后并没有返回普通函数 b,而是直接切换到另一个协程,叫做「对称协程」。「对称协程」和「非对称协程」可以互相模拟,本质区别不大。
那么,如何用普通函数实现非对称协程?
- 首先定义一个
struct Frame「协程帧」,负责存放协程的局部变量。这样每当新建一个协程时,只需整体分配一次堆空间。显然,由于不同协程内具有不同的局部变量,你需要对每一种协程都定义一个 Frame,于是代码里会出现A_Frame、B_Frame、…… 你会觉得这种机械工作太过繁琐,为什么不让编译器来做呢?幸运的是,C++20 的编译器能够帮你准确无误地完成这些工作,真心建议放弃 C++11。 - 堆空间有了,那如何实现「可暂停」?很简单:在每个暂停点直接
return;即可。 - 在暂停点
return之后,又如何做到「可重入」?用switch case手动模拟即可。所以「协程帧」不仅要存放局部变量,还要存放switch case所依赖的状态值,用来表示「协程当前执行到哪一步」。 - 协程最终结束后,如何返回值?同样写入协程帧即可。如果调用方需要用到返回值,到协程帧里读取就是了。由此看来,协程结束后不能立即回收协程帧的内存——毕竟调用方还要读取。
- 如果协程内部要抛出异常怎么办?答案还是协程帧!我们强制让每个协程体都被一个
try-catch包裹,当异常发生时将其写入协程帧,然后当作无事发生;只有当调用方查询协程帧时,才会发现并重新抛出这个异常。
至此,你拥有了一个可暂停、可重入、可并发几十万个的普通函数(以及它对应的协程帧结构体),你称之为「协程」。由于每次暂停都是直接 return; 回到上层调度器,所以它是「非对称的」。不过,协程帧的内存申请与释放时机都需要你非常小心地控制。
理解了以上原理,再去阅读 C++20 协程的相关文档,应该会容易许多。
基于 C++20 协程封装的库
C++20 的协程特性主要是为库作者设计的,因此接口看起来比较底层、复杂;但经过库作者的封装,使用起来会非常简单。以下是一些值得关注的开源库:
- async_simple:阿里巴巴开源的轻量级 C++ 异步框架,提供了基于 C++20 无栈协程(Lazy)、有栈协程(Uthread)以及 Future/Promise 等异步组件。
- cppcoro - A coroutine library for C++:Lewis Baker 编写的经典协程库,注意它最初面向 Coroutines TS,仓库近两年更新较少,更适合作为学习参考。
- Felspar Coro:Coroutine library and toolkit for C++20。
- librf - 协程库:基于 C++ Coroutines 编写的无栈协程库。
- concurrencpp, the C++ concurrency library:Modern concurrency for C++. Tasks, executors, timers and C++20 coroutines to rule them all.
- Coro:用 C 语言
setjmp和longjmp实现的一个最基本的协程,适合用来理解协程的最小骨架。 - UE5Coro:A deeply-integrated C++20 coroutine plugin for Unreal Engine 5(同时兼容 UE4)。
基于 C++20 协程实现的 server 示例:
- co-uring-WebServer:用 C++20 编写的 Web 服务器,可处理静态资源,同时也包含一些学习 C++20 与 io_uring 的相关资料。