回到顶部 暗色模式

C++协程(1):协程理论

        这是C++ Coroutines标准 ( C++ Coroutines TS ) 系列的第一篇文章,协程作为新技术被引入C++20标准。
        在这个系列里我会介绍C++协程的底层机制,以及如何使用它们实现一些更高级的抽象,就像 cppcoro 库一样。
        在这篇文章,我会介绍函数和协程的区别,以及相应的一些他们所支持的行为的理论。文章的目标是介绍一些基础概念,帮助你理解C++协程。

协程是函数,函数是协程

        协程是一种允许挂起 ( $suspend$ ) 和恢复 ( $resume$ ) 的函数的统称。
        在解释这个含义之前,我们先复习一下一个“普通”C++函数的行为是什么。

“普通”函数

        一个普通函数具有两种行为:调用 ( $call$ ) 和返回 ( $return$ , 注意这里返回也包括了抛出异常 )。
        调用创建一个调用帧 ( $activation$ $frame$ ),挂起调用方函数,执行被调函数的第一条命令。
        返回会把返回值返回给调用方,销毁调用帧,然后在调用方函数的调用点处,恢复调用方函数执行。
        让我们再分析一下这些语义……

调用帧

        什么是“调用帧”?
        你可以认为调用帧是一块存储着特定函数调用状态的内存块,这些状态包括了入参和局部变量。
        对于“普通”函数,调用帧也包含返回地址——函数返回时将跳转到的指令,和调用方函数的调用帧地址。这两部分组成了函数调用的后续执行上下文 ( $continuation$ ) ,即当前函数调用完后,下一步应该继续执行哪个函数调用,从哪开始执行。         对于“普通”函数,所有的调用帧都有着严格嵌套的生命周期。严格嵌套可以更有效地分配和释放每个函数调用的调用帧,这种数据结构也被称为“栈”。
        调用帧分配在栈上,也被称为“栈帧”。
        栈是一种十分常见的数据结构,大部分CPU架构都有个专门的寄存器来保存栈顶地址 ( 例如,X64rsp寄存器 )。
        分配一个调用帧,只需要让寄存器加上栈帧大小的值。同样的,释放一个调用栈,只需要让寄存器减去栈帧大小。

调用

        当一个函数调用另一个函数,调用方必须准备进入“挂起”状态。
        “挂起”阶段通常涉及保存当前CPU寄存器中的值,并在之后恢复的时候重新设置这些值。保存寄存器这一步,可以是调用方执行,也可以是被调方执行,取决于不同的调用方式,但你可以认为它们都是调用的一部分。
        调用也会把入参在新的调用栈里保存一份,让被调可以访问。
        最后,调用方在新调用栈里面写入恢复点的地址,并把执行权让出给被调函数。
        在X86/X64架构,最后一步有单独对应的 $call$ 指令,会把当前指令的下一条指令地址写入栈,递增栈寄存器,然后跳转到指令操作地址。

返回

        当一个函数通过 $return$ 返回,函数会先把返回值保存到调用方可以访问的地方,可以是调用方调用帧,也可以是当前调用帧 ( 对于跨越两个调用帧的参数和返回值,这种概念可能没有明确的区别 )。
        然后函数通过以下步骤销毁调用帧:

        最后,通过以下步骤恢复调用方执行:

        注意,与“调用”操作一样,不同的调用方式中,“返回”操作中调用方和被调方的细分职责可能不一样。

协程

        协程是一种在函数的调用返回操作的基础上细分出挂起恢复销毁三种额外操作的操作行为的统称。
        挂起 ( $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++协程标准语言扩展的机制,以及编译器是怎么把你写的代码翻译成协程的。

C++协程(1):协程理论