C++20 Coroutine

协程(coroutine)是能够暂停和恢复的函数。

协程是线程阻塞造成性能下降的最佳解决方案,尤其是应用在静态线程池中。

吐槽

GoLang 设计的 goroutine 简单好用,大名鼎鼎;虚拟机语言(例如 C#,Javascript,Java)的协程更是逆天改命,强势占领高性能并发的高地;机器队这边简直神速,而我们的人工队 C++ 在干什么呢,不会还没做完 STL network 吧,不会连 format 的编译器支持都没有吧,不会还没推广 import <module> 吧 🥵🥵🥵……

C++20 协程永远的神

C++的协程

C++的协程是:

  1. 对称的。一个协程暂停后,可返回 caller 或恢复任意协程。
  2. 语言级特性。编译器知道你在使用协程。然而不比库强到哪里去。
  3. 无栈(Stackless) 的。没有独立运行时栈,无惧爆栈,调度成本低。

一个协程在被命令「暂停」时,会保证将数据和当前运行位置保存在堆内存(以便恢复现场),然后转移运行权。

协程允许程序员更美观地编写异步代码,也使懒惰求值的算法成为可能。

当一个函数出现以下三种关键字之一,它就是协程:

  1. co_await 暂停(直到被命令「恢复」)。
  2. co_yield 暂停同时返回一个值。
  3. co_return 结束整个协程并返回一个值。

使用协程的理由

  1. 相比于回调和 sender/receiver,协程的使用成本更低性能下限更高
  2. 降低使用者的心智负担和阅历要求,催化高质量工程,资本宠儿。
    • 例如可以摆脱 asio 里常见的 std::shared_ptr

C++协程的弱点

  1. 除非编译器优化,每个协程都需要通过 operator new 来分配 frame:
    • 动态内存分配可能引发性能问题;
    • 在嵌入式或异构(例如 GPU)环境下,缺乏动态内存分配能力,难以工作。
  2. 除非编译器优化,协程的可定制点太多,需要大量间接调用/跳转(而不是内联),同样引起性能问题。
    • 目前,编译器通常难以内联协程;
    • HALO 优化理论:P0981R0
  3. 动态分配间接调用的存在,导致协程暂时无法成为异步框架的最优方法。
  4. Debug 的体验风评不佳。

协程的限制

  1. 不能使用可变参数(variadic arguments),但可以使用变参模板
  2. 不能使用 return
  3. 不能使用占位符返回类型 (auto 或者 Concept)
  4. 不能是 constexpr 函数
  5. 不能是构造函数或者析构函数
  6. 不能是 main 函数

协程的运行过程

所有协程必须关联着几个对象:

  1. promise object,在协程内部进行操作,协程向其写入结果或者异常。
  2. coroutine handle,在协程外部进行操作,用于恢复协程或者销毁协程帧(frame)。
  3. coroutine state,保存协程的信息,分配于堆内存上(除非被优化),对程序员不可见。具体保存着:
    1. promise object
    2. 所有协程参数(按值复制或移动)
    3. 记录暂停点的状态机
    4. 局部变量和临时变量

当协程「开始」时,它会:

  1. 使用 operater new 来构造 coroutine state。
  2. 将所有协程参数拷贝或移动到 coroutine state。小心发生「垂悬引用」,尤其在协程恢复后。
  3. 构造 promise object。优先调用接受所有协程参数的构造函数,否则调用默认构造函数。
  4. 调用 promise.get_return_object(),其结果保存为本地变量。此结果在第一次暂停时会返回给 caller。包括这一步和以前的步骤,所有异常都会抛给 caller,而不是放入 promise object。
  5. 调用 promise.initial_suspend(),紧接着 co_await 它。常见的返回值是 suspend_always 用于懒汉协程,或者 suspend_never 用于饿汉协程
  6. co_await promise.initial_suspend() 恢复后,协程开始运行其函数体。

当协程到达暂停点,它会:

  1. 将 return object 返回给执行权所有者,类型应为协程的返回类型,允许发生隐式转换。

当协程到达 co_return [expr] 语句,它会:

  1. 调用 promise.return_void(),条件是:
    1. co_return;
    2. co_return expr 而 expr 的类型是 void
    3. 函数体结束
  2. 或者调用 promise.return_value(expr)
  3. 析构所有自动变量。
  4. 调用 promise.final_suspend(),紧接着 co_await 它。

当协程因未捕获异常而结束,它会:

  1. 捕获这个异常,并在 catch 块中调用 promise.unhandled_exception()
  2. 调用 promise.final_suspend(),紧接着 co_await 它。此时恢复另一个协程是 UB。

当 coroutine state 被析构(要么遇到 co_return,要么未捕获异常,在要么被 handle 销毁)时,它会:

  1. 析构 promise object。
  2. 析构所有协程参数。
  3. 使用 operator delete 来释放coroutine state。
  4. 转移执行权。

关于堆分配

动态内存分配可能成为严重性能瓶颈

程序员可通过自定义 operator new 来控制 coroutine state 的分配。这部分暂时忽略不讲。

Promise类型推导

这部分暂时忽略不讲。

co_await

一元运算符 co_await 会暂停协程并将转移执行权。其操作数必须定义 operator co_await,或者能通过当前协程的 Promise::await_transform 转换成这种类型。

1
co_await expr

co_await

首先,expr 要被转换为 awaitable,规则如下:

  1. 如果 expr 是由 initial suspend point,final suspend point 或者 yield expression 生成的,那么 awaitable 就是 expr 本身。
  2. 否则,如果当前协程有定义 Promise::await_transform,那么 awaitable 就是 promise.await_transform(expr)
  3. 否则,awaitable 就是 expr 本身。

然后生成一个 awaiter object,规则如下:

  1. 根据重载解析的结果调用 opeartor co_await(awaitable) 或者 awaitable.operator co_await()
  2. 如果重载解析找不到函数,那么 awaiter 就是 awaitable 本身。
  3. 如果重载解析有歧义,那么程序是 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
2
co_yield expr
co_yield braced-init-list

等价于

1
co_await promise.yield_value(expr)