从云风的coroutine库学习协程

协程又被称为微线程,不过其实这样的称呼无形中为理解协程增加了一点阻碍。协程本质上是在一个线程里面,因此不管协程数量多少,它们都是串行运行的,也就是说不存在同一时刻,属于同一个线程的不同协程同时在运行。因此它本身避免了所有多线程编程可能导致的同步问题。

协程的行为有点像函数调用,它和函数调用的不同在于,对于函数调用来说,假如A函数调用B函数,则必须等待B函数执行完毕之后程序运行流程才会重新走回A,但是对于协程来说,如果在协程A中切到协程B,协程B可以选择某个点重新回到A的执行流,同时允许在某个时刻重新从A回到B之前回到A的那个点,这在函数中是不可能实现的。因为函数只能一走到底。用knuth的话来说:

子程序就是协程的一种特例

既然允许协程中途切换,以及后期重新从切换点进入继续执行,说明必须有数据结构保存每个协程的上下文信息。这就是ucontext_t,而linux中包含以下几个系统函数对ucontext_t进行初始化,设置,以及基于ucontext_t进行协程间的切换:

  1. int getcontext(ucontext_t *);
  2. int setcontext(const ucontext_t *);
  3. void makecontext(ucontext_t , (void )(), int, …);
  4. int swapcontext(ucontext_t , const ucontext_t );

下面根据云风的精简协程库来说明如何用这些函数和ucontext_t来实现一个协程池。

coroutine的实现分析

coroutine里面主要是由协程管理结构schedule和以下几个核心函数组成 :

  1. coroutine_resume
  2. coroutine_yield

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/

你可能感兴趣的:(协程)