文章目录
本文以有栈非对称协程的实现为例。项目源码在 https://github.com/lylhw13/Coroutine-in-C。
准备
基本概念
编写协程前需要明白几个概念:
有栈协程和无栈协程:
- 有栈协程:通过保存运行时的堆栈及运行时的上下文来保存运行状态,需要改变调用栈。
- 无栈协程:通过闭包或者状态机的方法记录程序的运行状态,不改变调用栈。
对称协程和非对称协程:
- 对称协程:各协程之间可以进行执行权的切换。
- 非对称协程:有一个中心化的调度器,所有协程都只和调度器进行执行权的切换。
已有的实现案例
- 无栈协程
可以参考 Simon Tatham 的文章 Coroutines in C,简明阐述了无栈协程的原理和实现。 - 有栈协程
比较出名的案例有 云风的 coroutine 协程实现 和 腾讯的 libco 库。
有栈协程的实现方法
常用的方法有3种:
- 使用 C 标准函数库的 setjmp 和 longjmp 函数
- 使用POSIX标准中的 ucontext 库,但是这个库已被标记为 obsolete
- 使用汇编直接对函数上下文进行操作
有栈协程的实现原理
协程原理的简单概括就是:函数可以根据需要让渡出 CPU 以供其他函数执行,而且函数可以在后续被唤起时,继续之前的状态执行。
下面一张图说明了协程的运行原理。
难点
协程的概念其实简单。但是当用汇编来实现,会有如下一些难题需要解决。
问题1,恢复后的协程应该在哪里继续执行?
在schedule中申请一个静态数组,静态数组是在栈上分配的,因此可以用来作为共有栈来使用。
typedef struct schedule {
char stack[STACK_SIZE]; /* 当作共有栈来使用 */
struct listhead head;
struct context ctx;
}schedule_t;
问题2:怎么初始化协程的栈和上下文?
这个需要考虑函数调用栈和函数的调用约定。对于C语言的调用约定可以参考 一段程序说明C语言不同调用约定的区别。初始化一个协程,就相当于手动构建函数的调用栈。对于C语言的函数调用栈可以参考一幅图帮助理解C函数的调用栈。
x86 的默认调用约定是 cdecl,函数调用汇编如下
caller:
pushl %ebp
movl %esp, %ebp
pushl para2
pushl para1
call callee
x86-64的函数调用汇编如下
caller:
pushq %rbp
movq %rsp, %rbp
movl para2, %esi
movl para1, %edi
call callee
因此仿照上面的函数调用,就可以初始化好协程的栈和上下文:
- 需要指定参数的位置,x86 默认的调用约定是 cdecl,参数是从右到左进行压栈;而x86-64前6个参数是通过寄存器传递。
- EIP (RIP, instruction pointer),即指令指针,该指针指向下一条即将执行的命令,此时应该指向代表协程执行主体的函数。
- 将 ESP (RSP)指向栈顶。
- 其他寄存器可以使用默认值。
- 注意,每个调用栈开始的EBP 压栈和 EBP 赋值主要用于函数的回退。这里考虑到执行到这一步的时候,协程已经执行完毕,可以将执行权交给schedule,而不用考虑协程的退出。
代码实现如下:
其中接口和 POSIX 函数 makecontext一致。
#if defined(__i386__)
void makectx(struct context *ctx, void(*fun)(struct coroutine*cor), void *para1)
{
void *sp;
void **para_addr;
sp = (void *)(ctx->ss_sp + ctx->ss_size - sizeof(void *));
/* align stack */
sp = (char*)((unsigned long)sp & -16L);
/* para in the stack */
para_addr = (void**)(sp - sizeof(void *)*2);
*para_addr = para1;
ctx->regs[oEIP/psize] = fun;
ctx->regs[oESP/psize] = (void*)(sp - sizeof(void *)*4);
return;
}
#elif defined(__x86_64__)
void makectx(struct context *ctx, void(*fun)(struct coroutine*cor), void *para1)
{
void *sp;
sp = (void *)(ctx->ss_sp + ctx->ss_size - sizeof(void *));
/* align stack */
sp = (char*)((unsigned long)sp & -16L);
ctx->regs[oRDI/psize] = para1;
ctx->regs[oRIP/psize] = fun;
ctx->regs[oRSP/psize] = sp;
return;
}
#endif
问题3:怎么切换协程的上下文?
其实切换协程的上下文,原理非常简单,就是将就协程的栈和寄存器保存起来,然后替换为新协程的栈和寄存器。
也就是操作分为两步:
1. 保存栈
每个协程挂起时,申请一块动态空间用来保存栈的值。在恢复的时候,将动态空间的值复制到栈空间。
2. 保存寄存器
以 x86 为例,x86 寄存器有 EAX, EBX, ECX, EDX, EDI, ESI, EBP, ESP, EIP。在保存寄存器时,我们申请一个数组,用来存储寄存器的值。恢复时,将寄存器值一一恢复。
x86-64 的原理一致,只不过寄存器数目不一致。具体实现请参照项目中的代码。
以下为各个寄存器值在数组中的偏离量,单位为字节。
#define oEAX 0
#define oEBX 4
#define oECX 8
#define oEDX 12
#define oEDI 16
#define oESI 20
#define oEBP 24
#define oESP 28
#define oEIP 32
以下为切换寄存器的汇编代码,先将寄存器值保存到第一个参数的数组中,然后再将第二个参数数组中的值加载到寄存器上。
.globl swapctx
.type swapctx, @function
swapctx:
/* save registers to first parameter */
movl 4(%esp), %eax
movl %ebx, oEBX(%eax)
movl %ecx, oECX(%eax)
movl %edx, oEDX(%eax)
movl %edi, oEDI(%eax)
movl %esi, oESI(%eax)
movl %ebp, oEBP(%eax)
movl %esp, oESP(%eax)
/* save eip */
movl (%esp), %ecx
movl %ecx, oEIP(%eax)
/* setup registers from second parameter */
movl 8(%esp), %eax
movl oESP(%eax), %esp
/* setup eip */
movl oEIP(%eax), %ecx
movl %ecx, (%esp)
movl oEBX(%eax), %ebx
movl oECX(%eax), %ecx
movl oEDX(%eax), %edx
movl oEDI(%eax), %edi
movl oESI(%eax), %esi
movl oEBP(%eax), %ebp
/* clear rax to indicate success */
xorl %eax, %eax
ret
参考: