原文地址:lewissbaker.github.io/2017/09/25/…
本文是介绍c++20标准中引入的新特性-协程系列文章的第一篇。在这个系列中,我会讲述协程的底层机制,并展示如何对其做封装和抽象。
在这篇文章中,我会介绍协程和函数的区别,以及协程支持的操作背后的原理,并会介绍一些基本的概念来帮助大家理解协程。
协程和函数
协程就是广义的函数,相较于我们平时写的“普通”函数,协程新增了挂起和恢复操作。
我接下来会详细讲解这句话的含义,不过在这之前,先让我们回顾下“普通”的c++函数是如何工作的。
“普通”函数
一个“普通”函数需要支持两个操作:调用和返回(这里把throw exception也看作返回操作)。
调用操作的执行步骤是:
- 创建活动帧
- 挂起当前函数
- 切换到调用函数的起始位置开始执行
返回操作的执行步骤是:
- 将返回值传给调用者
- 销毁自身的活动帧
- 切回调用者,从调用函数的地方继续向后执行
我们再详细介绍下这里...
活动帧
那么什么是“活动帧”?
活动帧就是一块内存空间,存储了函数当前的状态,包含输入参数及局部变量等数据。
对于“普通”函数来说,活动帧还包含函数的返回地址,表示在函数结束后接下来再从哪里继续执行。
“普通”函数还有一个特点,其生命周期是满足嵌套规则的,嵌套规则指的是被调用函数的生命周期一定在调用函数的生命周期内,也就是被调用函数一定是在调用函数开始后才开始,在调用函数结束前结束。这使得我们可以用高性能的数据结构“栈”来存储活动帧,大家常常称作“调用栈”。
栈这种数据结构如此通用,以至于大多数(或所有)cpu架构中都有一个专用的寄存器用来存储栈顶指针(如在x64架构中为rsp
寄存器)。若要分配一个新的活动帧,只需要让寄存器存储的地址加上活动帧的大小即可,同样,要销毁活动帧也只需要减去活动帧的大小就行。
调用操作
当一个函数调用其他函数时,需要先为挂起操作做一些准备。
挂起操作会把存储在寄存器的数据保存到内存中,以便在函数恢复执行后能够把寄存器恢复到原本的状态。基于这种函数调用约定,调用者和被调用者在使用寄存器时就不会发生冲突了。
调用者也会把所以要传给被调用者的参数存到新创建的活动帧中。
最后,调用者把返回地址写入活动帧,然后切换到被调用函数入口开始执行。
在X86/X64寄存器中,最后的这个操作有个专门的指令call
,call
指令会先把返回地址写入栈中,然后跳转到指定地址开始执行。
返回操作
当函数通过return
命令返回时,函数会先存储返回值,可以存到函数的活动帧中,也可以存到调用者的活动帧中(参数和返回值存储在两个活动帧的交界处,并不容易界定到底属于哪一个)。
然后函数会销毁自己的活动帧,通过:
- 销毁所有局部遍历
- 销毁所有参数对象
- 释放活动帧占用的内存
最后,恢复调用者的执行,通过:
- 把栈寄存器执行调用者的活动帧,恢复调用者活动帧
- 恢复寄存器
- 跳转到在调用时存储的返回地址处
注意,与调用操作一样,一些函数调用约定会把返回操作的责任分别归属到调用者和被调用者中。
协程
协程是更广义的函数,它把函数的调用和返回操作细分成了三个操作:挂起、恢复和销毁。
挂起操作会在函数正在执行的位置挂起协程,然后切换到调用者或恢复者(恢复执行的协程)执行,切换时并不销毁活动帧。在挂起时协程内的对象也不会被销毁,仍然的存活的。
注意,就像函数的返回操作一样,协程也只能在内部合适的挂起点处被挂起。
恢复操作从协程的挂起点恢复执行,这也会同时恢复协程的活动帧。
销毁操作会销毁活动帧,但不会恢复协程的执行。任何在挂起点处仍然存活的对象都会被销毁,存储活动帧的内存也会被释放。
协程的活动帧
协程被挂起时不再需要销毁活动帧,我们也就不能保证协程活动帧的生命周期满足嵌套规则了。这意味着我们不能再使用栈来存储活动帧,而是需要用堆来存储。
C++ Coroutines TS
提供的一些方法允许在调用者的活动帧中分配协程帧所需的内存,在编译器推断出协程的生命周期在调用者的生命周期内满足嵌套规则时,就可以用这种方法创建协程帧。所以,若编译器足够智能,在多数情况下是可以避免在堆上分配内存的。
在活动帧中,有些部分需要在协程挂起时保存起来,有些部分则不需要,只需要在协程运行时保存着就行。例如,有些变量的作用域不一定覆盖所有的挂起点,这些变量是有可能在栈上存储的。
从逻辑上来说,你可以认为协程的活动帧有两部分组成:协程帧和栈帧。
协程帧存储的是在协程挂起时仍需要保存的那部分数据。栈帧存储的是只需要在协程运行时保存的数据,这部分数据会在协程挂起然后切换到调用者/恢复者时被释放掉。
挂起操作
协程的挂起操作允许协程在函数中间挂起并切换到调用者或恢复者继续执行。
协程中的挂起点是预先确定的。在C++ Coroutines TS
中,挂起点是用co_await
或co_yield
关键字标记的点。
当协程执行到挂起点时,需要先做些准备,以便协程可以再被恢复:
- 将寄存器存储的数据写入协程帧
- 将挂起点写入协程帧,以便恢复操作可以知道从哪里恢复协程,或是销毁操作可以知道哪些变量在作用域内从而销毁这些变量
当协程做完这个操作后,就会进入“可挂起”状态。
在切换到调用者/恢复者执行前,当前协程还有机会再执行一些额外的逻辑,在额外的逻辑中可以被允许访问协程帧。
这里的目的是,若协程在进入挂起状态前就被执行恢复操作,可以直接在这个额外的逻辑中恢复协程,而不需要先挂起再通过同步指令恢复协程。后面的文章中我会再详细解释下这里。
协程可以选择立即继续恢复执行,或是切换到其他调用者/恢复者执行。
若选择了切换到其他调用者/恢复者,活动帧中的栈帧这部分会被释放掉。
恢复操作
当协程处于挂起状态时,可以被执行恢复操作。
当一个函数想要恢复在挂起状态的协程时,需要从协程函数中间某个特定的点开始执行。恢复者可以通过协程帧对象提供的resume()
方法来做这个事情。
和普通函数调用一样,在切换到协程函数前,resume()
方法会分配一个新的栈帧,然后将调用者的返回地址存入栈帧。
不过,相较于普通函数从被调函数起始位置执行,resume()
方法是会从协程最后被挂起的点开始执行。具体来说,是先从协程帧中加载恢复点,然后跳到恢复点执行。
当协程再次被挂起或是执行完成后,会切回调用者继续执行。
销毁操作
销毁操作只是去销毁协程帧,而不会恢复协程的执行。
也是只有在挂起状态的协程可以被销毁。
销毁操作和恢复操作非常相似,包括会先分配一个栈帧,然后将返回地址写入栈帧中,这里的返回地址也是调用者的地址。
不同的是,相比恢复操作直接切换到协程函数内从最后的挂起点执行,销毁操作是切换到一个可更换的代码路径中执行,在代码中会销毁所有在挂起点仍然存活的局部变量,然后是否协程帧的内存空间。
另外,销毁操作会调用协程帧对象提供的void destroy()
方法销毁活动帧。
协程的调用操作
协程的调用操作和普通函数的调用操作非常相似。实际上,从调用者的视角来看,这两个并没有什么不同。
然而,和普通函数不同的是,普通函数调用会在函数执行完成后返回调用者继续执行,而协程调用却是在协程执行到第一个挂起点时返回调用者继续执行。
具体来说,协程的调用操作会先创建一个新的栈帧,将参数及返回地址写入栈帧,然后切换到协程开始执行。这里和普通函数调用并没有区别。
不过,协程调用会多做一件事情。在第一次挂起操作前,会在堆上创建协程帧,然后把参数从栈帧上复制/移动到协程帧,以便延长参数的生命周期。
协程的返回操作
协程的返回操作和普通函数的返回操作略有不同。
当协程执行返回指令时(对于TS来说是co_return
指令),会先把返回值存在一个地方(可以又协程自己定制),然后销毁所有仍存活的局部变量。
这里也是有机会在切到调用者/恢复者执行前,执行一些额外的逻辑。
在额外的逻辑中,可以对返回值做一些操作,或是恢复其他正在等待结果的协程。这里是可以完全定制的。
接下来协程可以做挂起操作(保留协程帧),或是销毁操作(销毁协程帧)。
若是挂起操作就切换到调用者执行,若是销毁操作切换到恢复者执行。然后将活动帧中的栈帧从栈上弹出。
一些说明
举一个简单的例子,看下协程在被调用、挂起以及恢复时,都发生了什么。
假设我们有一个函数(或是协程)f()
,调用一个协程x(int a)
。
在调用前,应该是这个状态(rsp
寄存器指向f()
函数的栈顶)。
STACK REGISTERS HEAP
+------+
+---------------+ <------ | rsp |
| f() | +------+
+---------------+
| ... |
| |
接下来调用x(42)
,这里会先像普通函数一样为x()
创建一个栈帧,rsp
寄存器也指向了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()
被挂起时,还会把恢复点的地址写入协程帧(将恢复点缩写为RP
)。
STACK REGISTERS HEAP
+----> +-----------+
+------+ | | x() |
+----------------+ <----- | rsp | | | a = 42 |
| f() | +------+ | | b = 789 |
| handle ----|---+ | rbp | | | RP=x()+99 |
| ... | | +------+ | +-----------+
| | | |
| | +------------------+
指向协程帧的这个句柄会作为普通的值在函数直接传递。在后面的某个时间,可能是另外的一个函数,甚至可能是另外的一个线程,来恢复这个协程的执行(我们先把这个函数叫做h()
)。如,在异步IO完成后恢复协程。
恢复协程时,需要调用void result(handle)
这个函数。对于调用者来说,执行恢复协程就和调用一个返回void
只有一个handle参数的普通函数一样。
这会创建一个新的栈帧用来记录调用者的返回地址,然后通过把协程帧地址写入寄存器来重新激活协程帧,接下来从协程帧存储的恢复点开始执行x()
协程。
STACK REGISTERS HEAP
+----------------+ <-+
| x() | | +--> +-----------+
| ret= h()+0x87 | | +------+ | | x() |
+----------------+ +--- | rsp | | | a = 42 |
| h() | +------+ | | b = 789 |
| handle | | rbp | ------+ +-----------+
+----------------+ +------+
| ... |
| |
结语
我把协程描述成了一个更广义的函数,在“普通”函数提供的“调用”、“返回”的基础上,又增加来“挂起”、“恢复”和销毁操作。
我希望这能为你理解协程及其工作过程打下一个好的基础。
在下一篇文章,我会讲述C++ Coroutines TS
的机制以及解释编译器是怎么处理我们写在协程里的代码的。