协程理论
这是关于C++ 协程 TS的系列文章中的第一篇,C++ 协程 TS 是一项新的语言功能,目前有望纳入 C++20 语言标准。
在本系列中,我将介绍 C++ 协程的底层机制如何工作,并展示如何使用它们来构建有用的高级抽象,例如cppcoro 库提供的抽象。
在这篇文章中,我将描述函数和协程之间的差异,并提供一些有关它们支持的操作的理论。本文的目的是介绍一些基本概念,这些概念将有助于构建您对 C++ 协程的思考方式。
协程是函数也是协程
协程是函数的概括,它允许函数暂停然后恢复。
我将更详细地解释这意味着什么,但在此之前我想首先回顾一下“正常”C++ 函数的工作原理。
“正常”功能
一个普通的函数可以被认为有两个操作:调用和返回 (请注意,我在这里将“抛出异常”广泛地集中在返回操作下)。
Call操作创建一个激活帧,暂停调用函数的执行并将执行转移到被调用函数的开头。
Return操作将返回值传递给调用者,销毁激活帧,然后在调用者调用函数的点之后恢复调用者的执行。
让我们进一步分析这些语义......
激活框架
那么这个“激活框架”是什么?
您可以将激活帧视为保存函数特定调用的当前状态的内存块。此状态包括传递给它的任何参数的值以及任何局部变量的值。
对于“普通”函数,激活帧还包括返回地址(从函数返回时将执行转移到的指令的地址)以及用于调用调用函数的激活帧的地址。您可以将这些信息一起视为描述函数调用的“继续”。 IE。它们描述了当该函数完成时,哪个函数的哪个调用应该继续执行。
对于“正常”函数,所有激活帧都具有严格嵌套的生命周期。这种严格的嵌套允许使用高效的内存分配数据结构来为每个函数调用分配和释放激活帧。这种数据结构通常称为“堆栈”。
当在此堆栈数据结构上分配激活帧时,它通常称为“堆栈帧”。
这种堆栈数据结构非常常见,以至于大多数(所有?)CPU 架构都有一个专用寄存器,用于保存指向堆栈顶部的指针(例如,在 X64 中它是寄存器rsp)。
要为新的激活帧分配空间,只需将该寄存器增加帧大小即可。要为激活帧释放空间,只需将此寄存器减少帧大小即可。
“呼叫”操作
当一个函数调用另一个函数时,调用者必须首先做好挂起的准备。
此“挂起”步骤通常涉及将当前保存在 CPU 寄存器中的任何值保存到内存中,以便稍后在函数恢复执行时需要时可以恢复这些值。根据函数的调用约定,调用者和被调用者可能会协调谁保存这些寄存器值,但您仍然可以将它们视为作为Call操作的一部分执行。
调用者还将传递给被调用函数的任何参数的值存储到新的激活帧中,函数可以在其中访问它们。
最后,调用者将调用者的恢复点地址写入新的激活帧,并将执行转移到被调用函数的开头。
在 X86/X64 架构中,这个最终操作有自己的指令,该call 指令将下一条指令的地址写入堆栈,将堆栈寄存器增加地址的大小,然后跳转到指令操作数中指定的地址。
“返回”操作
当函数通过return- 语句返回时,该函数首先将返回值(如果有)存储在调用者可以访问的位置。这可能是在调用者的激活帧中,也可能是在函数的激活帧中(对于跨越两个激活帧之间边界的参数和返回值来说,区别可能会有点模糊)。
然后该函数通过以下方式销毁激活帧:
- 在返回点销毁范围内的任何局部变量。
- 销毁任何参数对象
- 释放激活帧使用的内存
最后,它通过以下方式恢复调用者的执行:
- 通过将堆栈寄存器设置为指向调用者的激活帧并恢复可能已被函数破坏的任何寄存器来恢复调用者的激活帧。
- 跳转到“呼叫”操作期间存储的呼叫者的恢复点。
请注意,与“调用”操作一样,某些调用约定可能会在调用者和被调用者函数的指令之间划分“返回”操作的职责。
协程
协程通过将Call和Return操作中执行的一些步骤分离为三个额外操作来 概括函数的操作: Suspend、Resume和Destroy。
挂起操作会在函数内的当前点挂起协程的执行,并将执行转移回调用方或恢复方,而不会破坏激活帧。协程执行暂停后,暂停时作用域内的任何对象仍保持活动状态。
请注意,与函数的Return操作一样,协程只能在协程本身内部的明确定义的挂起点处挂起。
Resume操作可在挂起的协程的挂起点恢复执行。这将重新激活协程的激活框架。
销毁操作会销毁激活帧而不恢复协程的执行。挂起点范围内的任何对象都将被销毁。用于存储激活帧的内存被释放。
协程激活帧
由于协程可以在不破坏激活帧的情况下挂起,因此我们不能再保证激活帧的生存期将严格嵌套。这意味着激活帧通常不能使用堆栈数据结构进行分配,因此可能需要存储在堆上。
C++ 协程 TS 中有一些规定,如果编译器可以证明协程的生命周期确实严格嵌套在调用方的生命周期内,则允许从调用方的激活帧分配协程帧的内存。如果您有足够智能的编译器,则在许多情况下这可以避免堆分配。
对于协程,激活框架的某些部分需要在协程挂起期间保留,而有些部分只需要在协程执行时保留。例如,范围不跨越任何协程挂起点的变量的生命周期可能会存储在堆栈上。
您可以从逻辑上认为协程的激活框架由两部分组成:“协程框架”和“堆栈框架”。
“协程帧”保存协程激活帧的一部分,该部分在协程挂起时持续存在,而“堆栈帧”部分仅在协程执行时存在,并在协程挂起并将执行转移回调用方/恢复方时释放。
“暂停”操作
协程的挂起操作允许协程在函数中间暂停执行,并将执行转移回协程的调用者或恢复者。
协程体内的某些点被指定为挂起点。在 C++ 协程 TS 中,这些挂起点通过使用co_await或co_yield关键字来标识。
当协程到达这些挂起点之一时,它首先通过以下方式准备协程恢复:
- 确保寄存器中保存的任何值都写入协程框架
- 向协程帧写入一个值,指示协程暂停在哪个暂停点。这允许后续的Resume操作知道在哪里恢复协程的执行,或者后续的Destroy 操作 知道哪些值在范围内并且需要销毁。
一旦协程准备好恢复,协程就被视为“暂停”。
然后,协程有机会在执行转移回调用者/恢复者之前执行一些附加逻辑。该附加逻辑可以访问协程框架的句柄,该句柄可用于稍后恢复或销毁它。
这种在协程进入“挂起”状态后执行逻辑的能力允许协程被安排为恢复,而无需同步,否则如果协程在进入“挂起”状态之前被安排为恢复,则需要同步,因为暂停和恢复协程进行竞赛的可能性。我将在以后的帖子中更详细地讨论这一点。
然后,协程可以选择立即恢复/继续执行协程,或者可以选择将执行转移回调用者/恢复者。
如果执行转移到调用者/恢复者,则协程激活帧的堆栈帧部分将被释放并从堆栈中弹出。
“恢复”操作
恢复操作可以在当前处于“挂起”状态的协程上执行。
当函数想要恢复协程时,它需要有效地“调用”到该函数的特定调用的中间。恢复程序识别要恢复的特定调用的方式是调用提供给相应挂起void resume()操作的协程帧句柄上的方法。
就像普通的函数调用一样,此调用resume()将分配一个新的堆栈帧,并将调用者的返回地址存储在堆栈帧中,然后再将执行转移到函数。
但是,它不会将执行转移到函数的开头,而是将执行转移到函数中上次挂起的位置。它通过从协程框架加载恢复点并跳转到该点来实现这一点。
当协程下次挂起或运行完成时,此调用resume() 将返回并恢复调用函数的执行。
“破坏”行动
Destroy操作会销毁协程框架,但不会恢复协程的执行。
此操作只能在挂起的协程上执行。
Destroy操作的行为与Resume操作非常相似,因为它重新激活协程的激活帧,包括分配新的堆栈帧并存储Destroy操作的调用者的返回地址 。
然而,它不是在最后一个挂起点将执行转移到协程主体,而是将执行转移到另一个代码路径,该代码路径在挂起点调用范围内所有局部变量的析构函数,然后释放协程使用的内存。协程框架。
与Resume操作类似,Destroy操作通过调用相应Suspendvoid destroy()操作期间提供的协程帧句柄上的方法来识别要销毁的特定激活帧 。
协程的“调用”操作
协程的调用操作与普通函数的调用操作非常相似。事实上,从调用者的角度来看没有什么区别。
然而,当函数运行完成时,执行不会仅返回到调用者,而对于协程,调用操作将在协程到达其第一个挂起点时恢复调用者的执行。
当对协程执行Call操作时,调用者分配一个新的堆栈帧,将参数写入堆栈帧,将返回地址写入堆栈帧并将执行转移到协程。这与调用普通函数完全相同。
协程要做的第一件事是在堆上分配一个协程帧,并将参数从堆栈帧复制/移动到协程帧中,以便参数的生命周期超出第一个挂起点。
协程的“返回”操作
协程的Return操作与普通函数的 Return 操作略有不同。
当协程执行return-statement(co_return根据 TS)操作时,它将返回值存储在某处(具体存储位置可以由协程自定义),然后销毁任何范围内的局部变量(但不包括参数)。
然后,协程有机会在将执行转移回调用者/恢复者之前执行一些附加逻辑。
此附加逻辑可能会执行某些操作来发布返回值,或者可能会恢复另一个正在等待结果的协程。它是完全可定制的。
然后,协程执行挂起操作(保持协程框架处于活动状态)或销毁操作(销毁协程框架)。
然后根据挂起/销毁操作语义将执行转移回调用者/恢复者 ,将激活帧的堆栈帧组件从堆栈中弹出。
请务必注意,传递给Return操作的返回值与从Call操作返回的返回值不同,因为返回操作可能会在调用者从初始Call 操作恢复之后很长时间才执行。
一个例子
为了帮助将这些概念转化为图片,我想通过一个简单的示例来演示调用协程、挂起并稍后恢复时会发生什么情况。
假设我们有一个函数(或协程),f()它调用协程x(int a)。
在调用之前,我们遇到的情况有点像这样:
STACK REGISTERS HEAP
+------+
+---------------+ <------ | rsp |
| f() | +------+
+---------------+
| ... |
| |
然后,当x(42)调用 时,它首先为 创建一个堆栈帧x(),就像普通函数一样。
STACK REGISTERS HEAP
+----------------+ <-+
| x() | |
| a = 42 | |
| ret= f()+0x123 | | +------+
+----------------+ +--- | rsp |
| f() | +------+
+----------------+
| ... |
| |
然后,一旦协程x()为堆上的协程帧分配了内存并将参数值复制/移动到协程帧中,我们最终将得到如下图所示的结果。请注意,编译器通常会将协程帧的地址保存在堆栈指针的单独寄存器中(例如,MSVC 将其存储在寄存器中rbp)。
STACK REGISTERS HEAP
+----------------+ <-+
| x() | |
| a = 42 | | +--> +-----------+
| ret= f()+0x123 | | +------+ | | x() |
+----------------+ +--- | rsp | | | a = 42 |
| f() | +------+ | +-----------+
+----------------+ | rbp | ------+
| ... | +------+
| |
如果协程x()随后调用另一个普通函数,g()它将看起来像这样。
STACK REGISTERS HEAP
+----------------+ <-+
| g() | |
| ret= x()+0x45 | |
+----------------+ |
| x() | |
| coroframe | --|-------------------+
| a = 42 | | +--> +-----------+
| ret= f()+0x123 | | +------+ | x() |
+----------------+ +--- | rsp | | a = 42 |
| f() | +------+ +-----------+
+----------------+ | rbp |
| ... | +------+
| |
当g()返回时,它将破坏其激活框架并恢复 的x()激活框架。假设我们将g()的返回值保存在b存储在协程框架中的局部变量中。
STACK REGISTERS HEAP
+----------------+ <-+
| x() | |
| a = 42 | | +--> +-----------+
| ret= f()+0x123 | | +------+ | | x() |
+----------------+ +--- | rsp | | | a = 42 |
| f() | +------+ | | b = 789 |
+----------------+ | rbp | ------+ +-----------+
| ... | +------+
| |
如果x()现在到达挂起点并挂起执行而不破坏其激活帧,则执行返回到f()。
这会导致堆栈框架部分从x()堆栈中弹出,而将协程框架留在堆上。当协程第一次挂起时,会向调用者返回一个返回值。此返回值通常包含挂起的协程框架的句柄,可用于稍后恢复它。当挂起时,它还存储 协程帧中x()的恢复点的地址(称为恢复点)。x()RP
STACK REGISTERS HEAP
+----> +-----------+
+------+ | | x() |
+----------------+ <----- | rsp | | | a = 42 |
| f() | +------+ | | b = 789 |
| handle ----|---+ | rbp | | | RP=x()+99 |
| ... | | +------+ | +-----------+
| | | |
| | +------------------+
该句柄现在可以作为函数之间的正常值传递。在稍后的某个时刻,可能来自不同的调用堆栈,甚至在不同的线程上,某些东西(例如,h())将决定恢复该协程的执行。例如,当异步 I/O 操作完成时。
恢复协程的函数调用一个void resume(handle)函数来恢复协程的执行。对于调用者来说,这看起来就像对void带有单个参数的返回函数的任何其他正常调用一样。
这将创建一个新的堆栈帧,记录调用者的返回地址 resume(),通过将其地址加载到寄存器中来激活协程帧,并x()在协程帧中存储的恢复点恢复执行。
STACK REGISTERS HEAP
+----------------+ <-+
| x() | | +--> +-----------+
| ret= h()+0x87 | | +------+ | | x() |
+----------------+ +--- | rsp | | | a = 42 |
| h() | +------+ | | b = 789 |
| handle | | rbp | ------+ +-----------+
+----------------+ +------+
| ... |
| |
总之
我将协程描述为一个函数的概括,除了“正常”函数提供的“调用”和“返回”操作之外,该函数还具有三个附加操作——“挂起”、“恢复”和“销毁”。
我希望这能为如何思考协程及其控制流提供一些有用的心理框架。
在下一篇文章中,我将介绍 C++ 协程 TS 语言扩展的机制,并解释编译器如何将您编写的代码转换为协程。