教你在 C 语言上编写自己的协程

c/c++

浏览数:204

2019-3-30

协程介绍

总所周知,协程这个概念已经是服务端开发领域中耳熟能详的名词了。说协程是一组程序组件,以往的多线程编程有个特点是需要来回进行系统级别的来回上下文切换,造成很大的系统开销,不仅如此,很多操作我们还需要保证原子性,加锁,锁这个东西嘛,本来就是个坑,能不能最好还是不要用了。协程就是这么牛,能解决上述出现的所有问题,因为协程是用户态轻量级的多线程,上下文切换的开销是非常小的,而且更重要的是,是用户主动去进行切换的,因此不存在这个操作执行到一半,就被另一个线程给打断了。那么协程常见的用例都有哪些呢?当然可以用来做状态机,可读性很高。也可以用来做角色模型和产生器。大牛们也在不断在造轮子,比如有非常牛逼的云风大叔也造了一个简易的协程框架,代码非常精简,非常适合学习,真心点个赞!后面我们会着重分析云风大叔的代码。

ucontext 上下文

下面我们来介绍几个相关的 C 库函数:
setcontextgetcontextmakecontextswapcontext 是用来做 context 控制的。setcontext 可以被看做是一个 setjmp/longjmp 的高级版本。

在 ucontext.h 这个系统的头文件上定义了 ucontext 的结构体,我们可以看到结构体如下所示:

typedef struct ucontext {
    struct ucontext *uc_link;
    sigset_t         uc_sigmask;
    stack_t          uc_stack;
    mcontext_t       uc_mcontext;
    ...
} ucontext_t;

这是最重要的结构体,让我们来分析一下这个这个结构体。
如果上下文被用 makecontext 来创建时,uc_link 指向的是当前上下文退出时候将会被 resumed 的上下文。uc_sigmask 被用来存储在上下文中一组被阻塞的信号, uc_stack 是一个被上下文使用的 stackuc_mcontext 用来存储执行状态,包括所有的寄存器和 CPU flags、指令指针和栈指针。

函数介绍

int setcontext(const ucontext_t *ucp)

这个函数会把当前上下文转移到上下文 ucp 中。该函数不会返回,从 ucp 这个指针中执行。

int getcontext(ucontext_t *ucp)

该函数会保存当前的上下文信息到 ucp 中。

void makecontext(ucontext_t *ucp, void *func(), int argc, ...)

在被之前使用 getcontext 初始化后的 ucp 中设置一个替代的控制线程, ucp.uc_stack 成员应该被指向合适大小的栈,常量 SIGSTKSZ 通常会被使用。当使用 setcontextswapcontext 跳转的时候,执行将从 func 指向的函数的入口点开始,当然别忘了指定 argc 参数,表示参数个数。当 func 终止的时候,控制权被返回到 ucp.uc_link

int swapcontext(ucontext_t *oucp, ucontext_t *ucp)

转到 ucp 上下文中执行并保存当前上下文到 oucp

下面我们来看一个简单的示例:

#include <stdio.h>
#include <ucontext.h>
#include <unistd.h>

int main(int argc, const char *argv[]){
    ucontext_t context;
    
    getcontext(&context);
    puts("Hello world");
    sleep(1);
    setcontext(&context);
    return 0;
}

结果的输出是:
Hello world
Hello world
Hello world
Hello world

是不是感觉这个世界很奇妙!

cloudwu C 协程

现在请把目光转移到 c 协程
主要定义了几个数据结构和函数,现在来分析一下如何实现的。
先创建一个结构体

struct schedule {
    char stack[STACK_SIZE];   // 运行的协程的栈
    ucontext_t main;          // 下个要切换的协程的上下文状态
    int nco;                  // 当前协程的数目
    int cap;                  // 协程总容量
    int running;              // 当前运行的协程
    struct coroutine **co;    // 协程数组,指向指针的指针 co
};
struct coroutine {
    coroutine_func func;      // 调用函数
    void *ud;                 // 用户数据
    ucontext_t ctx;           // 保存的协程上下文状态
    struct schedule * sch;    // 保存struct schedule指针
    ptrdiff_t cap;            // 上下文切换时保存的栈的容量
    ptrdiff_t size;           // 上下文切换时保存的栈的大小 size <= cap
    int status;               // 协程状态
    char *stack;              // 保存的栈
};

先调用 coroutine_open 来创建一个 schedule 结构体

struct schedule * 
coroutine_open(void) {
    struct schedule *S = malloc(sizeof(*S));  // S 是指针,*S 就是指针指向的结构体。
    S->nco = 0;
    S->cap = DEFAULT_COROUTINE;
    S->running = -1;
    S->co = malloc(sizeof(struct coroutine *) * S->cap);
    memset(S->co, 0, sizeof(struct coroutine *) * S->cap);
    return S;
}

后面调用 coroutine_new 来创建协程,如果当前的协程数目小于容量,直接加进去,否则,扩容为当前的2倍,并返回 id
后面就可以开始 resume 了,内部的实现细节是,先看看要执行的协程的状态是什么,如果是 ready 的话,那就先获取当前的上下文信息到协程的ctc中,设置栈,设置改协程终止时下一个要执行的协程,此处为 &S->main。设置状态为正在执行。设置该上下文指向的函数,此处为 mainfunc,利用 swapcontext 去执行上下文 &C->ctx 并保存当前的上下文信息到 &S->main

总的来说,云风大叔写的代码十分通俗易懂,如有不明白的地方请留言,我将会尽快帮助您解答。