天天看点

C++20:协程协程理论

文章目录

  • 协程理论
    • 协程是函数,函数是协程
    • “普通”的函数
      • 活跃帧
      • “调用”操作
      • ”返回“操作
    • 协程
      • 协程的活跃帧
      • “暂停”操作
      • “恢复”操作
      • “摧毁”操作
      • 协程的“调用”操作
      • 协程的“返回”操作
    • 一个插图
    • 总结
    • 参考

协程理论

这是C++ Coroutines TS系列文章的第一篇,Coroutines是一种新的语言特性,目前有望纳入C++20语言标准(已经纳入)。

在这个系列中,我将讨论C++协程底层的工作机制,以及展示它们如何被用来构建高级抽象。

在这篇文章中,我将描述函数和协程的差异,并且提供一些有关它们支持操作的理论。这篇文章的目的是引入一些基本的概念,这有助于构建你对C++协程的理解方式。

协程是函数,函数是协程

协程是函数的泛化,协程允许函数被暂停,并在之后恢复执行。

我将详细解释这意味着什么,但在这之前,我想先回顾一下“普通”C++函数的工作方式。

“普通”的函数

一个普通的函数可以被认为具有两个操作:调用和返回。

调用操作创建一个活跃帧,暂停调用函数(调用者)的执行,并将执行权转移到被调用函数的开头。

返回操作将返回值传递给调用者,摧毁活跃帧,然后恢复调用函数(调用者)的执行。

让我们再分析一下这些语义。。。

活跃帧

那么,“活跃帧”是什么?

你可以将它理解为内存块,它维护了一个调用函数当前的状态。这些状态包括被传递给它的所有参数值和所有局部变量值。

对于“普通”函数,活跃帧也包含返回地址 - 从函数返回时将执行转移到的指令的地址 - 以及用于调用函数的活跃帧的地址。您可以将这些信息一起描述为函数调用的“延续”。即,它们描述了该函数完成时在哪个点应继续执行哪个函数的调用。

对于“普通”函数,所有活跃帧有严格嵌套的生命周期。这种严格的嵌套允许使用高效的内存分配数据结构来为每个函数调用分配和释放活跃帧。这些数据结构通常被称为“栈”。

当一个活跃帧被分配在栈上时,它通常被称为“栈帧”。这种栈数据结构非常普遍,以至于大多数(所有?)CPU架构都有专用的寄存器,用于保存指向栈顶部的指针(例如,x64架构下的

rsp

)。

为了给新的活跃帧分配控件,你只需要将该寄存器的值增加帧的大小。为了释放活跃帧的空间,你只需要将该寄存器的值减小帧的大小。

“调用”操作

当一个函数调用另一个函数时,调用者必须首先为暂停执行做准备。

“暂停“步骤通常设计将当前保存在CPU寄存器中的所有值保存到内存中,以便以后在函数恢复执行时根据需要可以还原这些值。取决于这个函数的调用约定,调用者和被调用者可以协调谁来保存这些寄存器的值,但是你仍然可以将他们视为”调用“操作的一部分。

调用者还将传递给被调函数的所有参数的值存储到新的活跃帧中,在此它们可以被函数访问。

最后,调用者将调用者的恢复点地址写入新的活跃帧,并将执行权转移到被调函数的开头。

在X86/X86架构中,此最终操作具有其自己的指令,

call

指令,它将下一条指令的地址写入栈中,递增栈寄存器的值,然后跳转到该指令操作数中指定的地址。

”返回“操作

当函数通过

return

语句返回时,函数首先存储返回值到调用者可以访问的地方。这将是调用者的活跃帧中或者这个函数的活跃帧中。

接着该函数摧毁活跃帧通过:

  • 摧毁所有局部变量
  • 摧毁所有参数对象
  • 释放被活跃帧使用的内存

最后,恢复调用者的执行通过:

  • 恢复调用者的活动框架通过设置栈寄存器以只想调用者的活跃帧,然后恢复所有可能被该函数破坏的寄存器
  • 跳转到调用者的恢复点,恢复点在”调用“操作中被存储

请注意,与“调用”操作一样,某些调用约定可能会在调用方和被调用方功能说明之间划分“返回”操作的职责。

协程

协程概括了一个函数的操作,通过划分在调用和返回操作中的一些步骤为三个额外的操作:暂停,恢复和摧毁(Suspend, Resume and Destroy)。

暂停操作在函数中的当前点暂停协程的执行,并将执行权返回给调用者或恢复者,而不会破环活跃帧。在暂停协程执行后,在暂停点范围内的所有对象仍保持活动状态。

注意,像函数的”返回“操作一样,一个协程只能被协程本身暂停。

恢复操作恢复一个暂停协程的执行。这将重新活跃协程的活跃帧。

摧毁操作摧毁活跃帧并且不恢复协程的执行。任何在暂停点返回内的对象都将被摧毁。被用于存储这个活跃帧的内存将被释放。

协程的活跃帧

因为协程可以被暂停而无需摧毁活跃帧,我们不能再保证活跃帧的生命周期将严格嵌套。这一位置活跃帧通常不使用栈数据结构分配,而是可能被存储在堆上。

如果编译器可以证明协程的生命周期确实严格地嵌套在调用者的生存期内,则C ++协程TS中有一些规定允许从调用者的活跃帧中为协程帧分配内存。在许多情况下,只要您有一个足够聪明的编译器,就可以避免堆分配。对于协程,在协程暂停时需要保留活跃帧的某些部分,而只有在协程执行时才需要保留某些部分。例如,范围不跨越任何协程暂停点的变量的生存期可能会存储在栈中。

您可以从逻辑上将协程的活跃帧想象为由两部分组成:“协程帧”和“栈帧”。

“协程帧”维护了协程活跃帧的一部分,该活跃帧在协程被暂停时仍然存在,而“栈帧”部分仅在协程执行期间存在,而在协程暂停并将执行权转移回调用者/恢复者时被释放。

“暂停”操作

协程的暂停操作允许协程在函数中间暂停执行,并转移执行权给协程的调用者或恢复者。

协程的体内某些点被指定为暂停点。 在C++协程TS中,这些暂停点是通过使用

co_await

co_yield

关键字来标识的。

当协程到达这些暂停点之一时,它首先准备协程以恢复通过:

  • 确保将寄存器中保存的所有值写入协程帧
  • 向协程帧中写入一个值,该值指示协程所暂停的暂停点。这使后续的Resume操作可以知道在哪里继续执行协程,或者通过后续的Destroy可以知道范围内哪些值需要销毁。

一旦协程准备完毕,协程将被考虑“暂停”。

在执行权转移会调用者或恢复者之前,协程有机会执行一些其他的逻辑。这些额外的逻辑访问协程帧的句柄,该句柄可用于在之后恢复或摧毁协程。

在协程进入“暂停”状态后执行逻辑的这种能力允许协程被调度恢复,而无需同步,如果协程在进入“暂停”状态之前被调度恢复,则需要同步。 我将在以后的文章中对此进行更详细的介绍。

然后,协程可以选择立即恢复/继续执行协程,也可以选择将执行权转移回调用方/恢复方。

如果执行权转移到调用者/恢复者,则协程活跃帧的栈帧部分将被释放并从栈中弹出。

“恢复”操作

恢复操作可以在处于暂停状态的协程上执行。

当一个函数想要恢复协程是,他需要“调用”该函数的特定调用。恢复者标识特定的调用方式是通过在提供给相应的暂停操作的协程帧句柄上调用

void resume()

方法。

就像一个正常的函数调用一样,调用

resume()

将分配一个新的栈帧,然后在栈帧中存储调用者的返回地址,这些操作将在执行权转移给被调函数之前进行。

然而,代替转移执行权到函数的开始,恢复操作将转移执行权到函数最后一次被暂停的点。通过从协程帧中加载恢复点并跳转到改点来完成这个操作。

当协程接下来暂停或运行完毕时,调用

resume()

将返回并恢复调用函数的执行。

“摧毁”操作

摧毁操作摧毁协程帧,不会恢复协程的执行。

这个操作只能在被暂停的协程上执行。

摧毁操作的行为更像重新激活协程活跃帧的恢复操作,包括分配一个新的栈帧并存储摧毁操作的调用者的返回地址。

然而,代替转移执行权到最后一次的挂起点,它将转移执行权到一个替换的代码路径,这将摧毁所有作用域中的局部变量(在暂停点之前的),然后释放被协程帧使用的内存。

与恢复操作相似,摧毁操作标识特定的将要摧毁的活跃帧是通过在协程帧句柄上调用

void destory()

方法。

协程的“调用”操作

协程的调用操作和普通函数的调用操作类似。事实上,从调用者的角度来看,没有什么不同。

然而,不只是当函数执行完毕时,执行权才返回给调用者,协程的调用操作也将恢复调用者的执行,当协程抵达它的第一个挂起点时。

当在一个协程上执行调用操作时,调用者分配一个新的栈帧,写入参数到栈帧中,写入返回地址到栈帧中,并且转移执行权给协程。这与调用一个普通的函数完全相同。

协程第一件要做的事情时在堆上分配一个协程帧,然后从栈帧中拷贝或移动参数到协程帧中,以便延长参数的生命周期超出第一次暂停点。

协程的“返回”操作

协程的返回操作与普通函数的有点不同。

当一个协程执行一条

return

语句时(根据技术规范为

co_return

),它将存储返回值然后摧毁所有作用域中的变量(不包括参数)。

之后协程将有一个机会去执行一些额外的逻辑在转移执行权到调用者/恢复者之前。

这些额外的逻辑可能执行一些操作去发布返回值,也可能恢复另一个等待结果的协程。这完全是自定义的。

之后协程可以执行一个暂停操作(保持协程帧的活跃)或一个摧毁操作(摧毁协程帧)。

之后执行权被转移给调用者/恢复者根据暂停/ 恢复操作的语义,将活跃帧的栈帧组件从栈中弹出。

需要注意的是,被传递给返回操作的返回值与调用操作的返回值是不一样的,因为返回操作可能在调用者从初始调用操作恢复很久之后才执行。

一个插图

为了帮助将这些概念付诸实践,我想通过一个简单的示例说明协程被调用,暂停和恢复时的发生了什么。

假定有一个函数(或协程)

f()

,它调用了一个协程

x(int a)

调用之前的情形大致如下:

栈                         寄存器                   堆

                          +------+
+---------------+ <------ | rsp  |
|  f()          |         +------+
+---------------+
| ...           |
|               |
           

当调用

x(42)

时,首先为

x()

创建一个栈帧,像普通的函数一样。

栈                         寄存器                   堆
+----------------+ <-+
|  x()           |   |
| a  = 42        |   |
| ret= f()+0x123 |   |    +------+
+----------------+   +--- | rsp  |
|  f()           |        +------+
+----------------+
| ...            |
|                |
           

然后,当协程

x()

已经为协程帧在堆上分配完内存并且参数值已被拷贝或移动至协程帧后,我们将最终得到一样东西,这将在下一个图解中看到。注意编译器通常会维护协程帧的地址(例如,MSVC存储在

rbp

寄存器中)。

栈                         寄存器                   堆
+----------------+ <-+
|  x()           |   |
| a  = 42        |   |                   +-->  +-----------+
| ret= f()+0x123 |   |    +------+       |     |  x()      |
+----------------+   +--- | rsp  |       |     | a =  42   |
|  f()           |        +------+       |     +-----------+
+----------------+        | rbp  | ------+
| ...            |        +------+
|                |
           

如果协程

x()

之后调用了另一个普通函数

g()

,他将看起来像下面一样。

栈                         寄存器                   堆
+----------------+ <-+
|  g()           |   |
| ret= x()+0x45  |   |
+----------------+   |
|  x()           |   |
| coroframe      | --|-------------------+
| a  = 42        |   |                   +-->  +-----------+
| ret= f()+0x123 |   |    +------+             |  x()      |
+----------------+   +--- | rsp  |             | a =  42   |
|  f()           |        +------+             +-----------+
+----------------+        | rbp  |
| ...            |        +------+
|                |
           

g()

返回时,将摧毁它的活跃帧然后存储

x()

的活跃帧。假设

g()

的返回值存储在局部变量

b

中,

b

被存储在协程帧中。

栈                         寄存器                   堆
+----------------+ <-+
|  x()           |   |
| a  = 42        |   |                   +-->  +-----------+
| ret= f()+0x123 |   |    +------+       |     |  x()      |
+----------------+   +--- | rsp  |       |     | a =  42   |
|  f()           |        +------+       |     | b = 789   |
+----------------+        | rbp  | ------+     +-----------+
| ...            |        +------+
|                |
           

如果此时

x()

击中了一个暂停点并且在不摧毁它的活跃帧的情况下暂停执行,他么执行权将返回给

f()

这导致

x()

的栈帧部分将从栈帧中弹出,而协程帧保留在堆中。当协程第一次暂停时,一个返回值被返回给调用者。这个返回值通常维护一个协程帧的句柄,它可以被用来在之后恢复协程的执行。当

x()

暂停时,也将存储

x()

的恢复点的地址在协程帧中(

RP

为resume-point)。

栈                         寄存器                   堆
                                        +----> +-----------+
                          +------+      |      |  x()      |
+----------------+ <----- | rsp  |      |      | a =  42   |
|  f()           |        +------+      |      | b = 789   |
| handle     ----|---+    | rbp  |      |      | RP=x()+99 |
| ...            |   |    +------+      |      +-----------+
|                |   |                  |
|                |   +------------------+
           

现在这个句柄可以作为一个正常的值在函数之间传递。之后,从一个不同的调用栈或者甚至在一个不同的线程上,一些东西(我们称为

h()

)将决定去恢复协程的执行。例如,当一个异步I/O操作完成时。

恢复协程的函数调回

void resume(handle)

去恢复协程的执行。对于调用者而言,这就像是任何其他普通的带有

void

返回类型和一个单独参数的函数一样。

这将创建一个新的栈帧,它记录了调用

resume()

的调用者的返回地址,激活协程帧通过加载它的地址到寄存器,并且在存储在协程帧中的恢复点处恢复

x()

的执行。

栈                         寄存器                   堆
+----------------+ <-+
|  x()           |   |                   +-->  +-----------+
| ret= h()+0x87  |   |    +------+       |     |  x()      |
+----------------+   +--- | rsp  |       |     | a =  42   |
|  h()           |        +------+       |     | b = 789   |
| handle         |        | rbp  | ------+     +-----------+
+----------------+        +------+
| ...            |
|                |
           

总结

我们将协程描述为一个函数的概括(generalisation),出了”普通“函数的”调用“和”返回“操作之外,它还有3个额外的操作 - “暂停”,”恢复“和”摧毁“。

我希望这将提供一些有用的心里框架关于如何思考协程和它们的控制流。

在下一篇文章中,我将讨论C++协程技术规范语言扩展中的机制并解释编译器是如何将你写的代码翻译成协程。

参考

翻译自https://lewissbaker.github.io/2017/09/25/coroutine-theory

继续阅读