什么是协程?
协程被称为“轻量级线程”或者“用户态线程”。最近协程在高并发编程领域大放异彩,如Golang天生就支持协程,Lua和Python也支持协程。但其实协程并不是最近才出现的新技术,恰恰相反,协程是一项古老的技术。早期版本的Linux并不支持线程,这时就出现代替线程的轻量级线程--协程。比较有名的有: GNU Pth 和 Libtask(Go语言的作者之一Russ Cox的作品)。下面我们会以Libtask作为分析案例来解释协程的原理。
注意
以下内容会引起部分读者情绪不安,敬请留意。
基本概念
要理解协程,首先要知道程序是怎么运行的。所以下面我们先来聊聊程序是怎么跑起来的。
我们知道CPU的使命就是执行程序中的指令,而且CPU内部有很多用于存放数据的寄存器,其中比较重要的一个寄存器叫EIP寄存器,它用于存储下一条要执行的指令。除了EIP寄存器之外,还有一个比较重要的寄存器叫ESP寄存器,它用于保存程序的栈顶位置。除此之外,CPU还有很多其他用途的寄存器,如:通用寄存器EAX、EDX和段寄存器CS、DS等等。
当一个程序被执行(称为进程)的时候,这些寄存器的值通常会被修改。所以当要切换进程执行的时候,只需要把这些寄存器的值保存下来,然后把新进程寄存器的值赋值到CPU中,那么就完成进程切换了,通常我们把这个过程称为上下文切换,协程的切换也类似。
协程原理
上面讨论过,只需要切换上下文就可以切换协程。而上下文就是CPU寄存器的值,所以要创建一个协程,首先要创建一个保存CPU寄存器值的对象。在Libtask中,使用mcontext结构体来保存寄存器的值。mcontext结构体定义如下:
代码语言:javascript复制struct mcontext {
int mc_gs;
int mc_fs;
int mc_es;
int mc_ds;
int mc_edi;
int mc_esi;
int mc_ebp;
int mc_isp;
int mc_ebx;
int mc_edx;
int mc_ecx;
int mc_eax;
int mc_trapno;
int mc_err;
int mc_eip;
int mc_cs;
int mc_eflags;
int mc_esp;
int mc_ss;
};
mcontext结构体用来保存寄存器的值,从mcontext的成员可以看到要保存的寄存器很多,包括CS、DS、EIP、EAX、EBX等等。
C函数调用原理
因为协程切换一般是通过调用一个swapcontext()的C函数来进行,这个函数的作用就是保存旧的协程上下文和替换新的协程上下文来进行协程切换,而新旧协程上下文就是通过C函数的参数来传递的,所以我们先来了解下C函数调用过程的原理。
C函数是通过栈空间来传递参数的,通过下图有个感性的认识:
在上图中,浅绿色部分是调用函数时把参数入栈的。入栈时,C语言是从右到左开始入栈的。例如我们调用swapcontext(old, new)这个函数时,会先把new参数入栈,然后再把old参数入栈。
另外,在调用一个函数时,CPU会自动把当前指令的下一条指令入栈。所以,在上图可以看到在参数后面还有返回地址。在返回地址下面保存的是函数的局部变量。
注意
栈空间是从内存的高地址向地址增长的
协程切换
现在到了重头戏--协程的切换。协程的切换是通过保存旧协程的上下文和替换新协程的上下文来实现的。
在Libtask库中,保存协程上下文通过getcontext()实现,而替换协程上下文是通过setcontext()实现。这两个函数都是使用汇编语言实现的。所以要看明白这两个函数就必须有汇编的基础。我们来看看这两个函数的实现:
代码语言:javascript复制1getcontext()
gexcontext:
movl 4(%esp), �x
movl %fs, 8(�x)
movl %es, 12(�x)
movl %ds, 16(�x)
movl %ss, 76(�x)
movl �i, 20(�x)
movl %esi, 24(�x)
movl �p, 28(�x)
movl �x, 36(�x)
movl �x, 40(�x)
movl �x, 44(�x)
movl $1, 48(�x)
movl (%esp), �x
movl �x, 60(�x)
leal 4(%esp), �x
movl �x, 72(�x)
movl 44(�x), �x
movl $0, �x
ret
getcontext()函数的原型如下:
int getcontext(struct mcontext *ctx);
其作用是把当前寄存器的值保存到参数ctx中。上面这段汇编代码就不详细解说了,有兴趣可以根据C函数参数传递的原理来对照一下就很容易理解。
需要说明的一点是,“movl 4(%esp), �x”这行汇编代码的作用是把ctx参数放置到EAX寄存器中,后面的操作都是通过mcontext结构体的偏移量来赋值的。
代码语言:javascript复制2setcontext()
setcontext:
movl 4(%esp), �x
movl 8(�x), %fs
movl 12(�x), %es
movl 16(�x), %ds
movl 76(�x), %ss
movl 20(�x), �i
movl 24(�x), %esi
movl 28(�x), �p
movl 36(�x), �x
movl 40(�x), �x
movl 44(�x), �x
movl 72(�x), %esp
pushl 60(�x)
movl 48(�x), �x
ret
setcontext()函数是协程切换的切换点,原型如下:
int setcontext(struct mcontext *ctx);
其作用是把ctx参数中寄存器的值替换成CPU寄存器的值来实现切换。
最后,我们就可以通过getcontext()和setcontext()这两个函数来实现swapcontext()函数了,实现很简单:
代码语言:javascript复制int swapcontext(struct mcontext *new, struct mcontext *old)
{
getcontext(old);
setcontext(new);
return 0;
}
以后我们就可以通过swapcontext()函数来进行协程的切换了。
总结
在本文中,我们只要解释了协程的基本原理,但是要真正实现一个可以使用的协程库还需要做很多细节的工作,例如切换协程的栈空间(因为每个协程都需要有自己独立的栈空间才不会影响其协程)。
另外,一个完善的协程库还应该支持定时器和I/O阻塞自动切换协程等功能。对于怎么实现一个完善的协程库可以参考Libtask的源代码。