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