查看原文
其他

手把手教你从0开始实现C++协程!

The following article is from 微信客户端技术团队 Author peterfan


导语 | 本文将详细讲解C++协程上下文切换的底层原理,手把手教你从零开始实现C++协程,希望能为更多开发者提供一些经验和帮助。


简介


用C++实现有栈协程,核心在于实现协程上下文切换,在owl协程的整体架构中,owl.context位于最底层,所有上层API部基于这一层来实现:



本文将详细讲解C++协程上下文切换的底层原理,手把手教你从零开始实现C++协程。



一、owl.context接口设计


业界比较有名的上下文切换库有ucontext和boost.context,其中ucontext的接口文档齐全且语义清晰,而boost.context的接口略显晦涩。为了代码便于理解,一开始owl.context打算直接兼容ucontext接口,仔细研究后发现ucontext的一些设计在如今看来并不合理,严格遵循ucontext的接口会导致不必要的实现复杂度。因此最终的接口整体保留了ucontext的语义,但在细节上做了一些优化。


owl.context一共有4个API,先看一下接口定义,后面会依次讲解每一个API的具体实现:


typedef struct { void* base; size_t size;} co_stack_t;
typedef struct co_context { co_reg_t regs[32]; co_stack_t stack; struct co_context* link;} co_context_t;
// 获取当前 context// 返回值 0 表示正常返回// 返回值 1 表示调用 co_setcontext 导致函数返回int co_getcontext(co_context_t* ctx);
// 跳转到指定 context 执行void co_setcontext(const co_context_t* ctx);
// 先获取当前 context,再跳转到指定 context 执行void co_swapcontext(co_context_t* octx, const co_context_t* ctx);
// 创建新 context,在指定的栈上为 fn 设置好执行环境// 跳转到此 context 等价于调用 fn(arg)void co_makecontext(co_context_t* ctx, void (*fn)(uintptr_t), uintptr_t arg);



二、上下文切换示例


在讲解上述API的具体实现之前,我们先通过一个示例了解上下文切换的基本概念:


void test() { printf("start\n"); volatile int n = 3; co_context_t ctx; int ret = co_getcontext(&ctx); if (n > 0) { printf("ret = %d, n = %d\n", ret, n); sleep(1); --n; co_setcontext(&ctx); } printf("end\n");}


运行结果:


startret = 0, n = 3ret = 1, n = 2ret = 1, n = 1end


从运行结果可以看出,co_getcontext、co_setcontext本质上相当于一个增强版goto,可以控制执行流在同一个栈的栈帧之间跳转。第5行先调用co_getcontext()将当前上下文保存到ctx变量,代码执行到co_setcontext(&ctx)时,执行流跳回到co_getcontext()这行继续执行,从C/C++语言的角度看起来的效果是:co_getcontext()函数再次返回,只不过返回值变为1了



三、上下文切换原理


要实现上下文切换,必须先了解线程上下文的概念,对于一个正在运行的线程,其上下文由两部分组成:


  • CPU寄存器的值。


  • 线程的私有数据。


其中线程的私有数据只有极少数平台(如win32)才有,对于绝大部分主流操作系统,线程的上下文主要由CPU寄存器的值组成。因此,要实现上下文切换,只需要实现寄存器的保存/恢复即可。


那么哪些寄存器需要保存/恢复呢?这就需要了解寄存器使用约定,以32位ARM架构为例,其调用约定在AAPCS(Procedure Call Standard for the ARM Architecture)官方文档中有详细描述,AAPCS规定:


  • 一共有16个整数寄存器 r0-r15,32个浮点寄存器s0-s31。


  • r0-r3用作参数,r0-r1用作返回值。


  • r4-r8、r10、r11、s16-s31为callee saved registers。


  • r9由平台自定义如何使用。


  • r11-r15为特殊寄存器,分别对应(r11=FP、r12=IP、r13=SP、r14=LR、r15=PC)


对于callee saved registers,若函数中要用这些寄存器,必须先将这些寄存器的值压栈保存,用完这些寄存器后,在函数返回前从栈中恢复这些寄存器的值。也就是说,若函数foo调用函数bar,当bar返回后这些寄存器的值一定不会被改变


对于非callee saved registers(如r0-r3),函数中可以随意使用这些寄存器。也就是说,若函数foo调用函数bar,当bar返回后这些寄存器的值可能会被改变


在上面的示例中,test()调用了co_getcontext(),按照寄存器使用约定可知,当co_getcontext()返回后(无论是正常返回还是因co_setcontext()跳转返回),必须保证callee saved registers的值不变,因此co_getcontext需要保存如下寄存器的值:


  • callee saved registers。


  • r9由平台自定义,有可能被当做callee saved registers使用,必须保存。


  • SP为stack pointer,表示栈指针,必须保存。


  • LR为link register,表示当前函数的返回地址,必须保存。


相应的,co_setcontext需要恢复上述寄存器的值。


注意:由于每一种CPU架构都有自己的指令集和函数调用约定,甚至同一种CPU架构下不同操作系统也会有不同的调用约定。为了方便讲解,本文涉及到的所有API实现均基于32位ARM架构。



四、co_getcontext实现


有了上面的分析,实现co_getcontext就比较简单了,只用把寄存器r4-r11、SP、LR、s16-s31的值保存到ctx->regs中即可,汇编代码:


/* int co_getcontext(co_context_t* ctx); */.globl co_getcontextco_getcontext: /* save r4-r11, lr, sp to regs[0-9] */ mov r1, sp stmia r0!, { r4-r11, lr } stmia r0!, { r1 }
/* save s16-s31 to regs[16-31] */ add r0, r0, #24 vstmia r0, { s16-s31 }
/* return 0 */ mov r0, #0 mov pc, lr


为了便于理解,附上ctx->regs内存布局:




五、co_setcontext实现


co_setcontext的功能几乎与co_getcontext对称,反向操作即可:


/* void co_setcontext(co_context_t* ctx); */.globl co_setcontextco_setcontext: /* load r4-r11, lr, sp from regs[0-9] */ ldmia r0!, { r4-r11, lr } ldmia r0!, { r1 } mov sp, r1
/* load s16-s31 from regs[16-31] */ add r0, r0, #24 vldmia r0, { s16-s31 }
/* make co_getcontext() return 1 */ mov r0, #1 mov pc, lr


有一个比较微妙的点是,正常调用co_getcontext()返回值是0,而最后两行汇编会让co_getcontext()再次返回且返回值是1。



六、co_swapcontext实现


co_swapcontext()本质上是先调用co_getcontext()再调用co_setcontext(),因此可以用C语言实现:


void co_swapcontext(co_context_t* octx, const co_context_t* ctx) { if (co_getcontext(octx) == 0) { co_setcontext(ctx); }}


:在ucontext的glibc实现中,swapcontext()并没有采用上述取巧的方式,而是用汇编重新实现了一遍保存和恢复上下文的逻辑,实际上并不是很必要。owl.context直接复用co_getcontext、co_setcontext,大大减少了汇编代码量。


七、co_makecontext示例


使用co_getcontext、co_setcontext只能在同一个调用栈中跳转,并不具备实用价值。要实现有栈协程,每个协程必须有独立的调用栈,使用co_makecontext可以在指定的栈上创建一个新的执行环境,看一个稍微复杂点的例子:


co_context_t ctx0;co_context_t ctx1;
void co_hello(uintptr_t arg) { printf("co_hello() Enter arg = %lu\n", arg); co_swapcontext(&ctx1, &ctx0); printf("co_hello() Exit\n");}
void test_make_context() { printf("main start\n"); char stack[4096]; // 1.设置栈 ctx1.stack.base = stack; ctx1.stack.size = sizeof(stack); // 2.设置 co_hello 返回后需要跳转的上下文 ctx1.link = &ctx0; // 3.为 co_hello 创建执行环境 co_makecontext(&ctx1, &co_hello, 100); printf("main start co_hello\n"); co_swapcontext(&ctx0, &ctx1); printf("main resume co_hello\n"); co_swapcontext(&ctx0, &ctx1); printf("main end\n");}


运行结果:


main startmain start co_helloco_hello() Enter arg = 100main resume co_helloco_hello() Exitmain end


co_makecontext能够通过栈地址、栈大小、入口函数和函数参数创建一个执行环境,这一点与pthread_create很像,区别在于pthread_create会创建一个新线程,而co_makecontext只是创建一个独立的调用栈。


假设test_make_context()在主线程运行,则co_hello()也在主线程运行,区别是前者使用的是主线程栈,后者使用的是co_makecontext时设置的栈,由于两个函数在不同的栈中运行,来回跳转交叉执行栈上的状态也能够得以保留。两个函数之间的切换时序如下:




八、co_makecontext实现


要实现co_makecontext,需要了解AAPCS函数调用约定,调用约定规定了调用方(caller)和被调方(callee)的职责,要创建调用栈只需了解调用方的职责即可,在调用一个函数前调用方需要:


  • 设置好函数调用参数:若参数个数<=4依次使用r0-r3传递;若参数个数>4,前4个参数用r0-r3传递,剩余参数按照从右向左的顺序压栈。


  • 确保栈对齐:参数全部入栈后SP% 8==0(即按照8字节对齐)


为方便理解,看一个例子:


int hello(int a, int b, int c, int d, int e, int f) { //...}
void test() { hello(0, 1, 2, 3, 4, 5); //...}


test()调用hello()之前,需要为hello()设置好参数,hello()有6个参数,按照调用约定,前4个参数(0,1,2,3)依次放入(r0,r1,r2,r3),后2个参数(4,5) 压栈,此时寄存器和调用栈的状态如下:



当代码运行到hello()中时,通过(r0,r1,r2,r3) 可以访问前4个参数,通过FP寄存器加偏移可以访问后2个参数,此时寄存器和调用栈的状态如下:



到此实现co_makecontext就比较容易了,主要做的事情是:


  • 为入口函数设置好参数


入口函数的函数原型为void (uintptr_t),只有一个参数,直接将此参数保存到r0即可。


ucontext中makecontext的函数原型为:

void makecontext(ucontext_t ucp,void (func)(), int argc,...);


由于其入口函数可以支持多个int参数,参数个数大于4时需要进行压栈,因此ucontext中实现makecontext会比较复杂。


不只是32位ARM,大部分架构的调用约定中都有前N个参数直接使用寄存器,超过N个参数需要压栈的约定。owl.context将参数个数限制为一个,避免了繁琐的压栈操作,大大降低了实现复杂度。


  • 保证栈按照8字节对齐


  • 确保入口函数执行完毕后能跳转到link所指的上下文继续运行:ARM架构中,函数返回地址保存在lr寄存器,我们可以将lr寄存器的值改为某个stub函数地址,这样函数执行完毕后将会执行stub函数,在stub中跳转到link执行即可。


co_makecontext的实现代码如下:


#define R4 0#define LR 8#define SP 9#define FN 10#define ARG 11
void co_makecontext(co_context_t* ctx, void (*fn)(uintptr_t), uintptr_t arg) { uintptr_t stack_top = (uintptr_t)ctx->stack.base + ctx->stack.size;
/* ensure the stack 8 byte aligned */ uintptr_t* sp = (uintptr_t*)(stack_top & -8L);
ctx->regs[R4] = (uintptr_t)ctx->link; ctx->regs[LR] = (uintptr_t)&co_jump_to_link; ctx->regs[SP] = (uintptr_t)sp; ctx->regs[FN] = (uintptr_t)fn; ctx->regs[ARG] = arg;}


其中co_jump_to_link则是上面提到的stub函数,需要用汇编实现:


/* void co_jump_to_link(); */.globl co_jump_to_linkco_jump_to_link: /* when fn(arg) return call co_setcontext(link) */ movs r0, r4 bne co_setcontext b exit


最新ctx->regs的内存布局如下(与之前版本相比,新增了fn和arg字段):



co_makecontext的实现其实很简单,只需要设置(r4、lr、sp、fn、arg)即可,其中r4用于存放link。因为是全新的调用栈(r5-r11、s16-s31)的值并不重要。


还记得之前co_setcontext、co_setcontext的实现吗?之前的版本并没有处理co_makecontext的情况,因此需要稍做修改


  • 对于co_getcontext,需要将fn、arg赋空值。


  • 对于co_setcontext,需要判断fn的值,若不为空则调用fn(arg),否则走之前的逻辑直接返回。


最终的实现代码:


/* int co_getcontext(co_context_t* ctx); */.globl co_getcontextco_getcontext: /* r1 = sp, r2 = fn, r3 = arg */ mov r1, sp mov r2, #0 mov r3, #0 /* save r4-r11, lr, sp to regs[0-9] */ stmia r0!, { r4-r11, lr } stmia r0!, { r1-r3 }
/* save s16-s31 to regs[16-31] */ add r0, r0, #16 vstmia r0, { s16-s31 }
/* return 0 */ mov r0, #0 mov pc, lr
/* void co_setcontext(co_context_t* ctx); */.globl co_setcontextco_setcontext: /* r1 = sp, r2 = fn, r3 = arg */ /* load r4-r11, lr, sp from regs[0-9] */ ldmia r0!, { r4-r11, lr } ldmia r0!, { r1-r3 } mov sp, r1
/* load s16-s31 from regs[16-31] */ add r0, r0, #16 vldmia r0, { s16-s31 }
/* call fn(arg) if fn != 0 */ cmp r2, #0 bne .cofunc
/* make co_getcontext() return 1 */ mov r0, #1 mov pc, lr
.cofunc: /* call fn(arg) */ mov r0, r3 mov pc, r2



九、总结


理解了owl.context在32位ARM架构下的实现原理,要支持其它架构也就不难了,套路都类似,只需要熟悉每一种CPU架构的常用指令集和调用约定,最终就能实现一个支持全平台全架构的owl.context库。当然,在具体实现过程中会有很多坑,如:


  • win32中如何在协程中支持C++异常。


  • Windows中对FS/GS寄存器的特殊处理。


  • x64和AMD64调用约定的区别。


  • ARM/THUMB模式的兼容。


  • watchOS中对arm64_32的特殊处理。



 作者简介


范亮亮

腾讯客户端开发工程师

腾讯客户端开发工程师,目前负责微信客户端相关的跨平台开发工作,有丰富的C++开发经验。



 推荐阅读


阅见深我,读享生活,TVP读书分享会带你解锁新知!

保姆级教程!Golang微服务简洁架构实战

来,5W1H分析法帮你系统掌握缓存!(图文并茂)

2种方式!带你快速实现前端截图




您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存