美文网首页
云风基于C的协程库源码分析

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

作者: DayDayUpppppp | 来源:发表于2019-06-26 22:59 被阅读0次

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
    
  • 这段代码的逻辑就是不停的输出hello world,原因也很简单,就是getcontext函数相当于把当前函数栈记录到了context里面,当代码执行到setcontext的时候,程序又加载了之前记录的函数栈,于是代码又跳转到了入口的地方,继续向下执行。于是,就是不停的输入hello world。虽然这是一个小demo,但是这里表现出协程的一个很重要的特性函数栈跳转

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

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

    uc_link 是:字段保存当前context结束后继续执行的context记录;
    uc_sigmask 是:记录该context运行阶段需要屏蔽的信号;
    uc_stack 是:该context运行的栈信息;
    uc_mcontext 是:保存具体的程序执行上下文(如PC值、堆栈指针、寄存器值等信息)其实现方式依赖于底层运行的系统架构,是平台、硬件相关的;

  • getcontext 函数

    int getcontext(ucontext_t *ucp)
    # 这个函数的功能是把当前的上下文保存到ucp指针所指向的空间中
    
  • setcontext 函数

    int setcontext(const ucontext_t *ucp)
    # 这个函数的功能是将当前程序执行切换到参数ucp所指向的上下文状态
    
  • makecontext 函数

    int makecontext(ucontext_t *ucp, void (*func)(), int argc, ...) 
    # make 函数的功能和getcontext的功能类似,也是负责构造context。
    # func指明了setcontext的时候的入口函数(也是就setcontext立刻执行的函数)
    # argc指明了函数的参数 
    # 注意,makecontext只是负责构造context,但是并没有加载和执行context。
    
  • swapcontext 函数

    int swapcontext(ucontext_t *oucp, ucontext_t *ucp)
    # 保存当前的上下文到oucp,然后执行ucp对应的上下文。
    # swapcontext = getcontext + setcontext
    # 第一步把当前的上下文,使用getcontext保存到oucp中
    # 第二步把要执行的上下文,使用setcontext去执行
    

  • 跑一个简单的edemo看一下
#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
  • 这段代码的逻辑是:
    实现uc1和uc2的函数栈切换,实现在ping 和 pong 函数之间的跳转。main函数栈->保存到uc[0]->切换到uc[1] (执行ping函数)->切换到uc[2] (执行pong函数) ,然后再uc[1] 和 uc[2] 之间相互切换。最后,不满足while的条件的时候退出。当uc[1] 结束退出,去执行uc[0] 的函数栈。
  • 如果执行完上下文uc1和uc2的时候,不切换到uc0,切换到其他的uc会怎么样?
    可以看这个例子:
    demo2.cpp

  • 如果执行这个例子的话,会发现不停的输出begin。这是为什么呢?可以看下一个例子:
    demo3.cpp

看完这个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

  • 这段程序的运行结果是,在不停的循环打印。为什么会出现这样情况呢? 是因为 执行child时(执行了func函数)func函数结束执行child的后继上下文是main。而此时main的镜像是在 getcontext(&main)这段代码记录的。区别上一段代码,swapcontext的时候,没有把此刻的镜像记录在main里面,而是记录在context2上了。所以,当func执行完毕的时候,去加载mian的镜像,程序重新跳转到了入口的地方重新执行。于是,程序的输出就是死循环了。


  • 小结一下:
    context类似于一个镜像,getcontext和makecontext 两个接口负责构造镜像;setcontext 和 swapcontext 负责转载 & 执行镜像。有了这个功能,就基本实现了函数栈之间的跳转,实现了协程功能的第一步。


  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_open 创建了协程调度器 S,用来统一管理全部的协程。同时在 test 函数中,创建了两个协程 co1 和 co2,不断的反复 yield 和 resume 协程,直至两个协程执行完毕。

  • 可以看出,最核心的几个对象和函数是:

    1. struct schedule* S 协程调度器
    2. coroutine_resume(S,co1); 切入该协程
    3. coroutine_yield(S); 切出该协程

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

  • 阅读源码的几个点
    1. resume 第一次进去协程(创建协程之后,第一次resume,从ready的状态 进入 runing的状态)
    2. yield 协程挂起(从running 的状态 进入 suspend 状态)
    3. 再次resume (再次切入协程,继续执行)

整个状态机的流程:


image.png
  • dummy 变量的作用
    在 coroutine 中,因为协程的运行时栈的内存空间是自己分配的。在 coroutine_resume 阶段设置了 C->ctx.uc_stack.ss_sp = S.S->stack。根据以上理论,栈的生长方向是高地址到低地址,因此栈底的就是内存地址最大的位置,即 S->stack + STACK_SIZE 就是栈底位置。
    // 调用
    void _save_stack(C,S->stack + STACK_SIZE);
    
    // 定义
    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;
      // 从 dummy 拷贝 size 内存到 C->stack
      memcpy(C->stack, &dummy, C->size);
    }
    
    这个 dummy 的作用非常关键也非常巧妙,大家可以细细体会下。因为 dummy 变量是刚刚分配到栈上的,此时就位于 栈的最顶部位置。整个内存布局如下图所示:
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;
}

  • 共享栈的原理
    共享栈对标的是非共享栈,也就是每个协程的栈空间都是独立的,固定大小。好处是协程切换的时候,内存不用拷贝来拷贝去。坏处则是 内存空间浪费。

    因为栈空间在运行时不能随时扩容,为了防止栈内存不够,所以要预先每个协程都要预先开一个足够的栈空间使用。当然很多协程用不了这么大的空间,就必然造成内存的浪费。

    共享栈则是提前开了一个足够大的栈空间 (coroutine 默认是 1M)。所有的栈运行的时候,都使用这个栈空间。

    conroutine 是这么设置每个协程的运行时栈:

    C->ctx.uc_stack.ss_sp = S->stack;
    C->ctx.uc_stack.ss_size = STACK_SIZE;
    

    对协程调用 yield 的时候,该协程栈内容暂时保存起来,保存的时候需要用到多少内存就开多少,这样就减少了内存的浪费。(即_save_stack 函数的内容)。当 resume 该协程的时候,协程之前保存的栈内容,会被重新拷贝到运行时栈中。


其他背景知识
  • 一个协程的栈多大?协程切换的开销在哪里? 线程的切换的开销在哪里?线程的栈有多大?

    协程栈:128K
    线程的栈:8M

    ulimit -a # 查看线程栈
    
  • 为什么要使用协程? 协程可以干什么?
    简单的来说,就是改造异步的逻辑。写了一个小例子: https://github.com/zhaozhengcoder/coroutine/blob/master/test_demo1.c

  • 云风协程库的注释版本
    https://github.com/zhaozhengcoder/coroutine

  • libco 协程库
    微信团队开源的一个协程库,实现的思路不同于这里提到的云风的协程库,这里先挖个坑,以后补上。

  • linux 内存空间的布局

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

  • stack

    1. 函数stack是从高地址向下增长
    2. stack有编译器来分配,它的数据结构是一个栈
    3. stack的大小是一般是8M
    Linux中 ulimit -s 命令可查看和设置堆栈最大值
    

    当函数进行调用的时候,这个栈是如何变化的?

    1. 每个函数栈有两个指针,一个是栈底(栈帧指针EBP),一个是栈顶指针(ESP),一个函数栈的大小是位于这两个指针之间(ESP, EBP 是两个寄存器,里面存放这指向栈底和栈顶的指针)。

    2. 函数调用的

    • 第零步就是把调用函数的参数压栈

    • 第一步就是把当前函数的返回地址压栈

    • 第二步是把把原来的EBP压栈,然后更新型的EBP(指向func函数的 EBP)

    • 第三步是ESP(func函数的ESP),创建变量,然后不停的放到里面。



相关文章

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

    go语言热门起来之后,goroutine 和 协程的概念 也开始流行起来。云风很早的时候在自己的github上面开...

  • 浅析State-Thread

    State-Thread(以下简称st),是一个由C语言编写的小巧、简洁却高效的开源协程库。这个库基于单线程运作、...

  • Libco协程库实现

    腾讯开源的Libco协程库,以前看过部分源码,所有的协程都用数组模拟栈表示,里面使用到的技术点有hook系统函数,...

  • Libco协程库实现(二)

    这里补充下libco后续对于协程间切换的汇编新实现,原来的实现方法之前分析过Libco协程库实现,早期分析的时候有...

  • 【python】协程:协程与生成器的对比、激活协程、终止协程和异

    基于生成器的协程 生成器可以作为协程(coroutine)使用,称为 "基于生成器的协程"。协程和生成器类似,都是...

  • Unity3D协程介绍

    协程介绍 Unity的协程系统是基于C#的一个简单而强大的接口,IEnumerator,它允许你为自己的集合...

  • Coroutines in C++20

    首先,希望读者已经在其他语言或库中了解协程的概念。C++20 终于带来了官方的协程,这是一种无栈的协程实现。 pr...

  • Mix PHP V2 新特性:协程、定时器

    协程 Mix PHP V2 基于 Swoole 4 的 PHP Stream Hook 协程技术开发,协程使用方式...

  • Python协程

    目录:一、基于生成器的协程二、协程状态三、协程预激装饰器四、终止协程和异常处理五、协程返回值六、yield fro...

  • 协程库 gevent

    gevent,它是一个并发网络库。它的协程是基于greenlet的,并基于libev实现快速事件循环(Linux上...

网友评论

      本文标题:云风基于C的协程库源码分析

      本文链接:https://www.haomeiwen.com/subject/atbqcctx.html