云风基于C的协程库源码分析

2019-06-26  本文已影响0人  DayDayUpppppp

go语言热门起来之后,goroutine 和 协程的概念 也开始流行起来。云风很早的时候在自己的github上面开源了一个用c实现的基于ucontext的协程库,实现的非常简洁,精炼。学习了一下,受益匪浅,在这里做一个整理。
云风 - C实现的基于ucontext的协程库


  1. ucontext api的基本原理和用法
    在正式学习协程之前,先熟悉ucontext用到的几个api,可能会更好的理解函数栈切换的原理。
    先看一个简单的例子:
    #include <stdio.h>
    #include <ucontext.h>
    #include <unistd.h>
    int main(int argc, char *argv[]) {
       ucontext_t context;
       getcontext(&context);       // 将函数栈当前的上下文 写入到context里面
    
       puts("Hello world");
       sleep(1);
    
       setcontext(&context);     // 将context里面记录的函数上下文写入到当前的函数栈里面
       return 0; 
    }
    // 程序的输出是不停的输出 hello world
    

  1. 下面整理了ucontext的数据结构和提供的几个接口的基本功能,大致如下:

#include <stdio.h>
#include <ucontext.h>
#include <unistd.h>
const int MAX_COUNT = 5;

static ucontext_t uc[3];
static int count = 0;

void ping();
void pong();
void ping()
{
    while (count < MAX_COUNT)
    {
        printf("ping %d\n", ++count);
        // yield to pong
        sleep(1);
        swapcontext(&uc[1], &uc[2]); // 保存当前context于uc[1],切换至uc[2]的context运行
        printf("ping -> end \n");
    }
}

void pong()
{
    while (count < MAX_COUNT)
    {
        printf("pong %d\n", ++count);
        // yield to ping
        sleep(1);
        swapcontext(&uc[2], &uc[1]); // 保存当前context于uc[2],切换至uc[1]的context运行
        printf("pong -> end \n");
    }
}

char st1[8192];
char st2[8192];

int main(int argc, char *argv[])
{
    // initialize context
    printf("times %d \n", MAX_COUNT);
    getcontext(&uc[1]);
    getcontext(&uc[2]);

    printf("begin \n");

    uc[1].uc_link = &uc[0];         // 这个玩意表示uc[1]运行完成后,会跳至uc[0]指向的context继续运行
    uc[1].uc_stack.ss_sp = st1;     // 设置新的堆栈
    uc[1].uc_stack.ss_size = sizeof st1;
    makecontext(&uc[1], ping, 0);

    uc[2].uc_link = &uc[0];         // 这个玩意表示uc[2]运行完成后,会跳至uc[0]指向的context继续运行
    uc[2].uc_stack.ss_sp = st2;     // 设置新的堆栈
    uc[2].uc_stack.ss_size = sizeof st2;
    makecontext(&uc[2], pong, 0);

    // 执行协程
    swapcontext(&uc[0], &uc[1]);

    // 将当前context信息保存至uc[0],跳转至uc[1]保存的context去执行
    // 这里我稍微要多说几句,因为我迷惑过,我曾经困惑的一点在于uc[0],为什么uc[0]不需要设置堆栈的信息?
    // 因为swapcontext已经帮我们做好了一切,swapcontext函数会将当前点的信息保存在uc[0]中
    // 当然我们没有设置的话,默认的堆栈一定是主堆栈啦
    printf("end \n");

    return 0;
}

// 程序的输出:
#include <stdio.h>
#include <ucontext.h>
#include <unistd.h>
const int MAX_COUNT = 5;

static ucontext_t uc[3];
static int count = 0;

void ping();
void pong();
void ping()
{
    while (count < MAX_COUNT)
    {
        printf("ping %d\n", ++count);
        // yield to pong
        sleep(1);
        swapcontext(&uc[1], &uc[2]); // 保存当前context于uc[1],切换至uc[2]的context运行
        printf("ping -> end \n");
    }
}

void pong()
{
    while (count < MAX_COUNT)
    {
        printf("pong %d\n", ++count);
        // yield to ping
        sleep(1);
        swapcontext(&uc[2], &uc[1]); // 保存当前context于uc[2],切换至uc[1]的context运行
        printf("pong -> end \n");
    }
}

char st1[8192];
char st2[8192];

int main(int argc, char *argv[])
{
    // initialize context
    printf("times %d \n", MAX_COUNT);
    getcontext(&uc[1]);
    getcontext(&uc[2]);

    printf("begin \n");

    uc[1].uc_link = &uc[0];         // 这个玩意表示uc[1]运行完成后,会跳至uc[0]指向的context继续运行
    uc[1].uc_stack.ss_sp = st1;     // 设置新的堆栈
    uc[1].uc_stack.ss_size = sizeof st1;
    makecontext(&uc[1], ping, 0);

    uc[2].uc_link = &uc[0];         // 这个玩意表示uc[2]运行完成后,会跳至uc[0]指向的context继续运行
    uc[2].uc_stack.ss_sp = st2;     // 设置新的堆栈
    uc[2].uc_stack.ss_size = sizeof st2;
    makecontext(&uc[2], pong, 0);

    // 执行协程
    swapcontext(&uc[0], &uc[1]);

    // 将当前context信息保存至uc[0],跳转至uc[1]保存的context去执行
    // 这里我稍微要多说几句,因为我迷惑过,我曾经困惑的一点在于uc[0],为什么uc[0]不需要设置堆栈的信息?
    // 因为swapcontext已经帮我们做好了一切,swapcontext函数会将当前点的信息保存在uc[0]中
    // 当然我们没有设置的话,默认的堆栈一定是主堆栈啦
    printf("end \n");

    return 0;
}

// 
times 5
begin
ping 1
pong 2
ping -> end
ping 3
pong -> end
pong 4
ping -> end
ping 5
pong -> end
end

看完这个demo,就离我们日常使用的协程中函数栈切换的思路很相似了,实现了在不同函数栈之间的跳转。


再来看另外一个demo:

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

void func1(void * arg)
{
    printf("func1 \n");
}

int main()
{
    char stack[1024*128];
    ucontext_t child,main;
    getcontext(&child); //获取当前上下文

    child.uc_stack.ss_sp = stack;//指定栈空间
    child.uc_stack.ss_size = sizeof(stack);//指定栈空间大小
    child.uc_stack.ss_flags = 0;
    child.uc_link = &main;//设置后继上下文
    makecontext(&child,(void (*)(void))func1,0);//设置child协程执行func1函数

    swapcontext(&main,&child);//保存当前上下文到main,执行child上下文,因为child上下文后继是main,所以执行了func1函数后,会回到此处
    printf("main \n");//如果设置了后继上下文,func1函数指向完后会返回此处
    return 0;
}

程序的输出是:
$ ./a.out      
func1 
main 

基于上一个demo,下面做一个小的修改,把main改成context2,添加一个 getcontext(&main);看看会发生什么?

int main()
{
    char stack[1024*128];
    ucontext_t child,main,context2;

    // add
    getcontext(&main);
    printf("test hahah \n");

    getcontext(&child);                 //获取当前上下文
    child.uc_stack.ss_sp = stack;       //指定栈空间
    child.uc_stack.ss_size = sizeof(stack);     //指定栈空间大小
    child.uc_stack.ss_flags = 0;
    child.uc_link = &main;              //设置后继上下文
    printf("test \n");

    makecontext(&child,(void (*)(void))func1,0);

    // 这里改成context2
    swapcontext(&context2,&child);      //保存当前上下文到context2, 执行child上下文,因为child上下文后继是main,所以执行了func1函数后,会回到此处
    puts("main");                       //如果设置了后继上下文,func1函数指向完后会返回此处
    return 0;
}

这段代码执行的输出是:

$ ./a.out
test hahah
test
func1
test hahah
test
func1

...

test hahah
test
func1
test hahah


  1. 基于ucontext的云风的协程库

上面铺垫了几个api的用法和实现函数栈切换的基本原理。下面再来看云风的协程库,就不难理解他的思路了。云风的协程库是一个非对称的 有栈 协程库,协程之间切换的逻辑是:

# 非对称的协程的切换是要回到main函数的
co1 --> yeild  -->  main  --> co2  ---> yeild -->
  1. 我们首先基于 coroutine 的例子来讲下 coroutine 的基本使用。
struct args {
    int n;
};

static void foo(struct schedule * S, void *ud) {
    struct args * arg = ud;
    int start = arg->n;
    int i;
    for (i=0;i<5;i++) {
        printf("coroutine %d : %d\n",coroutine_running(S) , start + i);
        // 切出当前协程
        coroutine_yield(S);
    }
}

static void test(struct schedule *S) {
    struct args arg1 = {0};
    struct args arg2 = {100};

    // 创建两个协程
    int co1 = coroutine_new(S, foo, &arg1);
    int co2 = coroutine_new(S, foo, &arg2);

    printf("main start\n");
    while (coroutine_status(S,co1) && coroutine_status(S,co2)) {
        // 使用协程 co1
        coroutine_resume(S,co1);
        // 使用协程 co2
        coroutine_resume(S,co2);
    }
    printf("main end\n");
}

int main() {
    // 创建一个协程调度器
    struct schedule * S = coroutine_open();

    test(S);

    // 关闭协程调度器
    coroutine_close(S);

    return 0;
}

接下来,会从这几点出发,分析 coroutine 的原理。

整个状态机的流程:


image.png image.png

因此整个栈的大小就是从栈底到栈顶,S->stack + STACK_SIZE - &dummy。
最后又调用了 memcpy 将当前运行时栈的内容,拷贝到了 C->stack 中保存了起来。


// 协程调度器
struct schedule {
    char stack[STACK_SIZE];
    ucontext_t main;        // 正在running的协程在执行完后需切换到的上下文,由于是非对称协程,所以该上下文用来接管协程结束后的程序控制权
    int nco;                // 调度器中已保存的协程数量
    int cap;                // 调度器中协程的最大容量
    int running;            // 调度器中正在running的协程id
    struct coroutine **co;  // 连续内存空间,用于存储所有协程任务
};

// 协程任务类型
struct coroutine {
    coroutine_func func;    // 协程函数
    void *ud;               // 协程函数的参数(用户数据)
    ucontext_t ctx;         // 协程上下文
    struct schedule * sch;  // 协程所属的调度器
    // ptrdiff_t定义在stddef.h(cstddef)中,通常被定义为long int类型,通常用来保存两个指针减法操作的结果.
    ptrdiff_t cap;          // 协程栈的最大容量
    ptrdiff_t size;         // 协程栈的当前容量
    int status;             // 协程状态(COROUTINE_DEAD/COROUTINE_READY/COROUTINE_RUNNING/COROUTINE_SUSPEND)
    char *stack;            // 协程栈
};


// 创建协程调度器schedule
struct schedule * coroutine_open(void) {
    struct schedule *S = malloc(sizeof(*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;
}


其他背景知识

这里考虑的是函数栈,就只分析stack。


上一篇 下一篇

猜你喜欢

热点阅读