Modnar's Zone

C++20 协程原理

字数统计: 1.9k阅读时长: 6 min
2023/10/25

协程原理

协程与普通函数

普通函数

活跃帧

定义:一个保存了当前正处于调用状态的函数的状态数据的一段内存,函数的返回地址也保存在活跃帧中,可以将其视为函数调用的“挂起恢复点”。由于函数的调用总是遵循严格嵌套的(不存在执行到一半挂起的情况),因而其活跃帧的分配可以更加高效,其形式往往也是栈式的,因而也常常被称为栈帧。

调用与返回

通过调用者与被调用者之间的协作,保存相关必要的执行流程数据(比如执行恢复地址),从而完成函数执行的唤醒及返回动作。

协程

协程泛化了函数的操作,将操作扩充到了三种:挂起、恢复以及释放。

协程活跃帧

包括两部分:

协程帧:涉及协程挂起、恢复流程控制相关的数据。

栈帧:一些函数体内的局部变量等。协程挂起时会被销毁。

挂起操作

在协程被挂起前,可以额外执行一段逻辑,正是这段逻辑使得协程调度成为可能。

如果执行转移到了调用者/恢复者,当前协程的栈帧就会被释放。

恢复操作

类似函数调用一样,对挂起的协程调用 resume 将会分配新的栈帧、保存执行返回的地址。当恢复执行的协程再次挂起或执行完毕时,这次的 resume 才会返回并将执行交还给调用函数。

销毁操作

resume 调用类似,destroy 会重新激活协程活跃帧。与其不同的是,它不会从最近的挂起点恢复执行,而是执行相关数据的析构逻辑。

调用操作

和普通函数的“调用操作”类似

返回操作

当协程内执行返回操作时(co_return 语句),它会执行以下步骤:

  • 保存返回值(具体存放在哪里可以由协程定制)

  • 对内部的局部变量进行析构(不包括传入的参数)

在将执行返回至调用者/恢复者之前,可以执行一段额外的逻辑,这段逻辑可以用来

  • 发布(保存、传递)返回值

  • 恢复另一个一直在等待当前协程执行结果的协程的执行

这段逻辑是完全可以自行定义的

此时协程可以执行挂起或销毁操作,继而将执行交付给调用者/恢复者,携程的栈帧部分被销毁

? 值得注意的是,对于调用操作得到的返回值和返回操作传递的返回值并不相同,因为返回操作在调用者从最初的调用操作恢复很久后才执行

理解 co_await 运算符

C++ 提供的相关关键字与类型

新关键字

co_awaitco_yieldco_return

新类型

coroutine_handle<P>coroutine_traits<Ts...>suspend_alwayssuspend_never

Awaiters 与 Awaitables

Awaitable

支持 co_await 运算符操作的类型就叫做 Awaitable 类型

注意:co_await 是否可应用于一个类型取决于其所处的上下文。携程的 promise 类型是可以通过其 await_transform 方法来改变 co_await 表达式的含义的。

Awaiter

实现了三个可供 co_await 表达式调用的接口:await_readyawait_suspendawait_resume 的类型就是 Awaiter 类型。

获取 Awaiter 对象

假设等待协程拥有 promise_type 为 P,其 promise_type 类型的左值引用对象为 promise。如果 P 定义了 await_transform,那么将 <expr> 传入此函数来获得 Awaitable 对象。若未定义该成员函数,则尝试将 <expr> 的值本身作为 Awaitable 对象。继而,如果此 Awaitable 对象本身重载了 co_await 运算符,则对此调用以获得 Awaiter 对象,否则直接将此 Awaitable 对象作为 Awaiter 对象。

等待 Awaiter

  • 如果涉及协程的调度逻辑,在 await_suspend 中进行处理

  • await_ready 可以用于避免执行“欲挂起”部分的代码,适用于可以确定可同步执行的场景,可以提升执行效率。

协程句柄

coroutine_handleresume 调用,将在相应被恢复的协程再次执行至 <return-to-caller-or-resumer> 处返回。

此外,destroy 应该由库的设计实现者来调用。而且这更应当被用于 RAII 类型中,以避免多次释放。

同样地,对于应用开发者来说,应将 promise 对象视为协程本身的一部分,不要擅自对其操作。对参数而言,也应当使用 coroutine_handle<> 而非 coroutine_handle<Promise>

同步代码,异步执行

需要注意的一点是,对于多线程的场景,如果一个线程挂起了一个协程,那么此时可能会将句柄传递给其他线程来恢复执行,而本线程下的 await_suspend 逻辑可能还未执行完毕!

与有栈协程的对比

最大的差别就是栈式协程无法支持“协程挂起后、执行归还前”执行一段额外的逻辑。

理解 promise 类型

promise 对象

每当协程函数被调用时,promise 对象就与协程帧一同被创建

C++ 实现的原理就是将其视为协程的函数体插入一段生成的代码中。其伪代码大致如下:

1
2
3
4
5
6
7
8
9
10
11
{
co_await promise.initial_suspend();
try {
<body-statements>
} catch (...) {
promise.unhandled_exception();
}

FinalSuspend:
co_await promise.final_suspend();
}

分配协程帧

如果 promise 类型本身提供了一个静态成员函数 P::get_return_object_on_allocation_failure(),那么编译器就会生成 operator new(size_t, nothrow_t) 内存分配函数以覆盖默认的内存分配函数。如果分配时返回 nullptr,就立即调用上述静态函数而不再抛出异常。

自定义协程帧内存分配

为 promise 类型定义 operator new() 重载函数即可代替全局的 new 运算符操作

  • 需要注意的是,即便自定义了内存分配策略,编译器依然可以忽略对其的调用

复制参数至协程帧

  • 如果参数是值传递,那么会调用其 move 构造函数来拷贝至协程帧

  • 如果参数是引用传递(无论左值或右值),那么将只有引用本身被拷贝,他们引用的值则不会

理解 对称式转移

TODO

理解 编译器转译

TODO

渡劫 C++20 协程

需要注意的是,如果参数是值类型,他们的值或被移动或被复制(取决于类型自身的复制构造和移动构造的定义)到协程的状态当中;如果是引用、指针类型,那么存入协程的状态的值将会是引用或指针本身,而不是其指向的对象,这时候需要开发者自行保证协程在挂起后续恢复执行时参数引用或者指针指向的对象仍然存活。

不同于一般的函数,协程的返回值并不是在返回之前才创建,而是在协程的状态创建出来之后马上就创建的。也就是说,协程的状态被创建出来之后,会立即构造 promise_type 对象,进而调用 get_return_object 来创建返回值对象。

  • promise_type 类型的构造函数参数列表如果与协程的参数列表一致,那么构造 promise_type 时就会调用这个构造函数。否则,就通过默认无参构造函数来构造 promise_type。
CATALOG
  1. 1. 协程原理
    1. 1.1. 协程与普通函数
      1. 1.1.1. 普通函数
        1. 1.1.1.1. 活跃帧
        2. 1.1.1.2. 调用与返回
      2. 1.1.2. 协程
        1. 1.1.2.1. 协程活跃帧
        2. 1.1.2.2. 挂起操作
        3. 1.1.2.3. 恢复操作
        4. 1.1.2.4. 销毁操作
        5. 1.1.2.5. 调用操作
        6. 1.1.2.6. 返回操作
  2. 2. 理解 co_await 运算符
    1. 2.1. C++ 提供的相关关键字与类型
      1. 2.1.1. 新关键字
      2. 2.1.2. 新类型
    2. 2.2. Awaiters 与 Awaitables
      1. 2.2.1. Awaitable
      2. 2.2.2. Awaiter
      3. 2.2.3. 获取 Awaiter 对象
      4. 2.2.4. 等待 Awaiter
    3. 2.3. 协程句柄
    4. 2.4. 同步代码,异步执行
      1. 2.4.1. 与有栈协程的对比
  3. 3. 理解 promise 类型
    1. 3.1. promise 对象
      1. 3.1.1. 分配协程帧
        1. 3.1.1.1. 自定义协程帧内存分配
      2. 3.1.2. 复制参数至协程帧
  4. 4. 理解 对称式转移
  5. 5. 理解 编译器转译
  6. 6. 渡劫 C++20 协程