协程设计原理与汇编实现
一、为什么要有协程
1、协程为什么会出现
计算机的CPU执行三五个程序的时候使用的是CPU时间分片,也就是一个程序法分配一个时间片,在这个时间片没有完之前,都是属于你的运行时间,如果到了就需要交给下一个程序使用,那这时就出现一个问题,那如果程序A执行一半之后时间片用完阻塞了,此时程序B拿到CPU准备运行,那原先存储在CPU中的数据怎么办,程序A必须想办法吧这些数据记录下来,要不然等再次拿到CPU时还需要从头来。
基于这种情况就出现了进程,进程的出现是为了解决但CPU运行时程序的上下文切换问题,通过抽像出进程这样的概念,搭配虚拟内存、进程表(PCB)之类的东西,就可以用来管理独立的程序的运行和切换了。
后来,有的时候碰着I/O访问就会阻塞。此时空着也是空着,我们期望内核能把CPU切换到其他进程,让人家先用着。当然除了I\O阻塞,还有时钟阻塞等等。一开始大家都这样弄,后来发现不成,太慢了。为啥呀,一切换进程得反复进入内核,置换掉一大堆状态。进程数一高,大部分系统资源就被进程切换给吃掉了。后来搞出线程的概念,大致意思就是,这个地方阻塞了,但我还有其他地方的逻辑流可以计算,这些逻辑流是共享一个地址空间的,不用特别麻烦的切换页表、刷新TLB,只要把寄存器刷新一遍就行,能比切换进程开销少点。
如果连时钟阻塞、 线程切换这些功能我们都不需要了,自己在进程里面写一个逻辑流调度的东西。那么我们即可以利用到并发优势,又可以避免反复系统调用,还有进程切换造成的开销,分分钟给你上几千个逻辑流不费力。这就是用户态线程。
从上面可以看到,实现一个用户态线程有两个必须要处理的问题:一是碰着阻塞式I\O会导致整个进程被挂起;二是由于缺乏时钟阻塞,进程需要自己拥有调度线程的能力。如果一种实现使得每个线程需要自己通过调用某个方法,主动交出控制权。那么我们就称这种用户态线程是协作式的,即是协程。
2、协程解决了什么问题
异步请求池框架里我们提到了可以在同步状态下实现异步处理,也就是在客户端发起连接请求之后不用等待io操作,再将对应的fd添加到epoll管理时通过epoll_wait判断此时是否有io就绪,没有io就绪就准备处理下一次连接请求,如果有就进行io操作。
异步请求池最后提到了可以在兼顾异步性能的同时进行同步编程,实现方式就是使用setjmp/ longjmp函数进行跳转,但是频繁的跳转涉及到上下文的不断切换,所以我们就想能不能存在某种方式可以满足这种需求。
答案是协程,那为什么协程可以满足上述需求,原因就是协程可以兼顾异步性能的同时进行同步编程,而且协程的切换代价比线程的切换代价要低。首先协程的切换仅涉及CPU的上下文交换,就是把当前CPU内寄存器的值存储下来,然后将下一个协程的数据内容读入寄存器中。另一个原因就是协程是在用户态就可以完成切换,而线程的切换必须由内核完成。
由此可见,协程的出现就很有必要了。
二、异步的运行流程
上面说了那么就的异步,那异步的运行流程是怎样的呢:这部分内容我们通过将一个http服务器改成异步请求的方式来解释异步的运行流程。
关于http服务器前面的代码就不做过多解释了,直接来看io多路复用那块。
int read_buffer() {
// read
}
int write_buffer() {
// write
}
func() {
while(1) {
int nready = epoll_wait();
for(int i = 0; i < nready; i++) {
if(events[i].event & EPOLLIN) {
read_buffer();
}
else if(events[i].evemmt & EPOLLOUT) {
write_buffer();
}
}
}
}
此时我们可以看到,原先的http服务器在读写流程时本来就不在同一个流程里面,因此这里需要进行简单修改。首先来看一下异步流程图。
这里在建立连接之后,我们在read之后调到epoll_wait处,然后拿到nready之后遍历去判断io是否可读,如果可读就执行读操作,然后进行写操作,此时可能由于wbuffer满从而导致write阻塞,那这时我们需要将fd挂载到epoll树上去监听写事件,然后跳转到epoll_wait处。也就是说在跳转到epoll_wait时会有两种功能情况,一种是你ready大于零,此时继续往下执行,这也是一个完整的协程操作,如果小于零,就继续执行io操作。
现在我们再来看看协程在http服务器中的实现流程:当每次遇到io操作的时候就去判断io是否就绪,如果就绪就去处理,如果没有就绪就切换到下一个协程,然后下一个协程如果遇到io操作的时也会去判断是否有io就绪,没有就继续切换。所以在这里我们能看出是否切换协程是由epoll_wait决定的。
三、协程的原语操作
1、协程在http服务器中的使用
上述介绍到了跳转到epoll_wait的时刻,这里设计到协程的两个原语操作,resume(恢复)和yeild(让出),我们首先来看一张图:
这张图的意思就是首先执行commit函数,再将fd通过epoll_ctl加到epoll树上时,然后就会yeild到epoll_wait这里,此时epoll_wait会进行判断,如果没有io就绪就重新发起请求,这时不需要恢复的(也就是跳转回epoll_ctl的下一步)。如果有就绪就去执行对应的io操作,处理完成之后resume恢复到原来的状态,此时图中的红线就是一个协程。其本质就是每一次io操作都会使用epoll_wait去判断fd是否就绪。
2、协程的切换
那这两个原语操作有没有共同点,答案是有的,这里涉及三种三种方法(这里介绍汇编实现):
1、longjmp / setjmp
2、ucontext
3、汇编实现
开始介绍之前,我们首先了解一下线程或进程是怎样进行切换的
进程或线程甚至协程的切换总体上来说就是将此时CPU内寄存器的值存储下来,然后再将下一个进程(需要切换执行的进程)内存储记录的寄存器值读入CPU中,这样就完成了切换,实际上进程、线程的切换是由很大差距的,但大体都是这么一个流程。所以我们在切换协程时也是需要这样做的,接下来看NtyCo关于协程切换的代码:
typedef struct _nty_cpu_ctx {
void *esp; //
void *ebp;
void *eip;
void *edi;
void *esi;
void *ebx;
void *r1;
void *r2;
void *r3;
void *r4;
void *r5;
} nty_cpu_ctx;
#elif defined(__x86_64__)
__asm__ (
" .text \n"
" .p2align 4,,15 \n"
".globl _switch \n"
".globl __switch \n"
"_switch: \n"
"__switch: \n"
" movq %rsp, 0(%rsi) # save stack_pointer \n"
" movq %rbp, 8(%rsi) # save frame_pointer \n"
" movq (%rsp), %rax # save insn_pointer \n"
" movq %rax, 16(%rsi) \n"
" movq %rbx, 24(%rsi) # save rbx,r12-r15 \n"
" movq %r12, 32(%rsi) \n"
" movq %r13, 40(%rsi) \n"
" movq %r14, 48(%rsi) \n"
" movq %r15, 56(%rsi) \n"
" movq 56(%rdi), %r15 \n"
" movq 48(%rdi), %r14 \n"
" movq 40(%rdi), %r13 # restore rbx,r12-r15 \n"
" movq 32(%rdi), %r12 \n"
" movq 24(%rdi), %rbx \n"
" movq 8(%rdi), %rbp # restore frame_pointer \n"
" movq 0(%rdi), %rsp # restore stack_pointer \n"
" movq 16(%rdi), %rax # restore insn_pointer \n"
" movq %rax, (%rsp) \n"
" ret \n"
);
#endif
这里首先还需要简单介绍一下CPU中的16个寄存器(X64)
%rdi,%rsi,%rdx,%rcx,%r8,%r9
用作函数参数,依次对应第1参数,第2参数…(这里我们只需关注%rdi和%rsi)%rbx,%rbp,%r12,%r13,%14,%15
用作数据存储,遵循被调用者使用规则,简单说就是随便用,调用子函数之前要备份它,以防他被修改new_ctx是一个指针,指向一块内存,它现在存在%rid里面,同理cur_ctx存在%rsi里面%rsp代表栈顶,%rbp代表栈底,%eip代表cpu下一条待取指令的地址(这也就是为什么resume之后会接着运行代码流程的原因)//new_ctx[%rdi]:即将运行协程的上下文寄存器列表;
cur_ctx[%rsi]:正在运行协程的上下文寄存器列表
int _switch(nty_cpu_ctx *new_ctx, nty_cpu_ctx *cur_ctx);
这里首先要做的就是将CPU中寄存器的值记录下来,然后再将下一个协程的数据写入CPU中既可以完成了。
四、协程的定义
协程结构体:
struct coroutine {
int id; // 协程ID
struct ctx {
// 协程上下文
}
void *(*func)(void*); // 回调函数
void *arg; // 函数参数
int status; // 协程状态,ready、wait、sleep
int stack[len]; // 栈空间
int length; // 可用栈空间
void *ret; // 返回值
queue_node (, struct coroutine) ready;
struct queue_node {
struct coroutine *next;
struct coroutine *prev;
}ready;
queue_node (, struct coroutine) wait; // wait是一个树
rbtree_node (, struct coroutine) sleep;// sleep是可排序的数据结构(树)
}
这里只是简单协程结构体,实际上肯定比这要复杂很多,但这些应该也能够说明接下来的问题了。现在我们来看看这三个结构队列之间的关系,首先来看一张图:
首先,为什么一个结构体里要有wait树、sleep树和ready队列呢?这样做的好处是什么?这是因为调度策略的选择导致的,每一次循环都会判断io时间是否超时,超时之后对应两种处理,第一种是直接处理,一种是加入到ready队列,正常来说,我们都会加入到ready队列,然后再去判断io是否等待。那这时候有设计到如何加入到ready队列,其实很简单,因为我们是先判断io是否超时,然后是否等待,最后才会判断是否就绪,因此我们ready队列一定是包含所有协程节点的,我们这时候需要做的就是将协程从对应的sleep、wait树删除即可。
五、调度器的定义
调度器结构体:
struct scheduler_op {
remove_wait();
remove_sleep();
// 可以自定义调度策略
}
struct scheduler {
int epfd;
struct epoll_event events[];
struct coroutine *cur; // 当前运行的协程
queue_tail(, struct coroutine) ready; // 指针
queue_tail(, struct coroutine) wait;
rbtree_root(, struct coroutine) sleep;
struct scheduler_op *sch_op; // 调度策略指针
}
调度器首先必须有一个epfd用来管理所有的fd,然后需要一个指向ready队列的指针、一个指向wait树的指针、一个指向sleep红黑树根节点的指针。然后就是指向调度策略的指针,通过这个指针可以选择将节点从wait树上移除或者从sleep树上移除,也可以使用自定义的策略,这样我们就可以管理协程的切换了。
本专栏知识点是通过<零声教育>的系统学习,进行梳理总结写下文章,对c/c++linux系统提升感兴趣的读者,可以点击链接,详细查看详细的服务:C/C++后台服务器