C++20 协程(1):协程理论
文章目录
这是C++ Coroutines TS系列文章的第一篇,Coroutines是一种新的语言特性,目前有望纳入C++20语言标准(已经纳入)。
在这个系列中,我将讨论C++协程底层的工作机制,以及展示它们如何被用来构建高级抽象,例如cppcore库所提供的。
在这篇文章中,我将描述函数(Functions)和协程(Coroutines)的差异,并且提供一些有关它们的理论。这篇文章的目的是引入一些基本的概念,这有助于构建你对C++协程的理解方式。
协程是函数,函数可以是协程
协程是函数的泛化,协程允许函数被暂停(suspended),并在之后恢复(resumed)执行。
我将详细解释这意味着什么,但在这之前,我想先回顾一下一个“普通”C++函数的工作方式。
“普通”的函数
一个普通的函数可以被认为具有两个操作:调用和返回(注意,在这里我将“抛出异常”笼统的归为返回操作)。
调用操作创建一个活跃帧,暂停调用函数(调用者)的执行,并将执行权转移到被调函数的开头。
返回操作将返回值传递给调用者,摧毁活跃帧,然后恢复调用函数(调用者)的执行。
让我们再分析一下这些语义。。。
活跃帧
那么,“活跃帧”是什么?
你可以将它理解为内存块,它维护了一个函数调用的状态。这些状态包括所有参数值(外部传递的)和所有局部变量值。
对于“普通”函数,活跃帧也包含了返回地址(从函数返回时转移到的指令的地址)以及调用函数的活跃帧的地址。你可以将这些信息看成是函数调用的“延续”。即,它们描述了在该函数返回时,应在哪个地方继续执行。
对于“普通”函数,所有活跃帧有严格嵌套的生命周期。这种严格的嵌套可以使用高效的内存分配数据结构来为每个函数调用分配和释放活跃帧。该数据结构通常被称为“栈”。
当一个活跃帧被分配在栈上时,它通常被称为“栈帧”。
这种栈数据结构非常普遍,以至于大多数(所有?)CPU架构都有专用的寄存器,用于保存指向栈顶部的指针(例如,x64架构下的rsp
寄存器)。
为了给新的活跃帧分配空间,你只需要将该寄存器的值增加(increment)帧的大小。为了释放活跃帧的空间,你只需要将该寄存器的值减小(decrement)帧的大小。
“调用”操作
当一个函数调用另一个函数时,调用者必须先为暂停执行做准备。
“暂停”步骤通常涉及将当前保存在CPU寄存器中的所有值保存到内存中,以便之后在函数恢复执行时根据需要还原这些值。取决于这个函数的调用约定,调用者和被调用者可以协调谁来保存这些寄存器的值,但是你仍然可以将它们视为调用操作的一部分。
调用者会将传递给被调函数的所有参数的值存储到新的活跃帧中。
最后,调用者将调用者的恢复点地址写入新的活跃帧,并将执行权转移到被调函数的开头。
在X86/X64架构中,此最终操作有专门的指令,call
指令,它将下一条指令的地址写入栈中,递增栈寄存器,然后跳转到该指令操作数中指定的地址。
“返回”操作
当函数通过return
语句返回时,函数首先存储返回值到调用者可以访问的地方。该地方要么是调用者的活跃帧,要么是该函数的活跃帧。
然后该函数按以下步骤摧毁活跃帧:
- 摧毁所有局部变量
- 摧毁所有参数对象
- 释放被活跃帧使用的内存
最后,恢复调用者的执行通过如下步骤:
- 恢复调用者的活跃帧通过设置栈寄存器,使栈寄存器指向调用者的活跃帧,然后恢复所有可能被破坏的寄存器
- 跳转到调用者的恢复点,恢复点在“调用”操作中被存储
注意,与“调用”操作一样,某些调用约定可能会分离“返回”操作的职责。
协程
协程概括了一个函数的操作,通过划分在调用和返回操作中的一些步骤为三个额外的操作:暂停,恢复和摧毁(Suspend, Resume and Destroy)。
暂停操作暂停协程的执行,并将执行权返回给调用者(caller)或恢复者(resumer),但不会摧毁活跃帧。在协程暂停执行后,在暂停点作用域内的所有对象仍保持活动状态(不会被释放)。
注意,像函数的返回操作一样,一个协程只能被协程本身暂停。
恢复操作让协程在上次暂停的地方恢复执行。这将重新激活协程的活跃帧。
摧毁操作摧毁活跃帧并且不恢复协程的执行。任何在暂停点作用域内的对象都将被摧毁。用于存储该活跃帧的内存也将被释放。
协程的活跃帧
因为协程可以被暂停而无需摧毁活跃帧,我们不能再保证活跃帧的生命周期是严格嵌套的。这意味着活跃帧通常不使用栈数据结构,而是可能存储在堆上。
如果编译器可以证明协程的生命周期确实严格地嵌套在调用者的生命周期内,则C++协程TS中有一些规定允许从调用者的活跃帧中为协程帧分配内存。在许多情况下,只要你有一个足够聪明的编译器,就可以避免堆分配。
活跃帧的一部分需要在协程暂停时保留,而另一部分只需要在协程执行时保留。例如,作用域不跨越任何协程暂停点的变量可能会存储在栈中。
您可以从逻辑上将协程的活跃帧想象为由两部分组成:“协程帧”和“栈帧”。
“协程帧”维护了协程活跃帧的一部分,它在协程暂停时仍然存在,而“栈帧”仅在协程执行时存在,并在协程暂停或返回执行权时释放。
“暂停”操作
协程的暂停操作允许协程在函数执行时暂停执行,并转移执行权给协程的调用者或恢复者。
协程内的某些地方被标识为暂停点。 在C++协程TS中,这些暂停点通过co_await
或co_yield
来标识。
当协程到达暂停点时,按以下步骤保存协程当前的状态:
- 将寄存器中保存的所有值写入协程帧
- 向协程帧中写入一个值,该值表示协程的暂停点。这使得后续的恢复操作知道在哪里继续执行协程,或者让后续的摧毁操作知道哪些值需要销毁。
上述过程完成后,协程就被认为是“暂停”了。
在执行权转移回调用者或恢复者之前,协程有机会执行一些额外的逻辑。这些额外的逻辑可以访问协程帧的句柄,该句柄可用于在之后恢复或摧毁协程。
在协程进入“暂停”状态后可以执行逻辑的这种能力,允许协程被调度恢复,而无需同步,如果协程在进入“暂停”状态之前被调度恢复,由于协程“暂停”和“恢复”的潜在竞争状态,则需要同步。我将在未来的文章中对此进行更详细的介绍。
此时协程可以选择立即恢复/继续执行,也可以选择将执行权转移回调用者/恢复者。
如果执行权转移到调用者/恢复者,则协程活跃帧的栈帧部分将被释放并从栈中弹出。
“恢复”操作
恢复操作可以在处于暂停状态的协程上执行。
当一个函数想要恢复协程时,它需要“调用”void resume()
方法,该方法被协程帧句柄提供。
就像一个正常的函数调用一样,调用resume()
将分配一个新的栈帧,并在栈帧中存储调用者的返回地址,这些操作将在执行权转移给被调函数之前进行。
然而,代替转移执行权到函数的开始,恢复操作将转移执行权到函数最后一次被暂停的点。通过从协程帧中加载恢复点并跳转到该点来完成这个操作。
当协程下一次暂停或运行完毕时,resume()
将返回并恢复调用函数的执行。
“摧毁”操作
摧毁操作摧毁协程帧,且不会恢复协程的执行。
这个操作只能在被暂停的协程上执行。
摧毁操作的行为很像恢复操作,它重新激活协程的活跃帧,分配新的栈帧并存储调用者的返回地址。
然而,代替转移执行权到上次的挂起点,它将转移执行权到一个替换的代码路径(code-path),这将摧毁(调用析构函数)所有暂停点作用域内的局部变量,然后释放被协程帧使用的内存。
与恢复操作相似,摧毁操作通过在协程帧句柄上调用void destory()
方法。
协程的“调用”操作
协程的调用操作和普通函数的调用操作类似。事实上,从调用者的角度来看,没有什么不同。
然而,不只是当函数执行完毕时,执行权才返回给调用者,协程的调用操作将在抵达第一个挂起点时恢复调用者的执行。
当在一个协程上执行调用操作时,调用者分配一个新的栈帧,写入参数到栈帧中,写入返回地址到栈帧中,然后转移执行权给协程。这与调用一个普通函数完全相同。
协程第一件要做的事情是在堆上分配一个协程帧,然后从栈帧中拷贝/移动参数到协程帧中,使得参数的生命周期超出第一次暂停点。
协程的“返回”操作
协程的返回操作与普通函数的有点不同。
当协程执行return
语句时(根据技术规范为co_return
),它将存储返回值到某个地方(具体位置可以定制),然后摧毁所有作用域中的局部变量(不包括参数)。
在转移执行权到调用者/恢复者之前,协程有机会执行一些额外的逻辑。
这些额外的逻辑可能执行一些操作去发布(publish)返回值,也可能恢复另一个等待该结果的协程。这完全是自定义的。
然后协程将执行一个暂停操作(保留协程帧)或一个摧毁操作(摧毁协程帧)。
根据暂停/摧毁操作的语义,之后执行权转移给调用者/恢复者,从栈中弹出活跃帧的栈帧。
需要注意的是,传递给返回操作的返回值与调用操作的返回值是不一样的,因为返回操作可能在调用者从初始调用操作恢复(返回)很久之后才执行。
一个插图
为了帮助将这些概念付诸实践,我想通过一个简单的示例说明协程被调用,暂停和恢复时都发生了什么。
假设有一个函数(或协程)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++协程TS语言扩展中的机制并解释编译器如何将你写的代码翻译成协程。
参考
翻译自https://lewissbaker.github.io/2017/09/25/coroutine-theory