协程分类
对称协程与非对称协程
协程按概念分为对称协程、非对称协程,对称协程指的是协程a可任意跳转到协程b/c/d,所有的协程都是相同的,可任意跳转,称为对称协程。
非对称协程则是有类似函数调用栈的概念,如协程a调用了协程b,那么协程b只能跳转到协程a,返回到上一级,这种称为非对称协程。
有栈线程与无栈协程
协程按实现可分为有栈协程和无栈协程,顾名思义,有栈协程利用栈实现的,无栈协程则不需要利用协程栈,而是采用状态机等方法实现。
其中有栈协程还可分为共享栈(即所有协程用同一个栈)和独立栈(每个协程一个栈)。
这里讨论有栈协程的独立栈实现。
实现原理简述
我们知道,在函数执行过程中,其实就是在一块栈空间内运行罢了。这个栈空间由rsp、rbp指针来指定两端范围。函数中的局部变量的都会存放在栈中某一块内存中。函数调用无非是从一个函数栈跳转到相邻另一个函数栈罢了,只是由于调用返回后还需恢复原函数栈的状态,因此必须在调用时通过寄存器和栈空间的配合来存储一些数据,方便调用完成后恢复。这一点需要明确下,具有调用关系的两个函数栈空间一定是相邻的,也就是说主调方的函数栈空间与被调方的函数栈空间一点是相邻的。
协程实在函数间的一种跳转,这意味着:
如果我们保存了当前函数func_a的上下文,即当前函数的栈寄存器(rsp、rip),参数寄存器(rax rbx… rdi rsi)等寄存器,以及程序计数器(rip),那么假设我们现在跳转到了func_b执行,在func_b的执行中,我们将之前保存的寄存器内容写到原寄存器中,那么我们就跳转到了原func_a的执行上下文。
这是不是很类似与协程的跳转,没错,其实有栈协程就是这么实现的,这里有两个问题需要解决:
1:当func_b跳转到func_a后,func_a继续调用其他函数,继续使用栈空间,不就把原来的func_b的栈空间冲掉了吗,无法再跳转到func_b了。
2:如何获取这些寄存器值,如何写入呢。
其实linux以及为我们做好了,使用ucontenxt函数族即可。
ucontext函数族
简介
ucontext.h是GNU C库的一个头文件,主要用于用户态下的上下文切换。
结构
ucontext.h有两个比较重要的结构体,分别是mcontext_t和ucontext_t,其中mcontext_t中主要保存了上下文的各种寄存器信息,因此一般情况下不会修改mcontext_t的信息。
ucontext_t主要需要关注的字段如下:
typedef struct ucontext_t {struct ucontext_t *uc_link;stack_t uc_stack;mcontext_t uc_mcontext;sigset_t uc_sigmask;
} ucontext_t;
//其中mcontext_t 定义如下
typedef struct{gregset_t __ctx(gregs);//所装载寄存器fpregset_t __ctx(fpregs);//寄存器的类型
} mcontext_t;//其中gregset_t 定义如下
typedef greg_t gregset_t[NGREG];//包括了所有的寄存器的信息typedef struct {void *ss_sp;int ss_flags;size_t ss_size;
} stack_t;
uc_link指向一个上下文,当当前上下文结束时,将返回执行该上下文。sigset_t当上下文被激活时,被屏蔽的信号集合。stack_t栈消息,具体结构如下所示。uc_mcontext保存了上下文的各种寄存器信息。
getcontext(ucontext_t* ucp)
功能:将当前运行到的寄存器的信息保存在参数ucp中, 即我们之前所提到的将各个寄存器写入ucp的对应字段中,
可认为调用过后,uc_context就保存了当前上下文的所需寄存器值。
那么如何实现跳转呢,使用setcontext函数
setcontext
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <ucontext.h>int main()
{int i = 0;ucontext_t ctx;//定义上下文结构体变量getcontext(&ctx);//获取当前上下文printf("i = %d\n", i++);sleep(1);setcontext(&ctx);//回复ucp上下文return 0;
}
在getcontext(&ctx);中,我们会将下一条执行的指令环境保存到结构体ctx中,也就是printf(“i = %d\n”, i++)指令。然后运行到setcontext(&ctx)时就会将ctx中的指令回复到cpu中,所以该代码就是让cpu去运行ctx所保存的上下文环境,所以又回到了打印的那一行代码中,所以运行是一个死循环,而i值不变是因为i是存在内存栈中的,不是存在寄存器中的,所以切换并不影响i的值
这已经初步有了协程的感觉,但是协程需要的是跳转的其他函数,这该如何做到(其实就是修改rip,创建栈)
makecontext
函数签名:void makecontext(ucontext_t *ucp, void (*func)(), int argc, …)
功能:修改上下文信息,参数ucp就是我们要修改的上下文信息结构体;func是上下文的入口函数;argc是入口函数的参数个数,后面的…是具体的入口函数参数,该参数必须为整形值
示例:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <ucontext.h>void fun()
{printf("fun()\n");
}int main()
{int i = 0;//定义用户的栈char* stack = (char*)malloc(sizeof(char)*8192);//定义两个上下文//一个是主函数的上下文,一个是fun函数的上下文ucontext_t ctx_main, ctx_fun;getcontext(&ctx_main);getcontext(&ctx_fun);printf("i = %d\n", i++);sleep(1);//设置fun函数的上下文//使用getcontext是先将大部分信息初始化,我们到时候只需要修改我们所使用的部分信息即可ctx_fun.uc_stack.ss_sp = stack;//用户自定义的栈ctx_fun.uc_stack.ss_size = 8192;//栈的大小ctx_fun.uc_stack.ss_flags = 0;//信号屏蔽字掩码,一般设为0ctx_fun.uc_link = &ctx_main;//该上下文执行完后要执行的下一个上下文makecontext(&ctx_fun, fun, 0);//将fun函数作为ctx_fun上下文的下一条执行指令setcontext(&ctx_fun);printf("main exit\n");return 0;
}
在代码中,我们为ctx_fun指定了跳转指定时使用的栈(即malloc的空间,即设置了rsp的地址),这样就不会影响到原来的栈,使用makecontext函数,指定了跳转到地址(即设置了rip的值)
当执行到setcontext(&ctx_fun)代码时会去运行我们之前makecontext时设置的上下文入口函数所以在打印i完后会打印fun(),然后我们设置ctx_fun上下文执行完后要执行的下一个上下文是ctx_main,所以执行完后会执行到getcontext(&ctx_fun),所以最后也是一个死循环。
运行流程如上图,运行结果:
这里已经很接近协程了,同时也解决了在协程章节的问题:
Q1:当func_b跳转到func_a后,func_a继续调用其他函数,继续使用栈空间,不就把原来的func_b的栈空间冲掉了吗,无法再跳转到func_b了。
A:为rsp指定一个新空间,避免使用原来的栈空间就可以避免破坏其他栈数据。
Q2:如何获取这些寄存器值,如何写入呢。
A: 使用getcontext函数获取,在其变量中指定新的栈地址(rsp),新的执行地址(rip), 使用setcontext()写入完成跳转。
再看复杂一点的例子
swapcontext
int swapcontext(ucontext_t *oucp, ucontext_t *ucp), 将当前cpu中的上下文信息保存带oucp结构体变量中,然后将ucp中的结构体的上下文信息恢复到cpu中
这里可以理解为调用了两个函数,第一次是调用了getcontext(oucp)然后再调用setcontext(ucp)
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <ucontext.h>ucontext_t ctx_main, ctx_f1, ctx_f2;void fun1()
{printf("fun1() start\n");swapcontext(&ctx_f1, &ctx_f2);printf("fun1() end\n");
}void fun2()
{printf("fun2() start\n");swapcontext(&ctx_f2, &ctx_f1);printf("fun2 end\n");
}int main()
{char stack1[8192];char stack2[8192];getcontext(&ctx_f1);//初始化ctx_f1getcontext(&ctx_f2);//初始化ctx_f2ctx_f1.uc_stack.ss_sp = stack1;ctx_f1.uc_stack.ss_size = 8192;ctx_f1.uc_stack.ss_flags = 0;ctx_f1.uc_link = &ctx_f2;makecontext(&ctx_f1, fun1, 0);//设置上下文变量ctx_f2.uc_stack.ss_sp = stack2;ctx_f2.uc_stack.ss_size = 8192;ctx_f2.uc_stack.ss_flags = 0;ctx_f2.uc_link = &ctx_main;makecontext(&ctx_f2, fun2, 0);//保存ctx_main的上下文信息,并执行ctx_f1所设置的上下文入口函数swapcontext(&ctx_main, &ctx_f1);printf("main exit\n");return 0;
}
运行结果:定义三个上下文变量,ctx_main、ctx_f1、ctx_f2。
当执行到swapcontext(&ctx_main, &ctx_f1)时会执行fun1函数,然后打印fun1() start。再执行swapcontext(&ctx_f1, &ctx_f2),也就是保存ctx_f1的上下文,然后去执行ctx_f2的上下文信息,也就是fun2函数,所以会打印fun2() start。
执行到swapcontext(&ctx_f2, &ctx_f1);是会切换到fun1当时切换时的上下文环境,此时会打印fun1() end,ctx_f1上下文执行完后会执行之前设置的后继上下文,也就是ctx_f2,所以会打印fun2 end。fun2函数执行完会执行ctx_f2的后继上下文,其后继上下文为ctx_main,而此时的ctx_main的下一条指令就是printf(“main exit\n”),所以会打印main exit
这里其实就很接近协程的调用了,一些协程库也的确以此封装而成。
总结
可见有栈协程的原理并不算高深,就是保存寄存器,单独弄个栈空间赋值进去,然后调用系统调用,将新的栈地址、程序计数器写入CPU。这里每个协程都有单独的栈空间,这样使用倒是很方便,就是有点浪费,由此引入了共享栈,共享栈其实也不神秘,即所有协程公用一个栈空间,但是切换时,会将当前栈空间的内容全部memcpy的协程的数据结构中,恢复时再从其中拷贝到共享栈中。