go语言热门起来之后,goroutine 和 协程的概念 也开始流行起来。云风很早的时候在自己的github上面开源了一个用c实现的基于ucontext的协程库,实现的非常简洁,精炼。学习了一下,受益匪浅,在这里做一个整理。云风的协程库github仓库地址。
协程栈切换的原理是基于函数栈的跳转,关于函数调用的基本原理整理在这里做一个简单的背景知识整理。了解函数切换的基本原理,可以看这里 : 函数栈切换的基本原理
- 协程的几种切换函数栈的思路:
- 基于ucontext实现函数栈跳转,类似于云风的这个库
- 基于汇编实现函数栈跳转,类似于libco
- 无栈协程,基于switch-case的切换
- 基于setjump,longjump的函数栈跳转
- 协程的分类
-
根据调度方式的不同,协程可以分为对称协程和非对称协程,其主要区别在于:
- 对称协程:任何一个协程都是相互独立且平等的,执行权可以在任意协程之间转移,比如 A 协程调用了 B 协程后,A 协程与 B 协程之后就没有任何关系了,B 协程让出执行权时,该执行权最终花落谁家都有可能;
- 非对称协程:协程让出执行权的目标只能是它的调用者,即协程之间存在调用和被调用关系,比如 A 协程调用了 B 协程后,B 协程当需要让出执行权时一定是将执行权给了 A 协程。
-
根据运行时栈的不同协程可以分为有栈协程和无栈协程,其主要区别在于:
- 有栈协程:协程上下文和变量等相关信息均保存在调用栈上;
- 无栈协程:协程上下文和变量等相关信息保存在某个对象中,并不维护在调用栈上,比如维护在闭包中。
下面提到的云风ucontext的协程和libco都是有栈非对称协程。
ucontext的基本原理和用法
在正式学习协程之前,先熟悉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 是:字段保存当前函数结束后继续执行的函数; # uc_sigmask 是:记录该context运行阶段需要屏蔽的信号; # uc_stack 是:函数运行栈的位置; # 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, ...) # makecontext修改通过getcontext取得的上下文ucp # (这意味着调用makecontext前必须先调用getcontext)。 # 然后给该上下文指定一个栈空间ucp->stack,设置后继的上下文ucp->uc_link. # 当上下文通过setcontext或者swapcontext激活后,执行func函数,argc为func的参数个数,后面是func的参数序列。 # 当func执行返回后,继承的上下文被激活,如果继承上下文为NULL时,线程退出。
-
swapcontext 函数
int swapcontext(ucontext_t *oucp, ucontext_t *ucp) # 保存当前上下文到oucp结构体中,然后激活upc上下文。 # 需要注意的是,被保存的状态 EIP 是这个函数返回后的下一条语句
简单说来, getcontext获取当前上下文,setcontext设置当前上下文,swapcontext切换上下文,makecontext创建一个新的上下文。
执行一个简单的例子参考一下
#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() {
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[]) {
// 初始化uc1和uc2 供后续makecontext调用
getcontext(&uc[1]);
getcontext(&uc[2]);
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);
// 执行协程 main存入uc[0],执行uc[1]
// uc[1]对应的函数是 ping,于是代码被切换到ping
swapcontext(&uc[0], &uc[1]);
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] 的函数栈。
再来看另外一个例子:
#include <stdio.h>
#include <ucontext.h>
#include <unistd.h>
void func1(void * arg) {
printf("do something \n");
}
int main() {
char stack[1024*128];
ucontext_t child,main,context2;
// add
getcontext(&main);
printf("begin\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("begin make ctx \n");
makecontext(&child,(void (*)(void))func1,0);
// 这里改成context2
swapcontext(&context2,&child); //保存当前上下文到context2, 执行child上下文,因为child上下文后继是main,所以执行了func1函数后,会回到此处
printf("end"); //如果设置了后继上下文,func1函数指向完后会返回此处
return 0;
}
这段代码执行的输出是:
$ ./a.out
begin
begin make ctx
do something
...
begin
begin make ctx
do something // 不停的循环输出
这段程序的运行结果是,在不停的循环打印。为什么会出现这样情况呢? 是因为 执行child时(执行了func函数)func函数结束执行child的后继上下文是main。而此时main的镜像是在 getcontext(&main)这段代码记录的。区别上一段代码,swapcontext的时候,没有把此刻的镜像记录在main里面,而是记录在context2上了。所以,当func执行完毕的时候,去加载mian的镜像,程序重新跳转到了入口的地方重新执行。于是,程序的输出就是死循环了。
- 小结一下:
看完这几个例子就离我们日常使用的协程中函数栈切换的思路很相似了,实现了在不同函数栈之间的跳转。context类似于一个镜像,getcontext和makecontext 两个接口负责构造镜像;setcontext 和 swapcontext 负责转载 & 执行镜像。有了这个功能,就基本实现了函数栈之间的跳转,实现了协程功能的第一步。**
基于ucontext的云风的协程库
上面铺垫了几个api的用法和实现函数栈切换的基本原理。下面再来看云风的协程库,就不难理解他的思路了。云风的协程库是一个非对称的共享栈协程库,协程之间切换的逻辑是:
# 非对称的协程的切换是要回到main函数的
协程1 --> yeild(让出控制权)--> main (主协程) -->
--> resume (调度其他协程)--> 协程2 ---> yeild (让出控制权)-> ...
非对称式协程之所以被称为非对称的,是因为它提供了两种传递程序控制权的操作:
resume -> 调用协程
yield -> 挂起协程并将程序控制权返回给协程的调用者
基于 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); 切出该协程
程序的流程大致如下:
- resume 第一次进去协程(创建协程之后,第一次resume,从ready的状态 进入 runing的状态)
- yield 协程挂起(从running 的状态 进入 suspend 状态)
- 再次resume (再次切入协程,继续执行)
云风的代码写的很精简和巧妙,几个核心的地方:
-
什么是共享栈 和 非共享栈?
非共享栈 :是每个协程单独开辟一块固定大小的空间,用于存放自己的栈数据。这样带来的好处是,每次切换协程的时候,不需要对栈进行拷贝(只需要切换rbp 和 rsp,让它指向需要要被调度执行的协程的栈空间即可)。非共享栈被认为缺点的是,占用内存会更多。在实际中,栈的内存使用其实不多,如果每个栈128K内存,1w个协程才需要1G多的内存。共享栈协程 :是每个协程在要被切出的时候,开辟一块内存空间,然后把栈(rsp 到 rbp 的长度,在云风实现的库中利用dummy的变量在计算长度)拷贝到申请的内存上。这样的好处是内存的占用会合理(不会分配过多)。缺点是:协程暂停时,需要把用到的栈内存暂时保存起来,等要运行,再把保存的栈内存拷贝回协程执行的栈(有申请,拷贝和切换的成本)。
共享栈的思路就是动态内存分配,栈用多少内存就malloc多少内存给到使用(聪明的你一定会问,那怎么知道当前函数的栈用了多少空间呢?后面会提到dummy变量的作用,就是解决这个问题的)。而不是固定每个栈128k。不过,频繁切换协程的时候,就会带来频繁malloc各种小内存。似乎libco也提供了不用共享栈的方式,也许是为了解决这种问题。
不过,无栈协程就可以比较完美的解决这个问题了。它的原理是利用 “达夫-device” (duff device),设计了一个switch-case的状态机,函数栈切换保存的不是具体的数据状态,而是上次执行到了那个switch-case的分支。下次切换回来继续从这里执行。因此,就不需要额外的空间保存状态了,也就叫无栈协程了。
云风实现其实这里写的比较复杂,我基于这个思路自己实现了一个非共享栈的协程库 > 代码地址
-
dummy 变量的作用
协程在切换的时候如何保存当前协程的栈空间?这里用了一个很巧妙的trick。
要如何找到当前的栈,简单的来说就是如何找到栈低和栈顶。这里已经通过uc_stack执行了函数的运行栈的空间。(此时函数已经把栈空间建立在自定义的stack上面了)
C->ctx.uc_stack.ss_sp = S->stack; C->ctx.uc_stack.ss_size = STACK_SIZE;
栈的生长方向是高地址到低地址,因此栈底的就是内存地址最大的位置,即 S->stack + STACK_SIZE 就是栈底位置。所以,栈低的位置已经知道了是 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);
}
好奇看了一下libco的是怎么解决这个问题了,果然libco也借鉴了这个设计。libco在共享栈模式下的栈切换,对应的代码如下:
剩下的代码就很简单了。
除此之外,libco基于协程,改造了一些基础的系统调用,让他们异步化,比如改造read函数。这里有很有意思的一点,就是如何hook这些函数。
基本有两个思路:
- 静态库的 hook
如果我们在静态链接下 hook 掉一个函数,很简单,只要定义一个同样的函数,然后静态链接进来就好了。当然这种直接方法只适合被 hook 的函数是以动态库的方式暴露出去的,比如 linux 系统库。
-
动态库 hook
动态库的 hook 原理是 libco 分析文章中经常被讲解到,其使用的是动态链接时如果发现一个函数不存在,就会按照一定的顺序去查找 so 动态库文件的这个策略:谁是第一个被找到的,谁就会被认可,越先被扫描的 so 文件就会先被加载。所以实现 hook 的方法就是控制查找 so 动态库文件的顺序,比如 linux 下的 LD_LIBRARY_PATH 环境变量就可以控制 so 文件加载顺序,或者是编译时通过 -L 参数来指定查找目录等,你可以通过该文章了解加载路径优先级。
对于要hook动态库里面的函数,可以使用LD_PRELOAD提前加载一个新的so,新的库里面实现了要hook的函数。那这样就会优先查新的so,从新的so找到函数的符号,实现hook。
还有一种方式,是运行时的hook。可以使用动态库运行时加载的 API,
dlsym
来查找到第二个同名符号的地址dlsym(RTLD_NEXT, function_name);
其他背景知识
函数栈是如何调用的
https://www.zhihu.com/column/printf
https://vinsflyfish.github.io/posts/thinking-in-libco/函数栈调用的过程:
https://zhuanlan.zhihu.com/p/54984757
https://zhuanlan.zhihu.com/p/54954221-
其他
学习的时候,顺便参考对比了一下libco。libco除了实现了协程切换之外,还可以hook的系统函数,比如read,write这类,可以把底层的同步逻辑轻松的接入到协程的框架里面。整理一下libco里面hook的用法。
以read为例,这里给read的fd添加到了epoll中,监听fd是否可读。插入到epoll之后,逻辑就返回了。
当fd可读的时候,会触发poll,poll会触发协程切出,实现hook底层函数。等真正客可读的时候,调用g_sys_read_func函数(系统本身的read函数)返回给上层。