前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >手把手教你从零开始实现C++协程

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

作者头像
微信终端开发团队
发布2021-12-10 09:40:13
3.1K0
发布2021-12-10 09:40:13
举报

简介

在上一篇文章 《微信终端自研C++协程框架的设计与实现》 中,我们介绍了异步编程的演化过程和 owl 协程的整体设计思路,因篇幅所限,上文中并没有深入到协程的具体实现细节。用 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");
}

运行结果:

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

从运行结果可以看出,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 规定:

  1. 一共有 16 个整数寄存器 r0-r15,32 个浮点寄存器 s0-s31
  2. r0-r3 用作参数,r0-r1 用作返回值
  3. r4-r8、r10、r11、s16-s31 为 callee saved registers
  4. r9 由平台自定义如何使用
  5. 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_getcontext
co_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_setcontext
co_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 start
main start co_hello
co_hello() Enter arg = 100
main resume co_hello
co_hello() Exit
main 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_link
co_jump_to_link:
    /* when fn(arg) return call co_setcontext(link) */
    movs r0, r4
    bne co_setcontext
    b exit

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

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_getcontext
co_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_setcontext
co_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 的特殊处理

由于篇幅原因,就此打住,以后再找机会分享。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2021-12-09,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 WeMobileDev 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 简介
  • owl.context 接口设计
  • 上下文切换示例
  • 上下文切换原理
  • co_getcontext 实现
  • co_setcontext 实现
  • co_swapcontext 实现
  • co_makecontext 示例
  • co_makecontext 实现
  • 总结
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档