C++ 协程(1):函数和协程
这篇文章的目的是探究 C++ 中协程的机制和用法,以及怎样利用协程的特性来构建上层的库和应用。
1. 栈帧和函数
栈帧是一个函数执行的环境,包括函数参数、函数返回地址、局部变量等信息。操作系统每次调用一个函数,都会为其分配一个新的栈帧,相关的概念有:
- ESP:栈指针寄存器(Extended Stack Pointer),其内存中存放一个始终指向系统栈最顶部栈帧栈顶的指针
- EBP:基址指针寄存器(Extended Base Pointer),其内存中存放一个始终指向系统最顶部栈帧栈底的指针
- 函数栈帧:ESP和EBP之间的内存空间为当前栈帧,EBP标识了当前栈帧的底部,ESP标识了当前栈帧的顶部
对于普通的函数来说,一般我们可以对其进行两种操作:call(调用)和 return(返回)。为了方便对比,此处不讨论 throw exception 的情况。在运行一个 C++ 程序时,编译器会先执行 C++ runtime,然后会调用 main 函数,再由 main 函数调用其他的函数。
call 操作一般包含以下几个步骤:
- 参数入栈:参数从右向左依次入栈
- 返回地址入栈:将当前代码区的下一条待执行的指令入栈,以便在函数 return 之后执行
- 代码区跳转:处理器跳转到被调函数的入口
- 栈帧调整,包括:
- 保存当前栈帧状态值,EBP 入栈
- 从当前栈帧切换到新的栈帧,更新 EBP,将 EBP 的值设置为 ESP 的值
- 给新的栈帧分配内存空间,更新 ESP,将 ESP 的值减去所需空间的大小
当一个函数通过 return 语句返回时,执行的步骤与调用时相反:
2. 协程
协程由程序所控制,即在用户态执行,而不是像线程一样由操作系统内核管理,使用协程时,不需要如线程一般频繁地进行上下文切换,性能能够得到很大的提升,因此协程的开销远远小于线程的开销。一般来说协程有三种特性:
- suspend 悬停:暂停当前协程的执行,将执行权交还给调用者,但是保留当前栈帧。和函数的 return 类似,协程的 suspend 只能由协程自身发起
- resume 恢复:继续执行已经 suspend 的协程,重新激活协程的栈帧
- destroy 销毁:销毁协程的栈帧和其对应的内存
可以看到,协程可以在不清除栈帧的情况下被挂起而不被销毁,因此我们不能够使用调用栈这样的数据结构来严格保证活动栈帧的生命周期,我们可以把协程存储在堆中。我们可以把协程的栈帧分为两部分,一部分是执行栈帧,这部分仅在当前协程执行期间存在,在执行结束,即协程 suspend 的时候被释放;另一部分是数据栈帧,这部分即使在协程 suspend 的时候依然存在。
2.1 Suspend
协程通过某些特定的语句来执行 suspend 操作,在 C++ Coroutine TS 中有 co_await 和 co_yield。在执行 suspend 操作的时候,我们应该确保两点:
- 将当前执行栈帧中的数据保存到数据栈帧中
- 将协程 suspend 的位置写入数据栈帧中,以便后续的 resume 操作知道从哪里继续,或让 destroy 操作知道销毁哪一部分
接下来,协程可以将执行权转交给调用方,而执行栈帧将被释放。
2.2. Resume
我们可以使用 resume 操作来恢复一个已经 suspend 的协程,和函数的 call 类似,resume 操作将会分配一个新的执行栈帧来存储已经保存在数据栈帧中的数据,以及调用方的返回地址等,之后协程将加载之前 suspend 的位置并继续执行。
2.3 Destroy
Destroy 操作只能在已经 suspend 的协程上执行,和 resume 类似,他也会先分配执行栈帧,将调用方的返回地址存入其中,但它并不会继续执行 suspend 的位置之后的函数体,而是执行当前作用域内所有局部变量的析构函数,并释放这些内存。
2.4 Call 和 Return
协程的调用和普通函数的 call 操作类似,调用方会给其分配一个活动栈帧,将参数和返回地址入栈,并将执行权交给协程,而协程会先在堆上分配一个执行栈帧,并将参数复制到执行栈帧上,以便后续能够正确地删除这些参数。
协程的 return 操作和普通函数的略有不同,当协程执行 return 操作时,他会将返回值存储在另一个地址,然后删除所有局部变量,并将执行权转交给调用方,
3. 函数和协程的执行过程
假设 func() 是一个函数,他在函数体内调用了协程 co_func(int x),那么编译器会在调用栈上创建新的活动栈帧,将参数和返回地址入栈,并将 ESP 移动到新的活动栈帧的栈顶位置,如下所示。
Stack Register Heap (Coroutine Manager)
+----+
+------------+ <---------- ESP
func() +----+
+------------+
...
接下来协程管理器会在堆上申请一块新的区域作为协程的执行栈帧,此时编译器会将 EBP 指向执行栈帧的顶部,如下所示。
Stack Register Heap (Coroutine Manager)
+------------+ <------- +------------+
co_func() | -------> co_func()
x = 68 | | x = 68
ret = func() + 0x789 | +----+ | +------------+
+------------+ ---- ESP |
func() +----+ |
+------------+ EBP --------|
... +----+
如果在 co_func 执行的某一时刻触发了 suspend,那么执行栈帧中的数据将被保存到数据栈帧中,且改协程会返回一些返回值给调用方,这些返回值中通常含有 suspend 的位置,以及协程暂挂的句柄,这个句柄可以在接下来使用 resume 的时候恢复协程,如下所示。
Stack Register Heap (Coroutine Manager)
+----+ -------> +------------+
+------------+ <---------- ESP | co_func()
func() +----+ | x = 68
+------------+ EBP | resume point = co_func() + 16
handle --------------- +----+ |
... | |
| |
---------------------
现在因为某些原因触发了协程的 resume,恢复协程的调用方会调用 void resume (handle) 来恢复这个协程,此时编译器会再次创建新的活动栈帧用来记录参数和返回地址,同时激活执行栈帧,执行栈帧从数据栈帧读取数据,恢复协程,如下所示。
Stack Register Heap (Coroutine Manager)
+------------+ <------- +------------+
co_func() | -------> co_func()
x = 68 | | x = 68
ret = func() + 0x789 | +----+ | +------------+
+------------+ ---- ESP |
func() +----+ |
+------------+ EBP --------|
handle +----+
...