C++协程(1):协程理论
这是C++
Coroutines
标准 ( C++ Coroutines TS ) 系列的第一篇文章,协程作为新技术被引入C++20
标准。
在这个系列里我会介绍C++
协程的底层机制,以及如何使用它们实现一些更高级的抽象,就像 cppcoro 库一样。
在这篇文章,我会介绍函数和协程的区别,以及相应的一些他们所支持的行为的理论。文章的目标是介绍一些基础概念,帮助你理解C++
协程。
协程是函数,函数是协程
协程是一种允许挂起 ( $suspend$ ) 和恢复 ( $resume$ ) 的函数的统称。
在解释这个含义之前,我们先复习一下一个“普通”C++
函数的行为是什么。
“普通”函数
一个普通函数具有两种行为:调用 ( $call$ ) 和返回 ( $return$ , 注意这里返回也包括了抛出异常 )。
调用创建一个调用帧 ( $activation$ $frame$ ),挂起调用方函数,执行被调函数的第一条命令。
返回会把返回值返回给调用方,销毁调用帧,然后在调用方函数的调用点处,恢复调用方函数执行。
让我们再分析一下这些语义……
调用帧
什么是“调用帧”?
你可以认为调用帧是一块存储着特定函数调用状态的内存块,这些状态包括了入参和局部变量。
对于“普通”函数,调用帧也包含返回地址——函数返回时将跳转到的指令,和调用方函数的调用帧地址。这两部分组成了函数调用的后续执行上下文 ( $continuation$ ) ,即当前函数调用完后,下一步应该继续执行哪个函数调用,从哪开始执行。
对于“普通”函数,所有的调用帧都有着严格嵌套的生命周期。严格嵌套可以更有效地分配和释放每个函数调用的调用帧,这种数据结构也被称为“栈”。
调用帧分配在栈上,也被称为“栈帧”。
栈是一种十分常见的数据结构,大部分CPU
架构都有个专门的寄存器来保存栈顶地址 ( 例如,X64
的rsp
寄存器 )。
分配一个调用帧,只需要让寄存器加上栈帧大小的值。同样的,释放一个调用栈,只需要让寄存器减去栈帧大小。
调用
当一个函数调用另一个函数,调用方必须准备进入“挂起”状态。
“挂起”阶段通常涉及保存当前CPU
寄存器中的值,并在之后恢复的时候重新设置这些值。保存寄存器这一步,可以是调用方执行,也可以是被调方执行,取决于不同的调用方式,但你可以认为它们都是调用的一部分。
调用也会把入参在新的调用栈里保存一份,让被调可以访问。
最后,调用方在新调用栈里面写入恢复点的地址,并把执行权让出给被调函数。
在X86/X64
架构,最后一步有单独对应的 $call$ 指令,会把当前指令的下一条指令地址写入栈,递增栈寄存器,然后跳转到指令操作地址。
返回
当一个函数通过 $return$ 返回,函数会先把返回值保存到调用方可以访问的地方,可以是调用方调用帧,也可以是当前调用帧 ( 对于跨越两个调用帧的参数和返回值,这种概念可能没有明确的区别 )。
然后函数通过以下步骤销毁调用帧:
- 在返回点销毁所有局部变量
- 销毁所有参数对象
- 释放调用帧内存
最后,通过以下步骤恢复调用方执行:
- 栈寄存器指向当前调用存储的调用方调用帧地址,恢复所有当前函数可能修改的
CPU
寄存器 - 跳转到之前“调用”操作存储的恢复点
注意,与“调用”操作一样,不同的调用方式中,“返回”操作中调用方和被调方的细分职责可能不一样。
协程
协程是一种在函数的调用和返回操作的基础上细分出挂起、恢复和销毁三种额外操作的操作行为的统称。
挂起 ( $Suspend$ ) 操作让协程在当前执行点处挂起,并在不销毁调用帧的前提下,将控制权交还给调用方/恢复方的操作。协程挂起时,作用域内的所有对象在挂起之后依然存活。
注意,就像函数的返回操作一样,协程只能在协程内部定义好的挂起点处挂起。
恢复操作将在先前的挂起点处恢复协程的执行,这将会重新激活协程的调用帧。
销毁会在不恢复协程执行的前提下,销毁协程的调用帧,挂起点作用域内的所有对象也会被一并销毁。调用帧的内存会被释放。
协程调用帧
因为协程可以在不销毁调用帧的前提下被挂起,我们也就无法保证调用帧之间是严格嵌套的了。这意味着,调用帧不再能使用栈结构分配,取而代之的,是更多的堆存储。
C++
协程标准中有些规定,允许在调用方的调用帧上分配协程调用帧,只要编译器能保证协程的生命周期严格内嵌于调用方生命周期。如果编译器足够智能,这种方式可以在一定程度上减少堆分配。
协程调用帧有一部分需要在协程挂起时保留,而另一个部分只会在协程执行时临时存储。例如,作用域不跨越任何协程挂起点的变量,可以在栈上存储。
你可以认为协程调用帧由两部分组成:“协程帧”和“栈帧”。
“协程帧”指代调用帧中需要在协程挂起期间保留的部分,“栈帧”则指只需要在协程执行期间存在的部分,负责在协程挂起时将控制权移交给调用方或者恢复方 ( $resumer$ ) 并释放。
挂起
协程的挂起操作允许一个协程在函数执行的中间点暂停执行,并将控制权移交给调用方 / 恢复方。
C++
协程标准定义协程内有几个指定挂起点,它们会使用 $co_-await$ 或者 $co_-yield$ 关键字标识。
当一个协程执行到挂起点时,会通过以下几步来挂起:
- 确保寄存器值已写入协程帧
- 将挂起点写入协程帧,用于后续恢复操作恢复执行,或者销毁操作销毁挂起点前的变量
一旦协程准备好被恢复,就可以认为“已挂起”。
协程在把控制权交还给调用方 / 恢复方之前,有机会执行一些额外的逻辑,返回当前协程帧的句柄,用于后续恢复或者销毁。
在挂起后允许执行额外逻辑的能力,使得协程进入“已挂起”状态后恢复时,可避免“挂起”与“恢复”的竞态引入的额外同步机制。我将在后续的文章中详细讨论。
协程可以选择立即恢复 / 继续协程执行,或者可以选择移交控制权给调用方 / 恢复方。
如果控制权被移交给调用方 / 恢复方,协程调用帧的栈帧部分将被释放,并从栈中弹出。
恢复
恢复可以对一个处于“已挂起”状态的协程使用。
当一个函数恢复一个协程时,它需要高效地调用特定函数的中间状态。恢复方通过调用 $void$ $resume\left(\right)$ 来已挂起返回的协程帧句柄上,找到对应的指令。
就像普通函数调用,$resume\left(\right)$ 会分配一个新栈帧,并在移交控制权之前保存调用方调用帧的返回地址。
然而,控制权并不会移交到被调方函数的起始位置,而是从协程帧中读取最后的挂起点,并跳转到那里。
当协程下一次挂起或者完成调用,$resume\left(\right)$ 调用将会返回,恢复调用方函数执行。
销毁
销毁操作可以在不恢复协程执行的前提下销毁协程帧。
这个操作只能对已挂起的协程使用。
销毁操作就像恢复操作那样,重新激活协程调用帧,包括分配一个新的栈帧,保存销毁操作调用方返回地址。
然而,并非将控制权移交到上个挂起点,而是移交到一个可选的代码路径上,调用协程挂起点之前作用域内所有局部变量的析构器,然后释放协程帧的内存。
类似于恢复操作,销毁需要对协程挂起时返回的句柄调用 $void$ $destroy\left(\right)$ 函数。
协程调用
协程调用几乎跟普通函数调用一样,事实上,对于调用方来说,它们之间毫无区别。
然而,不像函数调用必须执行完才会返回,协程调用可以在到达挂起点处返回,并且调用方可以在后续恢复。
对协程执行调用,调用方会分配一个新的栈帧,把入参、返回地址写入栈帧,移交控制权。这些步骤和普通函数调用一样。
协程要做的第一件事是在堆上分配一个协程帧,并把入参从栈帧拷贝 / 移动到协程帧,保证它们的生命周期和协程一样。
协程返回
协程返回与普通函数有一点不同。
当一个协程执行 $return$ 语句 ( 标准里是 $co_-return$ 语句 ) 时,他会把返回值存储到某个地方 ( 可以被协程修改的地方 ),接着销毁作用域内的局部变量 ( 不包括入参 )。
然后协程有机会在移交控制权之前,执行一些额外的逻辑。
额外的逻辑可能发布返回值,或者恢复另一个等待返回值的协程。这些逻辑是完全可定制化的。
协程然后执行挂起 ( 协程帧继续存活 ) 或者销毁 ( 协程帧销毁 ) 操作。
控制权在挂起 / 销毁操作后被移交给调用方 / 恢复方,最后弹出栈帧。
需要特别注意的是,返回操作的返回值与调用操作的返回值不一样,因为返回可能是在初始调用之后很长时间后才执行的。
例子
为了以图像形式表达这些概念,我会通过一个简单的示例来演示协程挂起,并在之后恢复过程中的情况。
假设函数 ( 或者协程 ) $f\left(\right)$ 调用一个协程 $x\left(int\ \ a\right)$ 。
调用前大概是这样的:
STACK REGISTERS HEAP
+------+
+---------------+ <------ | rsp |
| f() | +------+
+---------------+
| ... |
| |
然后调用 $x\left(42\right)$ ,创建一个栈帧,就像普通函数那样。
STACK REGISTERS HEAP
+----------------+ <-+
| x() | |
| a = 42 | |
| ret= f()+0x123 | | +------+
+----------------+ +--- | rsp |
| f() | +------+
+----------------+
| ... |
| |
然后,一旦协程 $x\left(\right)$ 在堆上分配协程帧,并且将入参拷贝到协程帧,我们就得到了下面的图。注意编译器一般使用一个单独的寄存器存储协程帧地址 ( 例如MSVC
使用rbp
寄存器 )。
STACK REGISTERS HEAP
+----------------+ <-+
| x() | |
| a = 42 | | +--> +-----------+
| ret= f()+0x123 | | +------+ | | x() |
+----------------+ +--- | rsp | | | a = 42 |
| f() | +------+ | +-----------+
+----------------+ | rbp | ------+
| ... | +------+
| |
如果协程 $x\left(\right)$ 之后调用另一个普通函数 $g\left(\right)$ ,就会像这样:
STACK REGISTERS HEAP
+----------------+ <-+
| g() | |
| ret= x()+0x45 | |
+----------------+ |
| x() | |
| coroframe | --|-------------------+
| a = 42 | | +--> +-----------+
| ret= f()+0x123 | | +------+ | x() |
+----------------+ +--- | rsp | | a = 42 |
| f() | +------+ +-----------+
+----------------+ | rbp |
| ... | +------+
| |
当 $g\left(\right)$ 返回,他会销毁自己的调用帧,然后恢复 $x\left(\right)$ 的调用帧。假设 $g\left(\right)$ 的返回值存储在协程帧的局部变量 $b$ 中。
STACK REGISTERS HEAP
+----------------+ <-+
| x() | |
| a = 42 | | +--> +-----------+
| ret= f()+0x123 | | +------+ | | x() |
+----------------+ +--- | rsp | | | a = 42 |
| f() | +------+ | | b = 789 |
+----------------+ | rbp | ------+ +-----------+
| ... | +------+
| |
如果 $x\left(\right)$ 执行到挂起点,不销毁调用帧的前提下暂停执行,他会把控制权返回给 $f\left(\right)$ 。
这导致 $x\left(\right)$ 的栈帧会弹出,但是协程帧会保留。当协程被初次挂起,返回值返回给调用方,通常是被挂起协程句柄,用于后续的恢复。当 $x\left(\right)$ 挂起后,协程帧中会存储恢复点 ( 记为 $RP$ )。
STACK REGISTERS HEAP
+----> +-----------+
+------+ | | x() |
+----------------+ <----- | rsp | | | a = 42 |
| f() | +------+ | | b = 789 |
| handle ----|---+ | rbp | | | RP=x()+99 |
| ... | | +------+ | +-----------+
| | | |
| | +------------------+
这个句柄现在可以作为一个普通值在函数间传递。在一些点之后,可能是另一个调用帧,或者另一个线程。在某个时机,例如异步I/O
完成的时候,另一个调用 $h\left(\right)$ 恢复了协程。
恢复协程的函数调用 $void$ $resume$($handle$) ,恢复了协程执行。对于调用方来说,就好像用一个参数调用了一个 $void$ 函数。
这会创建一个新的栈帧,记录 $resume\left(\right)$ 调用的返回,激活协程帧,重新设置寄存器,在协程帧存储的上一个挂起点处恢复 $x\left(\right)$ 的执行。
STACK REGISTERS HEAP
+----------------+ <-+
| x() | | +--> +-----------+
| ret= h()+0x87 | | +------+ | | x() |
+----------------+ +--- | rsp | | | a = 42 |
| h() | +------+ | | b = 789 |
| handle | | rbp | ------+ +-----------+
+----------------+ +------+
| ... |
| |
总结
这篇文章中,我把协程描述为一种不仅具有调用和返回操作,还具有挂起、恢复和销毁这三种额外操作的函数。
我希望这对你理解协程及其控制流有帮助。
下一篇文章我将介绍C++
协程标准语言扩展的机制,以及编译器是怎么把你写的代码翻译成协程的。