此文简要分析一下libco协程的关键原理。
在分析前,先简单过一些协程的概念,以免有新手误读了此篇文章。
协程是用户态执行单元,它的创建,执行,上下文切换,挂起,销毁都是在用户态中完成,对linux系统而言,其实协程和进程(注:在linux系统中,进程是拥有独立地址空间的线程)一样,都是CPU的执行单元,只是进程是站在操作系统的层面来看,操作系统帮我们实现了这一抽象概念,而协程是站在用户的应用程序层面来看,协程的实现得靠我们自己。我们常说使用协程可以做到以阻塞式的编码方式实现异步非阻塞的效果,这是因为我们在用户程序层面实现了调度器,当协程要阻塞的时候切换上下文,执行其余就绪的协程。
下面简要说一下实现一个协程库需要哪几个模块。
1 首先当然是操作系统的执行单元,对于一个执行单元来说,最基本的其实也就两点,一是指令,二是内存空间,指令定义了操作,内存用于保存指令中需要的数据,基于对指令和内存的抽象,我们这里先牵强的称之为协程。
2 有了执行单元后,当然就需要调度器来负责调度这些执行单元,如某个协程要阻塞了,就保存其上下文,然后运行下一个就绪状态的协程,当然调度器也是在一个协程单元中运行。
3 最后为了实现阻塞式编码实现非阻塞的效果,需要实现异步I/O,而异步IO也恰是调度协程的触发器。
协程库中有了这三个模块基本就完成了。这里有一个关键的点,那就是当前运行的协程要阻塞了,我们将其上下文保存,切换至下一个就绪状态的协程,这里该如何实现?
要回答这个问题,我们得先想想什么操作会引起当前协程阻塞?协程或者说所有的执行单元其实都是指令和数据的有序排列,指令的执行依赖于数据,因此协程阻塞的话想必是因数据而起,说白了就是I/O操作(当然还有sleep操作,这个先以特例看待)。为了避免当前协程阻塞导致整个进程都阻塞掉,我们可以使用多路I/O模型,例如epoll,将所有的I/O操都作通过epoll模型来进行,一旦有协程的需要进行IO,就保存好它的上下文环境,加入阻塞队列,然后再从就绪队列中取出下一个协程运行,待所有工作协程都陷入阻塞时,再通过epoll进行多路IO操作。
至于如何保存与恢复上下文这一点正是此文接下来要分析的。
我们先简要看一下协程上下文的定义
//coctx.h
struct coctx_t
{
#if defined(__i386__)
void *regs[ 8 ];
#else
void *regs[ 14 ];
#endif
size_t ss_size;
char *ss_sp;
};
该结构保存着协程的上下文,在这里先不解释各个变量的含义,将其拿出来只是为了解释协程切换的关键函数:coctx_swap,因为调用该函数时将传入两个coctx_t类型指针。
协程上下文切换的关键实现位于coctx_swap.S文件中。初学者可能会疑惑这是什么文件,这里简单解释一下,我们写的源代码能变成最终的可执行文件,是经过多个步骤的,分别是预处理->编译->汇编->链接4个过程,其中编译这一过程是将源代码转成汇编代码,那么为什么这里直接提供一个汇编代码文件,而不是一个.c或.cpp文件呢?因为这个函数跟我们用c/cpp写出的函数经过gnu编译器编译后生成的函数结构不一致,另外c/cpp的语法糖也无法实现对寄存器(主要是rsp和rip寄存器)的控制。
这里只看x86_64架构下的实现
.globl coctx_swap
#if !defined( __APPLE__ ) && !defined( __FreeBSD__ )
.type coctx_swap, @function
#endif
coctx_swap:
leaq 8(%rsp),%rax
leaq 112(%rdi),%rsp
pushq %rax
pushq %rbx
pushq %rcx
pushq %rdx
pushq -8(%rax) //ret func addr
pushq %rsi
pushq %rdi
pushq %rbp
pushq %r8
pushq %r9
pushq %r12
pushq %r13
pushq %r14
pushq %r15
movq %rsi, %rsp
popq %r15
popq %r14
popq %r13
popq %r12
popq %r9
popq %r8
popq %rbp
popq %rdi
popq %rsi
popq %rax //ret func addr
popq %rdx
popq %rcx
popq %rbx
popq %rsp
pushq %rax
xorl %eax, %eax
ret
先简单解释一下头部的代码:
.globl coctx_swap //.global 声明coctx_swap是全局可见的
#if !defined( __APPLE__ ) && !defined( __FreeBSD__ )
.type coctx_swap, @function //gnu汇编器定义函数时的规则
#endif
coctx_swap: //coctx_swap函数内容开始
leaq 8(%rsp),%rax
leaq 112(%rdi),%rsp
...
上面已经提过了,该函数实际被调用时,传入了两个参数,均为coctx_t类型指针。接下来我们看该函数的上半段:
coctx_swap:
leaq 8(%rsp),%rax
leaq 112(%rdi),%rsp
pushq %rax
pushq %rbx
pushq %rcx
pushq %rdx
pushq -8(%rax) //ret func addr
pushq %rsi
pushq %rdi
pushq %rbp
pushq %r8
pushq %r9
pushq %r12
pushq %r13
pushq %r14
pushq %r15
...
lea是取址指令,b,w,l,q是操作属性限定符,分别表示1字节,2字节,4字节,8字节。在x86_64架构下,函数调用时,参数传递将从左到右分别存入rdi,rsi,rdx,rcx,r8,r9,当这6个不够用的时候才会借用栈。
此处简要提一下x86_64架构下gnu编译器编译后的c/cpp函数调用过程:
1 传参,主要是传递给寄存器。当寄存器不够用时,会丛右到左压栈,然后再传参给寄存器
2 将返回地址压栈,该地址一般指向上一函数中的下一条指令
3 修改rip寄存器(指令寄存器)为调用函数的起始地址,新的函数开始了
4 将上个函数的栈帧基址(rbp寄存器用于存放栈帧基址)压入栈中
5 将rbp寄存器中的值修改为rsp寄存中的值,即开启了新的栈帧
其中2,3是一般由call指令做的(当然也可以拆分为push,jump两个指令),4,5为被调函数里面的逻辑。
函数返回时是一个逆向的过程,即恢复到上个函数的栈帧即可。
其中rsp寄存器为栈顶的地址,由于栈空间是向下增长的,每次push,pop操作都会对其减少和增加对应的字节数。因此上半段相当于是把当前的各寄存器值存入了第一个参数传入的协程上下文的regs数组中,结果如下:
//low | regs[0]: r15 |
// | regs[1]: r14 |
// | regs[2]: r13 |
// | regs[3]: r12 |
// | regs[4]: r9 |
// | regs[5]: r8 |
// | regs[6]: rbp |
// | regs[7]: rdi |
// | regs[8]: rsi |
// | regs[9]: ret | //函数的返回地址
// | regs[10]: rdx |
// | regs[11]: rcx |
// | regs[12]: rbx |
//hig | regs[13]: rsp | //该值为上个栈帧在调用该函数前的值
其实从这段代码中也能推出来了,传入的第一个参数必然就是当前工作协程的上下文变量,那么相应的,传入的第二个参数必然就是接下来要执行的工作协程。接下来看下半段代码:
movq %rsi, %rsp
popq %r15
popq %r14
popq %r13
popq %r12
popq %r9
popq %r8
popq %rbp
popq %rdi
popq %rsi
popq %rax //ret func addr
popq %rdx
popq %rcx
popq %rbx
popq %rsp
pushq %rax
xorl %eax, %eax
ret
第一行即把rsp(存储栈顶的地址,改变它的地址,就相当于改变了栈空间)替换为rsi寄存器中的值,上面提过了rsi保存着第二个参数中传入进来的上下文变量,即接下来要运行的工作协程的上下文。接着是一系列的赋值行为(注意栈空间是向下增长的),将接下来要运行的工作协程的上下文中的regs数组中的各值恢复到各寄存器中。将返回地址压入栈中,清0rax寄存器的低32位后(该寄存器一般用于存储函数返回值,这行代码并不是要拿它作为返回值使用,因为c/cpp代码在声明该函数时,它并没有返回值,个人感觉是出于程序安全考虑),执行ret指令(该指令用于将栈顶的返回地址弹出给rip寄存器,这也是push %rax 将返回地址压入栈中的原因),于是下一个工作协程开始运行了。
有没有感觉漏了些什么?
是的,漏了协程的上下文初始化过程。我们看一下其初始化函数:
//coctx.cpp
enum
{
kRDI = 7,
kRSI = 8,
kRETAddr = 9,
kRSP = 13,
};
int coctx_make( coctx_t *ctx,coctx_pfn_t pfn,const void *s,const void *s1 )
{
char *sp = ctx->ss_sp + ctx->ss_size;
sp = (char*) ((unsigned long)sp & -16LL );
memset(ctx->regs, 0, sizeof(ctx->regs));
ctx->regs[ kRSP ] = sp - 8;
ctx->regs[ kRETAddr] = (char*)pfn;
ctx->regs[ kRDI ] = (char*)s;
ctx->regs[ kRSI ] = (char*)s1;
return 0;
}
其中,下面的两行代码最为重要
ctx->regs[ kRSP ] = sp - 8;
ctx->regs[ kRETAddr] = (char*)pfn;
第一行是将rsp寄存器替换为了该协程私有的栈空间地址,这样就保证了每个协程具备独立的栈空间。
为什么替换了rsp寄存器就保证了该协程将使用自己的栈空间地址呢?
因为栈空间的分配和回收,是通过rsp寄存器来控制的,如我要分配4个字节时,可执行sub $0x4,%rsp,回收4个字节时,可执行add $0x4,%rsp,因此当替换了rsp寄存器的值时,即替换了栈空间
第二行是将返回地址(即下一条执行指令)替换为了用户创建协程时传入的开始函数地址。
当然一个函数的执行少不了传参,因此接下来的两行代码,就把参数赋值给了regs数组中对应与rdi寄存器和rsi寄存器的位置
ctx->regs[ kRDI ] = (char*)s; //rdi寄存器保存从左到右的第一个参数
ctx->regs[ kRSI ] = (char*)s1; //rsi寄存器保存从左到右的第二个参数
到此,核心部分均分析完毕。接下来再回顾核心函数coctx_swap的代码,上面我已经提过了,这个函数的结构和普通的c/cpp写出的函数经gnu编译器编译后生成的函数结构不一致,在接下来的代码中,我会在注释里将其精简掉的部分写出来。
.globl coctx_swap
#if !defined( __APPLE__ ) && !defined( __FreeBSD__ )
.type coctx_swap, @function
#endif
coctx_swap:
//push %rbp //将上个栈帧的基址压入栈中
//movq %rsp,%rbp //将rbp赋值为当前的栈顶的值,即开启了新的栈帧
//保存当前工作线程的上下文
leaq 8(%rsp),%rax
leaq 112(%rdi),%rsp
pushq %rax
pushq %rbx
pushq %rcx
pushq %rdx
pushq -8(%rax) //函数返回地址,即下一条指令的执行地址
pushq %rsi
pushq %rdi
pushq %rbp
pushq %r8
pushq %r9
pushq %r12
pushq %r13
pushq %r14
pushq %r15
//恢复下一个工作协程的上下文
movq %rsi, %rsp
popq %r15
popq %r14
popq %r13
popq %r12
popq %r9
popq %r8
popq %rbp
popq %rdi
popq %rsi
popq %rax //函数返回地址,即下一条指令的执行地址
popq %rdx
popq %rcx
popq %rbx
popq %rsp
pushq %rax
xorl %eax, %eax
//leaveq 该指令将rbp赋值给rsp,再弹出栈顶的上个栈帧的基址,并将其赋值给rbp寄存器,从而恢复上个栈帧调用该函数前的结构。相当于movq %ebp, %esp和popq %ebp两条指令
ret //相当于popq %rip
最后再额外提一点,libco协程库的性能如何?其实可以看到其切换成本非常的低,每次切换只有三十多条指令。但真正影响切换性能的其实并不是这关键性的上下文切换代码,而是切换之后可能带来的cache缺失问题!要知道对于现在的cpu来说,一次总线周期已经足够cpu执行几十条指令了。关于cpu cache的知识,可以查看我的另一篇文章,从死循环说起。关于libco如何hook第三方库,实现无缝接入的原理,可以参考我的另一篇文章,libco hook原理简析。
末尾附上c/cpp程序函数调用过程时的栈帧结构以及i386架构下的c/c++程序内存结构,辅助初学者理解。
此图为c/cpp程序的函数调用栈示意图,在x86_64架构下,当寄存器足够存放参数时,是不会对参数进行压栈的,因此参数1到n(对应函数参数列表是从右到左)是可选的,当把上个栈帧的基址压入栈中时,新的栈帧就开始了。
下图为32位系统(linux)下的c/cpp程序的内存结构简易图,32位系统寻址能力为4G,其中0x0C000000-0xFFFFFFFF为内核空间,用户空间只有3G,箭头标明了内存的增长方向,其中堆和动态库都是向上增长的,栈是向下增长的