一、前言
??本来是想学习一下鹅厂的 libco 协程库来着,无奈之前没怎么接触过协程的实现,且 libco 源码太厚实了,啃起来太慢,所以打算先学习一个精简点的协程库,由浅入深再去啃 libco 。
??本次笔记记录的是云风大佬在2012年实现的一个短小精悍的协程库,代码总共只有200行左右,但却实现了协程的核心功能。个人感觉很适合用来学习协程的实现方法和执行流程,所以 fork 了一下,过了一遍并加了注释。 ??
二、实现分析
1. 数据结构
??首先是协程结构体,每个结构体实例对应着一条协程。可以看到其中定义了协程对应的回调函数与其传参,还有核心内存协程上下文ctx,以及每个协程对应的栈区内容等。
struct coroutine {
coroutine_func func;
void *ud;
ucontext_t ctx;
struct schedule * sch;
ptrdiff_t cap;
ptrdiff_t size;
int status;
char *stack;
};
??其次是协程调度器结构体,其中储存了若干协程指针以及当前运行协程id等内容,用于进行协程调度等操作。其中最重要的我觉得是共享栈区stack。
struct schedule {
char stack[STACK_SIZE];
ucontext_t main;
int nco;
int cap;
int running;
struct coroutine **co;
};
2. 整体思路
??了解了核心数据结构,那么协程的切换是如何实现的呢?我过了一遍源码,大致了解了云风大佬这个协程库的实现原理:
??简单来讲,调度器结构体 schedule 中有一块栈上的内存,声明为 char stack[STACK_SIZE]; ,当协程在执行时,会将这块内存当作自己的进程栈来使用。
??当协程执行挂起操作时,会通过 memcpy 把执行栈上的内容 copy 至协程自己的缓冲区,进行栈区内容的保存;而当协程进行唤醒操作时,只需要把协程缓冲区中的栈内容 copy 至执行栈上,即可实现栈区内容的恢复。因为所有的协程在执行中都需要使用这块栈内存,所以这种协程实现方法被称为共享栈。
??除了栈区内容的切换外,也需要进行硬件上下文的切换,对此,Linux存在系统调用来进行上下文的切换与保存,其均定义在头文件 ucontext.h 中。此协程库中主要使用的内容有:
- ucontext_t:上下文结构体,其中储存了上下文的内容。主要需要关注的有
uc_link: 下一个要执行的上下文 、uc_stack:此上下文所使用的栈信息 ,其余还有信号掩码、硬件上下文等内容。 - getcontext:传入一个
ucontext_t ,初始化它并获取当前上下文环境存入其中。 - makecontext:传入一个
ucontext_t 和一个函数指针及其参数,作用是指定该上下文的入口函数。即在此上下文被激活后,执行这个被绑定的函数。 - swapcontext:传入两个
ucontext_t ,作用为切换上下文环境。具体操作是保存当前上下文环境至第一个 ucontext_t ,并激活第二个 ucontext_t 的上下文环境。
??所以可以说这个协程库主要就是通过系统调用切换/保存上下文,以及保存每个协程的运行栈来实现的。 ??
3. 唤醒 resume
??协程的核心操作唤醒的实现如下,其中已经加上了我的注释。
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) {
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_READY 。在上面的源码中,可以看到对于刚创建的协程和被挂起后的协程操作是不一致的。主要原因是因为刚创建的协程并没有初始化上下文,而且也没有指定运行栈为调度器中的共享栈,所以新的协程需要指定上下文相关的内容并绑定入口函数。
??此外,makecontext 中的入口函数传参被拆成两部分来传入,我感觉是因为传参类型为 uint 而在64位环境下指针大小为8B,为了避免这部分的差异,所以把指针分为前32位和后32位来进行传入。而在随后入口函数的定义中对指针进行拼合操作,来获取正确的指针。
??另外值得一提的是在对已挂起协程的恢复时,需要恢复执行栈,所以使用 memcpy 进行内存拷贝。由于栈地址是由高向低发展,所以这里是从后倒着找栈顶 S->stack + STACK_SIZE - C->size 。
??入口函数 mainfunc 定义如下,主要就是传入协程调度器,从而获取正在执行的协程,从而执行对应的回调函数。并在回调函数执行完毕后删除协程,从而宣布此协程执行完毕。
static void
mainfunc(uint32_t low32, uint32_t hi32) {
uintptr_t ptr = (uintptr_t)low32 | ((uintptr_t)hi32 << 32);
struct schedule *S = (struct schedule *)ptr;
int id = S->running;
struct coroutine *C = S->co[id];
C->func(S,C->ud);
_co_delete(C);
S->co[id] = NULL;
--S->nco;
S->running = -1;
}
??
4. 挂起 yield
??协程的核心操作挂起的实现如下,其中已经加上了我的注释。
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);
}
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);
}
??挂起操作相对就比较简单了,主要就是保存执行栈 + 切换上下文。其中主要的篇幅是在保存执行栈这一环节上,主要的逻辑是协程栈内存不足时释放并申请足够的内存,随后 copy 执行栈。 ??
5. 新建与扩容
??值得一提的是协程调度结构体中是通过一个协程指针数组来储存所管理的协程的,所以涉及到扩容问题。此协程库中采用了经典的二倍扩容法,通过 realloc 进行扩容,具体实现如下:
int
coroutine_new(struct schedule *S, coroutine_func func, void *ud) {
struct coroutine *co = _co_new(S, func , ud);
if (S->nco >= S->cap) {
int id = S->cap;
S->co = realloc(S->co, S->cap * 2 * sizeof(struct coroutine *));
memset(S->co + S->cap , 0 , sizeof(struct coroutine *) * S->cap);
S->co[S->cap] = co;
S->cap *= 2;
++S->nco;
return id;
} else {
int i;
for (i=0;i<S->cap;i++) {
int id = (i+S->nco) % S->cap;
if (S->co[id] == NULL) {
S->co[id] = co;
++S->nco;
return id;
}
}
}
assert(0);
return -1;
}
??
三、小结
??这个协程库的实现比较简单明了,但是仍有很多学习的地方。通过这个协程库,我也初步了解了协程的实现原理。但是不得不说,虽然源码仅仅200行,但是其中很多思路可能是一时半会想不出来的。这种思路包括数据结构的定义/函数的实现,我觉得都需要大量的积累,只能说我还有很多需要学习和接触的。
??在之后对鹅厂 libco 的源码学习中,我应该会着重去了解相比此协程库其多出内容的作用和意义,希望可以让我接触到更多新的东西。
|