最近对C++20协程的进行了预研, 作为对比,同时研究了下市面上已经存在的其他协程实现方案。
虽然工作重点是C++20协程的预研,但作为一篇完整的文章, 不可避免的要从协程的基础开始讲起。
如果你已经对协程非常熟悉,尤其是知道栈(stack),帧(frame)在协程知识体系中意义,可以直接跳过相关章节。
关于协程的定义和实现,并没有像进程和线程那样有统一的标准。而且由于都有一个“程”字,初学者很容易去和线程和进程做类比,往往走进理解的误区。
所以我们一开始只是引用维基的定义,简单说明下协程是什么:“协程是允许执行被挂起与被恢复子例程(也称为子程序 subroutine)”。
这一章节,我从函数切换的寄存器操作入手,继而通过协程的实现,和不同协程分类标准的介绍,帮助读者理解协程的本质。
我们都知道进程内存空间被划分为下面的各内存段(引自维基百科Code segment):
对于函数切换以及后面介绍的协程切换,我们最关心的是栈区的内存的管理。
当函数调用发生后,会扩展栈区内存,用于存放函数体内声明的局部变量。当被调函数返回时,则释放这部分内存。每次函数调用扩展的这部分内存我们称之为栈帧。函数调用栈(call stack)就是由栈帧组成的,函数调用和返回就是栈帧(stack frame)入栈出栈的过程。
我们以如下代码为例,看下这个过程中详细步骤。
#include <cstdint>
int64_t f(int p1, int p2, int p3, int p4, int p5, int p6, int p7, int p8)
{
int x = 0;
int y = 0;
int64_t z = 0;
int64_t l = 0;
x++;
p1++;
p7++;
p8++;
return 0;
}
int main()
{
int64_t i = 0;
i++;
i = f(1, 2, 3, 4, 5, 6, 7, 8);
return 0;
}
这里需要一些汇编和基础知识,但由于这部分不是此篇文章的重点,感兴趣的同学可以自行学习。 对于本文要解释的函数切换过程,我们只需要了解如下内容:维持栈帧需要两个寄存器的支持,对于x86_64架构,即%rbp,%rsp。在本文中, 我们把他们称为
1. rbp 栈帧基地址寄存器: 当前栈帧的起始内存地址。
2. rsp 栈顶地址寄存器:整个调用栈的栈顶地址。
此外还需要了解:函数需要执行的下一条指令存放在%rip中,我们称之为指令地址寄存器。
有了如上的背景知识,我们看下在main函数中调用函数f,具体发生了什么。以此来加深栈帧的概念。
根据上一节的内容,在函数f调用发生之前,%rbp指向main函数对应栈帧的基地址。%rsp指向整个栈区的顶地址。
函数调用的第一步是参数传递。上面例子参数传递部分,在gcc编译器下,产生的汇编如下:
pushq $8
pushq $7
movl $6, %r9d
movl $5, %r8d
movl $4, %ecx
movl $3, %edx
movl $2, %esi
movl $1, %edi
参数个数小于等于6时,采用寄存器传递,对于gcc使用的是%rdi, %rsi, %rdx, %rcx, %r8, %r9这6个寄存器。当大于6个参数时,需要使用栈区内存进行传递。本例中的参数7和8,都被执行了压栈操作pushq。 pushq,可以认为是两步操作
1. 扩展栈区地址空间,也就是将%rsp寄存器内容增加8 。
2. 将目的操作数,拷贝到这个新扩展的内存空间。
可以看到参数传递是在调用方完成的。
函数调用产生的汇编代码如下:
call f(int, int, int, int, int, int, int, int)
call指令也相当于三步操作
1. 扩展栈区地址空间,也就是将%rsp寄存器内容增加8(x86_64架构)。
2. 将%rip的内容(下一条指令,函数返回后需要执行的指令),拷贝到这个地址空间。
3. jmp到f函数标号。
pushq %rbp
movq %rsp, %rbp
subq $56, %rsp
被调用函数生成的汇编,需要执行三步初始化操作:
1. 保存调用者(main函数)的栈帧基地址寄存器的地址。
2. 将当前的栈顶指针,作为新的栈帧的基地址。
3. 根据当前栈帧(f函数)内申请的局部变量的总大小,直接扩展栈的大小。这里之所以用sub, 是因为栈的地址空间是从高地址向低地址部分扩展的。
以上内容,可以总结为下图。
需要说明下,虽然为了画图方便,%rsp指向了一个未使用的地址,但实际上栈顶指针指向实际使用的地址: 比如参数传递部分,指向的是参数7的地址; 函数调用部分指向的是返回地址对应的地址。
在被调函数完成相关初始化处理后, 我们称绿色部分为函数f对应的栈帧。也可以把 (%rbp %rip] 部分当做函数f的栈帧。虽然有些许差别,但在此处并不关键。
理解了上述函数调用的过程后,函数返回的过程可以简单的理解为以上操作的反向操作。
movl $0, %eax
leave
ret
也是三步:
1. 函数返回值保存在%rax中
2. leave指令会把当前%rbp的值恢复到%rsp(恢复栈顶);会将保存在f函数栈帧中的main函数的栈帧基地址弹出, 恢复到%rbp寄存器中
3. ret指令会将保存在f函数栈帧中的返回地址(main函数函数调用后的下一条指令地址)弹出, 恢复到%rip寄存器中
如果我们理解了上述函数调用的实现细节, 如果我们允许函数f 在执行某些等待异步操作的时机, 将它的执行上下文, 主要是各寄存器(通用寄存器, %rbp, %rsp, %rip等)的值, 保存在某个协程上下文对象(内存)中。
在异步操作完成,需要切换回来时, 我们再将这些保存的值恢复到各寄存器, 那么函数f 就成为了一个协程。当然这个过程还涉及到栈帧这块内存本身的处理, 这点在后面的小节马上介绍。
int64_t f(int p1, int p2, int p3, int p4, int p5, int p6, int p7, int p8)
{
int x = 0;
int y = 0;
int64_t z = 0;
int64_t l = 0;
<等待异步操作-yield>
x++;
p1++;
p7++;
p8++;
return 0;
}
posix ucontex, boost fcontext和tencent libco都是这种类型的协程。 对于有栈协程, 时刻要记住一点: 栈帧中使用的指针型变量, 如果不是指向该栈帧中的局部变量, 在协程恢复后其意义可能已经发生改变。
有栈协程是指协程本身有自己独立的调用栈。基于栈帧切换的协程, 除了寄存器上下文,一般需要我们给协程指定其使用的栈空间。在协程切换后, 你会发现调用方的函数调用栈替换为了, 被调方协程的调用栈。 以libco为例, 协程的寄存器上下文保存在regs[ 14 ], 而协程使用的栈由ss_sp指定。
struct coctx_t
{
#if defined(__i386__)
void *regs[ 8 ];
#else
void *regs[ 14 ];
#endif
size_t ss_size;
char *ss_sp;
};
我们以libco为例看下有栈协程的切换。 libco的协程入口函数遵循如下原型:
typedef void* (*coctx_pfn_t)( void* s, void* s2 );
在函数切换前,我们要完成对上下文的初始化,主要是完成如下三点:
1. 将RSP(此时还不是寄存器,而是保存该寄存器的内存)设置为之前指定的ss_sp对应的地址空间的最大值-8(可以想下为什么设置为栈空间的最大值,前面已经提过)。
2. 将返回地址设置为协程函数pfn的起始地址,这样协程上下文切换后,就可以从指定的函数执行。
3. 将函数的参数保存在RDI, RSI(此时还不是寄存器,而是保存该寄存器的内存)
int coctx_make(coctx_t* ctx, coctx_pfn_t pfn, const void* s, const void* s1) {
char* sp = ctx->ss_sp + ctx->ss_size - sizeof(void*);
sp = (char*)((unsigned long)sp & -16LL);
memset(ctx->regs, 0, sizeof(ctx->regs));
void** ret_addr = (void**)(sp);
*ret_addr = (void*)pfn;
ctx->regs[kRSP] = sp;
ctx->regs[kRETAddr] = (char*)pfn;
ctx->regs[kRDI] = (char*)s;
ctx->regs[kRSI] = (char*)s1;
return 0;
}
协程的切换过程相较于函数切换的call指令,使用的是coctx_swap函数。这是汇编实现的一个函数,函数原型如下:
extern void coctx_swap(coctx_t*, coctx_t*) asm("coctx_swap");
想下上面的“参数传递”小节,coctx_swap作为一个函数, 在进入该函数前。函数第一个参数coctx_t*会设置到%rdi,这个结构体用于保存切换前的协程上下文。第二个参数会设置到%rsi,这个结构体就是切换后的协程上下文。如果第二个参数对应的上下文结构刚通过上面coctx_make初始化完成。那么通过下述恢复操作,
movq 48(%rsi), %rbp
movq 104(%rsi), %rsp
<other-code>
movq 56(%rsi), %rdi
<other-code>
leaq 8(%rsp), %rsp
pushq 72(%rsi)
movq 64(%rsi), %rsi
ret
关键的寄存器会被设置:
1. 设置新协程的栈帧基地址寄存器,此处是0。
2. 设置新协程的栈顶地址寄存器(前面已经介绍过,coctx_t结构中指定的ss_sp对应的地址空间的最大值-8)。
3. 通过"movq 56(%rsi), %rdi" 把coctx_make的第三个参数 void*s放置在参数传递寄存器%rdi。
4. 通过“leaq 8(%rsp), %rsp” 将栈顶降低8(用于保存下一步的返回地址)
5. 通过"pushq 72(%rsi)" 把coctx_swap返回后执行的指令进行压栈,也就是将协程函数pfn(coctx_make的第二个参数) 起始指令进行压栈。
6. 通过"movq 64(%rsi), %rsi" 把coctx_make的第四个参数 void* s1放置在参数传递寄存器%rsi。
7. 通过ret指令将第5步的压栈的地址弹出到%rip,开始了新协程函数的执行。
在执行完被调函数初始化后,会开始新的栈的执行,后续该协程栈上的函数调用和普通函数调用没有区别。 完整的流程如下图:
再次说明下,虽然为了画图方便,%rsp指向了一个未使用的地址,但实际上栈顶指针指向实际使用的地址: 比如第二步中,指向返回地址对应的栈空间。
其他的有栈协程切换方式和libco类似,不一一赘述。
libco在协程切换之上,还提供了私有栈和共享栈的封装。 私有栈是针对每个新开的协程都指定独立的栈空间,栈空间不能太大,有越界风险。 共享栈则是定义一个默认线程栈空间大小的栈,多个协程共享同一空间,使用者不用担心越界风险。但在协程切换时,涉及到栈空间的保存。
这种方法, 本人还没有仔细研究。简单说下和其他同事讨论的相关结论: 这种方式并不会执行寄存器级的上下文保存和恢复, 只是将函数执行到的行号记录在协程对象的成员变量中, 协程函数通过switch case和跳转宏, 在恢复执行时跳转到指定的行号继续执行。 这种实现方式就可以认为是一种无栈协程。无栈并不是没有stack。而是在现有的stack上创建协程栈帧。不会为协程指定独立的stack空间。 C++20的原生协程就是此种实现。这里可以提前透露下,相较于其他无栈协程,C++20的原生协程创建的栈帧存在于堆上,我们可称之为堆帧,并不会随函数的挂起而销毁。
关于协程还有一种分类方法,对称,非对称。对称协程只提供了一种协程间的控制转移的语义即pass control, 而非对称提供了两种, invoke和suspend。 利用libco可以实现对称协程,也可以实现非对称协程。但我们一般倾向于实现非对称协程,实现如下程序架构。
c++20的原生协程也是非对称式的。在协程挂起时会返回到它的调用方。但我们还是可以实现它的对称转移,其中原因下篇文章会讲到。 对称协程的控制转移示意图如下:
本文对协程的做了基础的介绍,并从函数切换为起点对有栈协程的切换做了详细的分析。下一篇会继续介绍c20协程的预研成果,对比实验及库封装。
对C++20协程感兴趣的话,可以继续阅读本人的另一篇文章。
扫码关注腾讯云开发者
领取腾讯云代金券
Copyright © 2013 - 2025 Tencent Cloud. All Rights Reserved. 腾讯云 版权所有
深圳市腾讯计算机系统有限公司 ICP备案/许可证号:粤B2-20090059 深公网安备号 44030502008569
腾讯云计算(北京)有限责任公司 京ICP证150476号 | 京ICP备11018762号 | 京公网安备号11010802020287
Copyright © 2013 - 2025 Tencent Cloud.
All Rights Reserved. 腾讯云 版权所有