前言
c++有两个较好的协程库libgo和libco,本文简易讲述用纯c的代码实现的一个协程ntyco。
协程的主要应用的三个方面:
1.文件io的操作
2.网络io的操作
3.mysql,redis等第三方库的操作
简单来说可以理解为把阻塞等待的时间用来干别的事情,提高效率。
协程存在的原因?
在CS,BS的开发模式下,服务器的吞吐量是一个受关注的参数。那什么是吞吐量呢?吞吐量等于1s 业务处理次数
。(业务处理就是网络IO读取时间加上业务处理)
因为业务的不同所以业务处理时间不同。所以对于业务处理时间的优化,要根据业务场景来优化,而网络IO时间是可以优化的。
这个网络io是所有业务处理里面的必有环节,那么也就是从提升recv和send的性能开始。
如果只是一个客户端连接,就没必要有这个协程,当有百万并发量的时候,效率就能得到显著提升。
对于响应式服务器来说,所有客户端的操作都是源于类似下面的大循环。使用 epoll 管理百万计的客户端长连接。
while(1){
epoll_wait();
for(){
}
}
对于服务器处理网络IO,sockfd的实现有两种方式:
1.IO同步
2.IO异步
IO同步
同步:检测IO 与 读写IO 在同一个流程里
优点:
1.sockfd 管理方便
2.代码逻辑清晰
缺点:
1.服务器程序依赖 epoll_wait 的循环,响应速度慢。
2.程序性能差
IO异步
对于IO 异步操作来说,将对sockfd 的操作push到线程池中 ,由其他线程进行读写,也就是说,异步:检测IO 与 读写IO 不在同一个流程里。
优点:
- 子模块好规划
- 程序性能高
缺点:
- 因为子模块好规划,使得模块之间的 sockfd 的管理异常麻烦。每一个子线程都需要管理好 sockfd,避免在 IO 操作的时候,sockfd 出现关闭或其他异常。通俗来讲,需要避免一个fd被多个线程操作的情况发生。
在做网络 IO 编程的时候,有一个非常理想的情况,就是每次 accept 返回的时候,就为新来的客户端分配一个线程,这样一个客户端对应一个线程。就
不会有多个线程共用一个 sockfd。这种方式,代码逻辑非常易读。但是这只是理想,线程创建代价,调度代价是很大的。
IO同步操作,写代码逻辑清晰,但是效率低;而IO异步操作,fd管理复杂,但是效率高。由此,协程便出现了。
协程:把两者结合起来,以同步的编程方式,实现异步的性能。即写代码的时候,同步;运行的逻辑,异步。
原语
yield()
yield的含义是让出,将当前的执行流程让出,让出给调度器。
那么什么时候需要yield让出呢?在recv之前,send之前,也就是在io操作之前,因为我们不知道io是否准备就绪
了,所以我们先将fd加入epoll中,然后yield让出,将执行流程给调度器运行。
schedule
schedule调度器做什么事情呢?调度器就是io检测,调度器就是不断的调用epoll_wait,来检测哪些fd准备就绪了,然后就恢复相应fd的执行流程执行现场
。注意schedule不是原语,schedule是调度器
。
resume()
resume的含义是恢复,从上面我们得知恢复是被schedule(调度器)恢复的
,那么现在恢复到了原来流程的哪里呢?其实是恢复到了yield的下一条代码处。通常下面的代码都会将fd从epoll中移除,然后执行recv或send操作
,因为一旦被resume,就说明肯定是准备就绪的。
实现过程
如何实现呢?在检测到了准备recv之前,先将fd加入epoll,然后yield让出给调度器来执行,调度器来决定resume恢复到哪一个地方开始执行,恢复之后将fd从epoll下掉,然后执行recv。
我们发现检测IO 与 读写IO 不在同一个流程里,实现了异步,而我们代码看起来却是同步的。
在协程的上下文 IO 异步操作(nty_recv,nty_send)函数,步骤如下:
1.将 sockfd 添加到 epoll 管理中。
2. 进行上下文环境切换,由协程上下文 yield 到调度器的上下文。
3.调度器获取下一个协程上下文。Resume 新的协程。
IO 异步操作的上下文切换的时序图如下:
实现原语操作
如何实现yield和resume:
- setjmp/longjmp
- ucontext
- 用汇编代码自己实现切换
本文采用汇编代码实现yield和resume操作的切换,函数为_switch()。
yield=_switch(A,B)
resume=_switch(B,A)
如何从一个协程切换到另一个协程呢?我们只需要将当前协程的上下文从寄存器组中保存下来;将下一个要运行的协程的上下文放到寄存器组上去,即可实现协程的切换。
寄存器介绍
下面介绍的都是x86_64的寄存器:
- %rdi,%rsi,%rdx,%rcx,%r8,%r9 用作函数参数,依次对应第1参数,第2参数…(这里我们只需
关注%rdi和%rsi
) - %rbx,%rbp,%r12,%r13,%14,%15 用作数据存储,遵循被调用者使用规则,简单说就是随便用,调用子函数之前要备份它,以防他被修改
- new_ctx是一个指针,指向一块内存,它现在存在%rdi里面,同理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);
__ asm __是内联汇编函数,内联汇编,指在C语言中插入汇编语言,其是Linux中使用的基本汇编程序语法。
typedef struct _nty_cpu_ctx {
void *rsp;//栈顶
void *rbp;//栈底
void *eip;//CPU通过EIP寄存器读取即将要执行的指令
void *edi;
void *esi;
void *rbx;
void *r1;
void *r2;
void *r3;
void *r4;
void *r5;
} nty_cpu_ctx;
int _switch(nty_cpu_ctx *new_ctx, nty_cpu_ctx *cur_ctx);
//默认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) # save eip \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 \n"
" movq 32(%rdi), %r12 \n"
" movq 24(%rdi), %rbx # restore rbx,r12-r15 \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) # restore eip \n"
" ret # 出栈,回到栈指针,执行eip指向的指令。 \n"
);
movq的汇编指令是将逗号前的那一个寄存器的内容赋值到逗号后面的那一个。
%rdi, %rsi表示第一个参数和第二个参数。0(%rsi)是指第二个参数的从0开始的0到8字节。简单来看其实就是先把寄存器的内容保存到nty_cpu_ctx *cur_ctx里面,再把nty_cpu_ctx *new_ctx的值赋给寄存器,再开始运行。
协程的细节
创建协程
//创建协程
int nty_coroutine_create(nty_coroutine **new_co, proc_coroutine func, void *arg);
- nty_coroutine **new_co:需要传入空的协程的对象,这个对象是由内部创建的,并且在函数返回的时候,会返回一个内部创建的协程对象。
- proc_coroutine func :协程的子过程。当协程被调度的时候,就会执行该 函数。
- void *arg :需要传入到新协程子过程中的参数。
typedef struct _nty_coroutine {
//cpu ctx
nty_cpu_ctx ctx;
// func
proc_coroutine func;
void *arg;
// create time
uint64_t birth;
//stack
void *stack;
size_t stack_size;
size_t last_stack_size;
//status
nty_coroutine_status status;
//root
nty_schedule *sched;
//co id
uint64_t id;
//fd event
int fd;
uint16_t events;
//sleep time
uint64_t sleep_usecs;
//set
RB_ENTRY(_nty_coroutine) sleep_node;
RB_ENTRY(_nty_coroutine) wait_node;
TAILQ_ENTRY(_nty_coroutine) ready_node;
} nty_coroutine;
调度器定义
typedef struct _nty_schedule {
// create time
uint64_t birth;
//cpu ctx
nty_cpu_ctx ctx;
//stack_size
size_t stack_size;
//coroutine num
int spawned_coroutines;
//default_timeout
uint64_t default_timeout;
//当前调度的协程
struct _nty_coroutine *curr_thread;
//页大小
int page_size;
//epoll fd
int epfd;
//线程通知相关,暂未实现
int eventfd;
//events
struct epoll_event eventlist[NTY_CO_MAX_EVENTS];
int num_new_events;
//set
nty_coroutine_queue ready;
nty_coroutine_rbtree_sleep sleeping;
nty_coroutine_rbtree_wait waiting;
} nty_schedule;
hook技巧
如果跟mysql,redis建立连接进行io操作,但是不去修改它们提供的客户端源码开发包的时候,就会发现连不上去,因为其源码用的是posix api,recv和send。而协程用的是nty_recv()和nty_send()。两者之间没有关联。
我们可以使用hook,帮助我们不用再封装posix api接口取个别的名字的函数,可以直接用和那些posix api接口同名并且不会冲突的函数(recv()、send()等等),并且功能由我们来具体实现。
hook提供了两个接口;1. dlsym()是针对系统的,系统原始的api。2. dlopen()是针对第三方的库。
Demo:
ssize_t read(int fd, void *buf, size_t len) {
printf("in read\n");
return read_f(fd, buf, len);
}
static int init_hook() {
read_f = dlsym(RTLD_NEXT, "read");
}
int main() {
init_hook();
........
}
原来的系统调用,被hook后,都被截获变成了xxx_f,原来的名字就空出来可以让我们定义了,所以下面我们再有使用到read的地方就变成了自己定义的read。
多核模式
解决协程多核的问题有两种方式
- 多进程(实现起来容易,对协程代码本身不用去改)
- 多线程(复杂,需要对调度器进行加锁)
那么做多线程对调度器进行加锁,锁放在哪呢?锁放在调度器结构体里面,因为调度器是全局唯一的,那么要锁哪里呢?<取协程,恢复协程>,这里需要加锁。
参考
本文参考自纯c协程框架NtyCo实现与原理