go语言热门起来之后,goroutine 和 协程的概念 也开始流行起来。云风很早的时候在自己的github上面开源了一个用c实现的基于ucontext的协程库,实现的非常简洁,精炼。学习了一下,受益匪浅,在这里做一个整理。
云风 - C实现的基于ucontext的协程库
- 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,但是这里表现出协程的一个很重要的特性函数栈跳转。
- 下面整理了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 负责转载 & 执行镜像。有了这个功能,就基本实现了函数栈之间的跳转,实现了协程功能的第一步。
- 基于ucontext的云风的协程库
上面铺垫了几个api的用法和实现函数栈切换的基本原理。下面再来看云风的协程库,就不难理解他的思路了。云风的协程库是一个非对称的 有栈 协程库,协程之间切换的逻辑是:
# 非对称的协程的切换是要回到main函数的
co1 --> yeild --> main --> co2 ---> yeild -->
- 我们首先基于 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 协程,直至两个协程执行完毕。
-
可以看出,最核心的几个对象和函数是:
- struct schedule* S 协程调度器
- coroutine_resume(S,co1); 切入该协程
- coroutine_yield(S); 切出该协程
接下来,会从这几点出发,分析 coroutine 的原理。
- 阅读源码的几个点
- resume 第一次进去协程(创建协程之后,第一次resume,从ready的状态 进入 runing的状态)
- yield 协程挂起(从running 的状态 进入 suspend 状态)
- 再次resume (再次切入协程,继续执行)
整个状态机的流程:
![](https://img.haomeiwen.com/i4717565/488062220f816301.png)
- dummy 变量的作用
在 coroutine 中,因为协程的运行时栈的内存空间是自己分配的。在 coroutine_resume 阶段设置了 C->ctx.uc_stack.ss_sp = S.S->stack。根据以上理论,栈的生长方向是高地址到低地址,因此栈底的就是内存地址最大的位置,即 S->stack + STACK_SIZE 就是栈底位置。
这个 dummy 的作用非常关键也非常巧妙,大家可以细细体会下。因为 dummy 变量是刚刚分配到栈上的,此时就位于 栈的最顶部位置。整个内存布局如下图所示:// 调用 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); }
![](https://img.haomeiwen.com/i4717565/9388aa1275882f01.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
线程的栈:8Mulimit -a # 查看线程栈
-
为什么要使用协程? 协程可以干什么?
简单的来说,就是改造异步的逻辑。写了一个小例子: https://github.com/zhaozhengcoder/coroutine/blob/master/test_demo1.c
-
云风协程库的注释版本
https://github.com/zhaozhengcoder/coroutine -
libco 协程库
微信团队开源的一个协程库,实现的思路不同于这里提到的云风的协程库,这里先挖个坑,以后补上。 -
linux 内存空间的布局
这里考虑的是函数栈,就只分析stack。
-
stack
- 函数stack是从高地址向下增长
- stack有编译器来分配,它的数据结构是一个栈
- stack的大小是一般是8M
Linux中 ulimit -s 命令可查看和设置堆栈最大值
当函数进行调用的时候,这个栈是如何变化的?
-
每个函数栈有两个指针,一个是栈底(栈帧指针EBP),一个是栈顶指针(ESP),一个函数栈的大小是位于这两个指针之间(ESP, EBP 是两个寄存器,里面存放这指向栈底和栈顶的指针)。
-
函数调用的
-
第零步就是把调用函数的参数压栈
-
第一步就是把当前函数的返回地址压栈
-
第二步是把把原来的EBP压栈,然后更新型的EBP(指向func函数的 EBP)
-
第三步是ESP(func函数的ESP),创建变量,然后不停的放到里面。
![](https://img.haomeiwen.com/i4717565/876340e43e4dd973.png)
![](https://img.haomeiwen.com/i4717565/55b626007a3e2216.png)
![](https://img.haomeiwen.com/i4717565/9375111766b5dc48.png)
- 参考:
ucontext api的分析blog1: https://illx.ink/article/17
ucontext api的分析blog2: https://www.jianshu.com/p/dfd7ac1402f0
linux 进程空间 : https://www.cnblogs.com/clover-toeic/p/3754433.html
linux 进程空间 : https://www.jianshu.com/p/f9760cb3cea2
网友评论