Linux下的协程实现
在实现协程之前,我们需要知道,什么是协程,他针对的业务场景是什么?
进程、线程、协程的概念
进程:对于一个可执行文件,其运行在内存中后即为一个进程,对于Windows而言,可执行文件也称为PE文件,对于Linux,它被称为ELF文件,因为在内存中,一个程序是分段的,大致分为内核区,堆区,栈区,常量区,代码区,对于静态文件而言,他也是分段的,windows存在PE表,表中以section作为分段,加载到内存时将这个表对内存页进行对齐,然后即可在程序中运行,Linux的原理有所区别,但大致一致。
线程:线程是CPU任务调度和系统调用的最小单位,他基于进程来执行,表现形式为一个线程栈,其调度原理也就是上下文切换,上下文切换除了保存现场环境之外,之所以能够突然执行其他地方的代码,原因在于其保存了现场的ESP(栈顶指针)与EBP(栈底指针)与EIP(指令寄存器)等寄存器作为上下文,上下文切换的本质就是切换寄存器状态。
协程:协程基于线程运行,一个线程可以运行多个协程,他的调度由用户自己控制,区别于线程调度是通过内核来调度,而这种调度相对较为费时,协程直接在用户态运行,这降低了CPU调度的资源占用,并且协程可以实现以同步的方式编写代码但实现异步的性能。
协程的应用场景
当我们调用recv,accept等会阻塞的函数时,整个线程就被卡在这里了,然后CPU进行线程调度,执行其他线程,等阻塞完了这个线程才会继续往后执行,但这明显就造成了资源浪费,于是,协程出现了,协程可以实现,当线程阻塞之后,继续在这个线程上执行其他代码,如果这个阻塞完毕了,则切换回来继续执行,只要遇到阻塞,就让出执行权(yield),并切换到其他协程来执行(resume),这就避免了调度造成的资源浪费。
协程的三种实现方式
setjmp/longjmp
执行流程如下代码所示,当第一次执行setjmp时,会返回0值,之后每次执行这个返回值都会自增,依靠这种类似于GOTO的形式来完成代码栈执行顺序的切换。
#include <setjmp.h>
jmp_buf env; //上下文
void func (int arg ){
pinrtf("func :%d\n",arg);
longjmp(env, ++arg);
}
int main(){
int ret = setjmp(env); //第一次返回0,每一次执行longjmp都会跳转到这里
if (ret == 0){
func(ret);
}else if(ret == 1){
func(ret);
}else if(ret == 2){
func(ret);
}
}
ucontext_t
ucontext_t ctx[2];
ucontext_t main_ctx;
int count = 0;
// coroutine1
void func1(void) {
while (count ++ < 30) {
printf("1\n");
swapcontext(&ctx[0],&ctx[1]);
printf("4\n");
}
}
// coroutine2
void func2(void) {
while (count ++ < 30) {
printf("2\n");
swapcontext(&ctx[1],&ctx[0]);
printf("5\n");
}
}
// schedule
int main() {
char stack1[2048] = {0};
char stack2[2048] = {0};
getcontext(&ctx[0]);
ctx[0].uc_stack.ss_sp = stack1;
ctx[0].uc_stack.ss_size = sizeof(stack1);
ctx[0].uc_link = &main_ctx; // 这里的uc_link表示协程执行完了之后,会回到这个上下文
makecontext(&ctx[0], func1, 0);
getcontext(&ctx[1]);
ctx[1].uc_stack.ss_sp = stack2;
ctx[1].uc_stack.ss_size = sizeof(stack2);
ctx[1].uc_link = &main_ctx;
makecontext(&ctx[1], func2, 0);
printf("swapcontext\n");
swapcontext(&main_ctx, &ctx[0]);
printf("\n");
}
以上代码实现了主协程切换到其他协程后其他协程相互切换并在业务完成后切换回主协程的过程,执行swapcontext(当前上下文,目的上下文)之后,主协程让出执行权,并切换到其他协程,并从coroutine1切换到coroutine2,最终当func1中count++ == 30时,循环结束,执行完这个函数会切换到主协程
汇编实现
协程的原理如上文所述,其实就是保存上下文,然后切换到其他上下文,如果有需要就切换回来继续执行,那么,如果我们将所有的寄存器都拷贝一份作为当前上下文,然后将其他上下文的寄存器全部拷贝过来,不就是上下文切换了吗,汇编的实现就是这个逻辑,在实现之前,我们需要知道,CPU大致有哪些寄存器。
x86架构的32位通用寄存器:
寄存器 | 序号 | 存储数据范围 | 16位 | 8位-低位 | 8位-高位 |
---|---|---|---|---|---|
EAX | 0 | 0-0XFFFFFFFF | AX | AL | AH |
ECX | 1 | 0-0XFFFFFFFF | CX | CL | CH |
EDX | 2 | 0-0XFFFFFFFF | DX | DL | DH |
EBX | 3 | 0-0XFFFFFFFF | BX | BL | BH |
ESP | 4 | 0-0XFFFFFFFF | SP | ||
EBP | 5 | 0-0XFFFFFFFF | BP | ||
ESI | 6 | 0-0XFFFFFFFF | SI | ||
EBI | 7 | 0-0XFFFFFFFF | BI |
当然,x64架构与x86的命名相似,不过将E开头改成了R开头,如RAX,arm架构的cpu可能差距比较大,需要自己查询手册根据情况来编写,因为arm的微架构是授权形式的,种类比较多。
除了通过一个一个拷贝寄存器的方式,也可以使用自带的指令集,如PUSHAD,POPAD,pushad会将寄存器压入栈中,这个过程称为现场保存,然后popad会将栈中的现场进行恢复,也可以实现协程切换。
协程的辅助应用
了解了协程的实现,直到他能处理什么样的业务场景后,这就引发了一个思考,我们怎么使用呢?
在这里,有两种方式来使用协程
阻塞时切换
阻塞时切换,意为在我们知道接下来的代码会导致阻塞时,提前执行协程切换,如以下函数,我们明显可以知道,在执行函数时会在recv这里阻塞住。
void func_test(fd,buff,length){
int count = recv(fd,buff,length);
}
那么如何检测是否会阻塞呢,我们可以使用poll的方式来检测
void func_test(fd,buff,length){
struct pollfd fds[1] = {0};
fds[0].fd = fd;
fds[0].events = POLLIN;
int nready = poll(fds, 1, 0);
if (nready == 0 ){
//切换协程
}
int count = recv(fd,buff,length);
}
hook替换执行
使用阻塞时切换时,是否阻塞,然后在调用前去检测并切换协程,这样的方式可以用,但感觉不太优美,有一个更好的方法就是hook住posix api,使其不去执行原生代码,而是先执行我自己的函数,然后在自己的函数里面来调用posix api,hook的方式如下:
1. 定义类型
typedef ssize_t (*read_t)(int fd, void *buf, size_t count);
read_t read_f = NULL;
2. 实现原始接口
ssize_t read(int fd, void *buf, size_t count) {
//实现业务
}
void init_hook(void) {
if (!read_f) {
read_f = dlsym(RTLD_NEXT, "read");
}
}
这样一来,用户使用recv时,此时就不需要阻塞了,因为调用的其实是用户自己的代码,并且完全可以实现不修改原生代码的情况下将recv之类的posix api同步阻塞函数修改为异步非阻塞执行。