理解 C++20 Coroutine: 协程的概念

理解 C++20 Coroutine: 协程的概念。C++ coroutine 的细节非常的多,但是能查到的资料却不是很多。我一边学习一边记录,打算写很多篇文章来分享我的理解。

这篇文章本来是想做成笔记的,但是写着写着就成了翻译。我参考的文章是 https://lewissbaker.github.io/2017/09/25/coroutine-theory,真的非常推荐大家将他的四篇文章都看完,讲解的非常详细清晰,我的文章不及他的千分之一。

我是在运行了一些协程的实际应用(cppreference里的一个例子:switch_to_new_thread)之后才看的这篇文章,所以我还是很容易理解原文中所谓的“执行额外的逻辑”是什么意思。 C++20 所引入的协程只有一套机制,没有更多其它东西了,而这套机制非常的灵活,允许你在协程执行的各个关键节点运行你自己的逻辑代码,这就是“执行额外的逻辑”的含义。

协程理论(Coroutine Theory)

协程就是函数

“普通”函数(“Normal”function 或称为 subroutine)有两种操作:调用(Call)和**返回(Return)。这里的返回,也包括函数因为异常而退出的情况。

调用操作会创建一个 “活动帧”(Activation Frame),挂起调用者(Caller)的执行,然后转移到被调用函数(function being called)的开始继续执行。

返回操作会将返回值传递给调用者,销毁“活动帧”,并让调用者恢复执行。

关于调用和返回以及活动帧的概念,会在下面进行深入而详细地分析。

协程(coroutine)也是函数,因此函数的两种操作协程也有,即协程也支持调用和返回。协程的特点是它还支持额外的三种操作:挂起(Suspend)恢复(Resume)销毁(Destroy)

挂起操作会暂停协程当前的执行,然后让调用者(Caller)继续执行。与返回操作不同的是,协程的挂起操作不会导致“活动帧”被破坏(这里的活动帧指的是协程活动帧)。函数的返回操作只在特定的节点发生,即在 return 关键词处发生返回操作。协程的挂起操作也是如此,只在使用 co_await 或 co_yield 关键词时,才会发生挂起操作。

恢复操作会恢复被挂起的协程,让它在被挂起的位置继续执行。这个操作会重新建立协程的活动帧(这里的活动帧指的是栈活动帧)。

销毁操作会销毁活动帧,而且不需要恢复协程的执行。用于储存活动帧的内存会被释放。

活动帧(Activation Frames)

你可以将活动帧认为是一块保持着某个被调用函数状态的内存。这些状态包括所有函数参数的值和所有临时变量的值。

对于普通函数,活动帧里也包括返回地址。在返回操作发生时,需要通过这个地址才能回到调用者,进而继续向后执行。

对于普通函数,所有的活动帧都有严格的嵌套的生命周期。这种严格的嵌套允许我们使用一种高效率的内存分配数据结构来分配和释放每一次函数调用的活动帧。(也就是“栈”)

当一个活动帧被分配到栈时,通常称为 “栈帧”(stack frame)

这个栈是如此地常用,以至于几乎所有的 CPU 架构都有一个特定的寄存器来存储这个栈的顶端(比如在 x64 架构是 rsp 寄存器)。

要为新的活动帧分配空间,你只需要根据新活动帧的大小将这个寄存器的值增加。反之,要释放活动帧的空间,你只需要根据活动帧的大小将这个寄存器的值减小。(原文末尾有图片,参考着原文末尾的图片会更好理解。)

调用操作(The ‘Call’ Operation)

当一个函数调用另一个函数,调用者必须首先准备好将它自己挂起。

这个挂起步骤通常包含将所有当前寄存器的值保存到内存。这些值会在将来函数恢复执行时被还原。根据函数的调用约定,调用者和被调用者会协调由谁来保存这些寄存器的值,但是你可以直接地认为这个步骤是调用操作的一部分。

调用者还会将所有传递给被调用函数的参数保存到新的活动帧,这样被调用函数就可以访问他们。

最后,调用者将调用者的恢复点地址写入新的活动帧,并将执行转移到被调用函数的开始处。

在 x86/x64 架构中,最后一个操作有它自己的指令,也就是 call 指令。它将下一条指令的地址写入栈,将栈寄存器递增一个地址的大小,然后跳转到 call 指令指定的地址继续执行。

返回操作(The ‘Return’ Operation)

当一个函数使用 return 语句返回时,这个函数首先会将返回值(如果有)储存到调用者可以访问到的地方。这个地方可以认为处于调用者的活动帧也可以认为处于当前函数的活动帧(因为两个函数都能访问它,所以活动帧的边界会因为它而变得模糊)。

然后,函数会经历以下步骤来销毁活动帧。

  1. 销毁返回点范围内的所有局部变量(调用析构函数);(返回点之外可能还声明了局部变量,它们可能尚未被初始化,因此不需要析构它们)
  2. 销毁所有参数;
  3. 释放活动帧占用的内存;

最后,在调用者处恢复执行:

  1. 通过设置栈寄存器以还原调用者的活动帧,并且还原所有的寄存器值,它们可能被函数给破坏了;
  2. 跳转到调用者的恢复点,这个恢复点在调用操作时被储存;

注意,和调用操作一样,某些调用约定可能将返回操作的一些职责分摊到调用者和被调用者的指令中。

协程活动帧(Coroutine activation frames)

由于协程具有可以被挂起而不销毁活动帧的特点,我们不再能保证活动帧的生命周期是严格嵌套的。这意味着活动帧不能像通常那样被分配到栈中,因此可能要分配到堆中。

在 C++ 协程提案中有一些规定允许将协程活动帧的内存分配到调用者的活动帧中,如果编译器能够证明协程帧的生命周期严格嵌套在调用者活动帧的生命周期之内。有一个足够聪明的编译器可以在许多情况下避免堆分配。(TODO:需要进一步了解何时可以节约堆分配)

对于协程来说,活动帧可以被分为两部分:一部分需要在协程被挂起时被保留,而另一部分只在协程执行时存在。

1
2
3
4
5
6
task<> func()
{
int a = 123;
co_await bar(a);
co_await foo(); // 不再需要a, 因此 a 不需要在协程被挂起时保留
}
1
2
3
4
5
6
task<> func()
{
int a = 123;
co_await bar();
co_await foo(a); // 跨越了一个挂起点,在第二个挂起点需要a, 因此 a 需要在协程被挂起时保留,分配到堆中
}

比如上面所示代码,一个不跨越协程挂起点的局部变量 a,它可以被储存在栈中。而跨越了挂起点的局部变量,需要储存到堆中。可以将协程的活动帧逻辑上分成两个部分:

  1. 对于需要在协程被挂起时而不被销毁的活动帧,将会分配到堆中,将这部分称之为协程帧(coroutine frame)
  2. 对于只在协程执行时存在的活动帧,将会分配到栈中,将这部分称为栈帧(stack frame)

栈帧部分只有在协程执行时存在,并且会在协程挂起时和转移执行到调用者(恢复者)时被释放。

挂起操作(The ‘Suspend’ operation)

挂起点

使用了 co_await 或 co_yield 关键词的地方就是挂起点。

挂起时进行的工作

首先,做好恢复协程的准备:

  1. 将寄存器的值写入到协程帧中;
  2. 将协程的挂起点写入协程帧中(这个工作让后续的恢复操作能够知道从哪里恢复,或者后续的销毁操作能够知道哪些值在范围内并且需要被销毁);//TODO: 不知道具体是如何工作的
    当协程准备好被恢复,那么协程就可以被认为是处于挂起状态。

然后,在协程将执行转移回调用者/恢复者之前,协程有机会去执行一些额外的逻辑。这些额外的逻辑允许访问一个句柄(handle),用以控制协程稍后如何恢复或销毁。

在协程进入挂起状态后执行额外逻辑的能力允许不需要同步地安排协程恢复。(TODO:不懂)

之后,协程可以选择立刻恢复(就是继续执行协程),或者选择将执行转移回调用者/恢复者。

如果执行被转移回调用者/恢复者,协程活动帧的栈帧部分会被释放并且从栈中弹出。

恢复操作(The ‘Resume’ operation)

恢复操作可以在一个处于挂起状态的协程上执行。

通过调用句柄的 void resume() 方法来执行恢复操作。

和普通的函数调用一样,对 resume 方法的调用会在转移执行之前分配新的栈帧并且储存调用者的返回地址到栈帧中。

不一样的是,普通函数调用会将执行转移到被调用函数的开始,恢复操作的 resume 调用会将执行转移到恢复点(resume-point),这个恢复点储存在协程帧中。

当协程再次遇到挂起点或者执行完毕,resume 方法就会返回。

销毁操作(The ‘Destroy’ operation)

销毁操作会销毁协程帧,而且不会恢复协程的执行。

销毁操作只能作用在处于挂起状态的协程。

销毁操作和恢复操作很相似,因为它们都重新激活了协程活动帧,包括分配新的栈帧和储存返回地址(调用者)。

另一个相似的地方是,销毁操作也是需要调用特定的方法,void destroy()。

不同的地方是,恢复操作会跳转到挂起点之后继续执行,销毁操作会跳转到另一个代码分支。这个代码分支负责析构所有恢复点之前范围内的局部变量,然后释放它们在协程帧中占用的内存。

协程的调用操作(The ‘Call’ opeartion of a coroutine)

从调用者的角度来说,和普通函数的调用操作没什么区别。

普通函数会在执行完毕时返回,协程会在遇到第一个挂起点时执行完毕时返回。

当调用一个协程的时候,调用者会分配新的栈帧,将调用参数压入栈中,将返回地址压入栈中,然后将执行转移到协程。

协程所做的第一件事情是在堆上分配协程帧,然后从栈帧中复制(copy)/移动(move)调用参数到协程帧中,以使得调用参数的生命周期超过第一个挂起点。

协程的返回操作(The ‘Return’ opeartion of a coroutine)

当一个协程执行返回声明(return-statement)时(即 co_return 关键词),会将返回值储存在某个地方(这个地方可以自定义),然后销毁所有的局部变量(但是不包括调用参数)。

然后协程有机会在将执行转移回调用者/恢复者之前执行一些额外的逻辑。

这些额外的逻辑可能执行一些操作来发布返回值,或者恢复执行其他需要这个返回结果的协程。这是完全可自定义的。

然后协程要么执行挂起操作(保持协程帧存活),或者执行销毁操作(销毁协程帧)。

作者

uint128.com

发布于

2022-02-16

更新于

2022-08-22

许可协议

评论