协程又被称为微线程,不过其实这样的称呼无形中为理解协程增加了一点阻碍。协程本质上是在一个线程里面,因此不管协程数量多少,它们都是串行运行的,也就是说不存在同一时刻,属于同一个线程的不同协程同时在运行。因此它本身避免了所有多线程编程可能导致的同步问题。
协程的行为有点像函数调用,它和函数调用的不同在于,对于函数调用来说,假如A函数调用B函数,则必须等待B函数执行完毕之后程序运行流程才会重新走回A,但是对于协程来说,如果在协程A中切到协程B,协程B可以选择某个点重新回到A的执行流,同时允许在某个时刻重新从A回到B之前回到A的那个点,这在函数中是不可能实现的。因为函数只能一走到底。用knuth的话来说:
子程序就是协程的一种特例
既然允许协程中途切换,以及后期重新从切换点进入继续执行,说明必须有数据结构保存每个协程的上下文信息。这就是ucontext_t,而linux中包含以下几个系统函数对ucontext_t进行初始化,设置,以及基于ucontext_t进行协程间的切换:
下面根据云风的精简协程库来说明如何用这些函数和ucontext_t来实现一个协程池。
coroutine里面主要是由协程管理结构schedule和以下几个核心函数组成 :
coroutine由用户协程和一个管理作用的协程组成。每次协程切换时,用户协程必须先切换到管理协程,然后管理协程再负责切换到其他的用户协程,不能直接从用户协程切换到其他用户协程。从以上两个函数来说,coroutine_resume就是从管理协程切换到用户协程的入口,coroutine_yield是从用户协程切换到管理协程的入口。
下面着重分析一下这两个函数:
void
coroutine_resume(struct schedule * S, int id) {
assert(S->running == -1);
assert(id >=0 && id < S->cap);
struct coroutine *C = S->co[id];
if (C == NULL)
return;
int status = C->status;
switch(status) {
//第一次被切换到的用户协程处于COROUTINE_READY
case COROUTINE_READY:
getcontext(&C->ctx);
C->ctx.uc_stack.ss_sp = S->stack;
C->ctx.uc_stack.ss_size = STACK_SIZE;
C->ctx.uc_link = &S->main;
S->running = id;
C->status = COROUTINE_RUNNING;
uintptr_t ptr = (uintptr_t)S;
makecontext(&C->ctx, (void (*)(void)) mainfunc, 2, (uint32_t)ptr, (uint32_t)(ptr>>32));
swapcontext(&S->main, &C->ctx);
break;
case COROUTINE_SUSPEND:
memcpy(S->stack + STACK_SIZE - C->size, C->stack, C->size);
S->running = id;
C->status = COROUTINE_RUNNING;
swapcontext(&S->main, &C->ctx);
break;
default:
assert(0);
}
}
coroutine中协程有三种状态:
#define COROUTINE_DEAD 0
#define COROUTINE_READY 1
#define COROUTINE_RUNNING 2
#define COROUTINE_SUSPEND 3
协程第一次运行之前处于COROUTINE_READY 状态,正在运行的协程处于COROUTINE_RUNNING 状态,让出CPU,切换到其他协程的协程处于COROUTINE_SUSPEND。
当管理协程调用coroutine_resume试图启动一个处于COROUTINE_READY 的协程时,coroutine首先初始化这个用户协程对应的context,然后通过调用makecontext设置这个用户协程的入口地址。最后调用swapcontext完成管理协程和用户协程的切换,swapcontext(&S->main, &C->ctx) 将当前的上下文保存在S->main中,然后切换到C->ctx对应的执行流。
这里先省略掉后面的代码,先解析coroutine_yield,回头再继续看coroutine_resume。
void
coroutine_yield(struct schedule * S) {
int id = S->running;
assert(id >= 0);
struct coroutine * C = S->co[id];
assert((char *)&C > S->stack);
_save_stack(C,S->stack + STACK_SIZE);
C->status = COROUTINE_SUSPEND;
S->running = -1;
swapcontext(&C->ctx , &S->main);
}
因为协程的切换完全在用户态,和系统内部的线程不同,它是非抢占式的,因此必须依靠用户自觉让出CPU 才能让其他协程运行,coroutine_yield函数的作用就是主动让出CPU,每当用户协程想让出CPU时,就调用coroutine_yield,coroutine_yield将完成从用户协程切到管理协程的动作。
回到coroutine_yield函数,程序首先获得正在运行的协程的控制结构体,然后将该协程的运行上下文(主要是堆栈)保存在这个结构体中,最后通过swapcontext函数切换到控制协程。我们可以看到,为了能够让这个用户协程下次还能回到这个执行点继续执行,必须保留它的执行上下文,这部分工作主要是在_save_stack函数中完成。下面看一下_save_stack函数的实现:
static void
_save_stack(struct coroutine *C, char *top) {
char dummy = 0;
assert(top - &dummy <= STACK_SIZE);
if (C->cap < top - &dummy) {
free(C->stack);
C->cap = top-&dummy;
C->stack = malloc(C->cap);
}
C->size = top - &dummy;
memcpy(C->stack, &dummy, C->size);
}
该函数的第二个指针代表当前协程运行栈的栈顶,从coroutine_yield我们知道
top = S->stack + STACK_SIZE
为什么会是这样?回到coroutine_resume函数,在coroutine_resume函数中,对于即将被调度的用户协程,会做如下的初始化:
C->ctx.uc_stack.ss_sp = S->stack;
C->ctx.uc_stack.ss_size = STACK_SIZE;
这两行代码的意思就是将该协程的栈顶设置为S->stack + STACK_SIZE。而下面的
makecontext(&C->ctx, (void (*)(void)) mainfunc, 2, (uint32_t)ptr, (uint32_t)(ptr>>32));
设置该协程的入口地址,也就是说该协程启动时,将从mainfunc进入,并使用S->stack + STACK_SIZE作为栈顶控制协程的运行栈。
从上面对用户协程的栈顶初始化过程中可以看到,用户协程在运行过程中用的都是struct schedule的stack[STACK_SIZE]作为栈空间,由于每个协程运行时都会使用到这个空间,因此还必须为每个协程单独建立一个属于自己的栈空间。下面回到_save_stack函数
C->cap表示该控制结构对应的协程的私有栈空间的大小,因此
if(C->cap < top - &dummy)
的作用是判断该用户协程的私有栈空间能否大于它运行时栈空间。前面说过top 表示栈顶,而dummy表示该用户协程当前的栈底(栈由高地址向低地址生长),所有top-dummy就表示该用户协程运行时栈所占空间。如果用来保存私有栈的C->stack的大小不能放下运行时栈空间,则需要重新申请。
memcpy(C->stack, &dummy, C->size);
这里就将运行时栈的数据全部拷贝到该协程控制结构的C->stack中,这样,以后别的协程使用S->stack做运行时栈空间时,就不会覆写掉该协程原来栈中的数据了。
回到coroutine_yield,现在当前的协程的栈记录已经被保存在了C->stack中,最后调用
swapcontext(&C->ctx , &S->main);
切换到控制协程。这个函数会保存上下文相关的寄存器到C->ctx中。下次这个协程被重新启动时,就会从这里继续运行下去(调用coroutine_yield函数的下一条语句)。
在前面的coroutine_resume中我们知道,上次main协程是从
swapcontext(&S->main, &C->ctx);
break;
切换出去的,因此回到main协程之后,将会从break语句开始执行。一般来说,买你协程会不断循环调用coroutine_resume启用协程池中的协程,直到所有的用户协程结束。因此我们假设main协程再次从coroutine_resume进入,此时main已经选好了下一个被调度的用户协程,他的控制结构是:
struct coroutine *C = S->co[id];
如前面所说,如果该协程之前没有运行过,则走
case COROUTINE_READY:
就是一些进行初始化的操作,如果前面运行过了,但还没有死亡,则走,
case COROUTINE_SUSPEND:
下面我们看一下这个分支:
首先为了启动这个协程,必须恢复它的上下文信息,上下文信息包括:
1. 运行栈
2. 寄存器
在case COROUTINE_READY中我们知道
C->ctx.uc_link = &S->main;
C的运行时栈空间始终是在S->main中的,因此恢复栈空间,其实就是将coroutine_yield中保存到各自私有的C->stack空间中的数据恢复到S->main中:
memcpy(S->stack + STACK_SIZE - C->size, C->stack, C->size);
这里有点讲究,因为栈是从高地址向低地址生长的,所以需要从高地址向低地址拷贝。[记住_save_stack中在保存栈空间时,C->stack中的低地址对应运行时栈空间(S->stack-S->stack+STACK_SIZE )的高地址 : memcpy(C->stack, &dummy, C->size);]
coroutine_resume最后调用swapcontext重新启动了一个新的用户协程,这样就完成了从一个协程切换到另一个协程的所有操作。
综上
本质上协程就是通过一个记录上下文的结构和若干可以切换,修改,获取上下文信息的函数来达到用户态切换执行流的功能。这里一个核心是我们可以为各个协程定义一个连续内存空间,然后将这个连续内存空间的地址(uc_stack.ss_sp = S->stack)和大小(uc_stack.ss_size)设置到上下文结构中作为该协程的运行栈空间,通过这两个变量就可以计算出栈顶指针(uc_stack.ss_sp + uc_stack.ss_size),所以本质上在用户态定义了栈空间。在协程切换是,直接使用上下文结构中的栈顶指针作为esp,完成多个执行流之间的栈空间分离。自然就可以做到模拟线程的效果。
这里附一个传送门,介绍swapcontext, makecontext, setcontext, getcontext的实现,这样可以更好理解协程的原理。
http://anonymalias.github.io/2017/01/09/ucontext-theory/