NtyCo源码解析
这两天着重学习了协程的原理以及实现,并且在网上找到了开源的协程框架NtyCo,于是就拿来看看并且学习了下,之后我将从以下几点解析这份代码
1.为什么会有协程,协程能够解决什么问题?
1.1协程是什么?
首先来说说协程是什么东西,协程可以理解为一个轻量级的线程也可以理解为一直处于用户态的线程,他的函数遵循posix的规范,所以我们可以用和pthread一样的编程方法去进行编程,学过操作系统后,我们都知道进程是系统资源分配的最小单位,线程是系统调用的最小单位,而协程是比线程还要小的单位,他的调度与操作系统无关,所有的资源以及调度都是由编程人员自己完成。
1.2 协程能够解决什么问题?
1.协程的出现能够让我们用同步的编程方式实现异步的性能。
这里来解释下什么是同步什么是异步
void sync()
{
while(1)
{
int nready = epoll_wait(epfd,events, MAX_EPOLL_EVENTS,1000);
for(int i = 0 ; i < nready ; i++ )
{
recv(fd[i],buff,len,0);
//处理事物
send(fd[i],buff.len,0);
}
}
}
这就是同步方式,数据的读取、处理以及发送处于同一个while循环中,只有recv完数据并且处理完成之后才能发送出去。如果没有数据可以接收那么recv将一直阻塞,在此期间不能干别的事情,效率不高。当然这种方式也有他的好处,比如编程简单,逻辑清晰。
void thread_cb(int fd)
{
recv(fd,buff,len,0);
//处理事物
send(fd,buff.len,0);
}
void async()
{
while(1)
{
int nready = epoll_wait(epfd,events, MAX_EPOLL_EVENTS,1000);
for(int i = 0 ; i < nready ; i++ )
{
push_thread(fd[i],thread_cb);
}
}
}
这就是异步方式,也就是把事件的检测与处理放到不同的线程中进行,极大的提升了程序的性能,但是也同样带来了一些问题,比如在每个线程中管理fd比较麻烦,并且当处理量变大时,线程的创建、销毁以及转换消耗不低。
而协程的出现刚好结合了两者的优点,这里得提一句,协程虽然是有着异步的性能,但它终究不是异步。
1.2协程编程简单,不需要像线程那样访问共享资源时需要加锁与解锁。
1.3 协程完全工作在用户态,不同协程间的切换更加高效。
协程的运行与原语
上面谈到协程可以节省用于等待recv与send的时间,那么在单线程的情况下怎么去实现呢?聪明的研发人员想到了用程序条跳转的方式来实现,并且他们用一个调度器来控制不同的协程的工作流程:
如图所示,程序通过调用两个原语yeild与resume,来实现协程与调度器之间的切换。
void nty_coroutine_yield(nty_coroutine *co);
nty_coroutine_yeild函数能让当前协程主动放弃处理器,保存断点,并转去执行scheduler上次resume的地方的程序。
int nty_coroutine_resume(nty_coroutine *co)
nty_coroutine_resume让当前调度器放弃当前执行的程序,恢复到上次yeild的协程程序处。
yeild与resume是一个可逆的过程
整个程序的大体结构如图所示,通过epoll_wait找出有哪些fd有可读可写事件,并resume到他们的协程中运行,等到读写完成后,回来继续epoll_wait监视各个fd。
这里在recv与send前后都要先epoll_ctl(add)再epoll_ctl(del)就是为了解决上文提到的recv与send等待数据的过程导致程序效率低下的问题。先将fd加入epoll中,然后返回调度器程序中循环执行epoll_wait,等到数据到来或者数据准备完成,再返回当时保存上下文环境的协程中,发送或者读取数据。这样子我们就不必把socket设置成非阻塞模式,大大简化了编程难度。
协程的切换
yeild与resume函数内部就是一个_switch()函数,该函数原型如下:
//第一个参数为新的协程上下文,第二个参数是当前协程的上下文
int _switch(nty_cpu_ctx *new_ctx, nty_cpu_ctx *cur_ctx);
switch函数的实现主要有如下三种方式
1.setjmp/longjmp
2.ucontext
3.汇编代码
这时候有人会说,既然是实现程序之间的跳转,那么为什么goto语句不能用呢?这里给大家解释下,goto只能实现函数内部的跳转,即只能在同一个函数栈中跳转,而协程需要在不同的函数栈之间跳转。
NtyCo中switch函数的实现用的是汇编代码,所以接下来讲怎么用汇编来实现协程切换。
//这里只列出了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"
);
//这是程序运行保存运行状态的一组寄存器
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;
寄存器 | 作用 |
---|---|
rsp | 栈指针 |
rax | 返回值 |
r12 | 通用寄存器 |
r13 | 通用寄存器 |
r14 | 通用寄存器 |
r15 | 通用寄存器 |
rbx | 通用寄存器 |
rbp | 通用寄存器 |
保存以及切换上下文各个寄存器的顺序就看你的结构体如何定义了,上图是NtyCo保存的顺序。 |
其他可能需要借鉴内容都贴在这里,可以更进一步的了解代码
这里贴出其他的实现方式链接:
协程ucontext的实现
https://github.com/cloudwu/coroutine.git
协程longjmp/setjmp实现
https://www.cnblogs.com/sewain/p/14360853.html
x86_64各个寄存器的意义
https://blog.csdn.net/z974656361/article/details/107125458/
协程结构体的定义
这里挑选了其中最重要的结构进行解析
typedef struct _nty_coroutine {
//private
nty_cpu_ctx ctx; //协程的寄存器组
proc_coroutine func;//协程的函数
void *arg;//协程保存的参数
void *data;//可以保存与协程一起传递的数据
size_t stack_size;//每个协程分配的栈的大小
void *stack; //每个协程都分配了大小一样的栈
//用于保存每个协程内分配的变量
nty_coroutine_status status;//协程的运行状态
//sleep,busy ,expire等
nty_schedule *sched; //协程所属的调度器
RB_ENTRY(_nty_coroutine) sleep_node; //下一个睡眠状态的协程
RB_ENTRY(_nty_coroutine) wait_node; //下一个等待的协程
LIST_ENTRY(_nty_coroutine) busy_next; //下一个就绪的协程
} nty_coroutine;
这里来说说为什么为什么sleep与wait用红黑树这种数据结构而不用最小堆,因为红黑树能保证对树遍历的时候,元素是有序的而最小堆并不是一个有序的序列,只能一个一个向外冒。
协程的创建函数
int nty_coroutine_create(nty_coroutine **new_co, proc_coroutine func, void *arg) {
assert(pthread_once(&sched_key_once, nty_coroutine_sched_key_creator) == 0);
nty_schedule *sched = nty_coroutine_get_sched();
if (sched == NULL) { //如果还没调度器就创建一个调度器
nty_schedule_create(0);
sched = nty_coroutine_get_sched();
if (sched == NULL) {
printf("Failed to create scheduler\n");
return -1;
}
}
//分配内存
nty_coroutine *co = calloc(1, sizeof(nty_coroutine));
if (co == NULL) {
printf("Failed to allocate memory for new coroutine\n");
return -2;
}
//分配栈的大小
int ret = posix_memalign(&co->stack, getpagesize(), sched->stack_size);
if (ret) {
printf("Failed to allocate stack for new coroutine\n");
free(co);
return -3;
}
//初始化所有变量的内容
co->sched = sched;
co->stack_size = sched->stack_size;
co->status = BIT(NTY_COROUTINE_STATUS_NEW); //
co->id = sched->spawned_coroutines ++;
co->func = func;
#if CANCEL_FD_WAIT_UINT64
co->fd = -1;
co->events = 0;
#else
co->fd_wait = -1;
#endif
co->arg = arg;
co->birth = nty_coroutine_usec_now();
*new_co = co;
//创建完成就加到就绪队列中
TAILQ_INSERT_TAIL(&co->sched->ready, co, ready_next);
return 0;
}
调度器的定义以及调度的策略
这里也是把核心内容提取出来讲
typedef struct _nty_schedule {
nty_cpu_ctx ctx; //当前运行协程的寄存器组
void *stack; //当前运行协程的栈
size_t stack_size; //栈的大小
struct _nty_coroutine *curr_thread;//当前运行的协程
int page_size; //内存页的大小
int poller_fd; //调度器管理的epollfd
int eventfd; //接收到客户端的sockfd
int num_new_events;//epoll_wait时接收到的新的事件数量
nty_coroutine_queue ready; //就绪队列
nty_coroutine_rbtree_sleep sleeping; //睡眠红黑树
nty_coroutine_rbtree_wait waiting; //等待红黑树
//private
} nty_schedule;
区别调度器结构体与协程结构体的是调度器中是所有协程都需要用到的相同的东西,而协程结构体中包含的是每个协程都有但却不完全一样的内容(比如协程 运行状态、协程运行的函数)。
调度的策略也就在整个协程最重要的函数里
void nty_schedule_run(void) {
nty_schedule *sched = nty_coroutine_get_sched();
if (sched == NULL) return ;
while (!nty_schedule_isdone(sched)) {
//检查睡眠协程是否存在到期,如果到期则唤醒
// 1. expired --> sleep rbtree
nty_coroutine *expired = NULL;
while ((expired = nty_schedule_expired(sched)) != NULL) {
nty_coroutine_resume(expired);
}
//检查就绪队列是否有事件,如果有则唤醒
// 2. ready queue
nty_coroutine *last_co_ready = TAILQ_LAST(&sched->ready, _nty_coroutine_queue);
while (!TAILQ_EMPTY(&sched->ready)) {
nty_coroutine *co = TAILQ_FIRST(&sched->ready);
TAILQ_REMOVE(&co->sched->ready, co, ready_next);
if (co->status & BIT(NTY_COROUTINE_STATUS_FDEOF)) {
nty_coroutine_free(co);
break;
}
nty_coroutine_resume(co);
if (co == last_co_ready) break;
}
//最后是通过epoll_wait将所有可读可写事件记录,集中处理
// 3. wait rbtree
nty_schedule_epoll(sched);
while (sched->num_new_events) {
int idx = --sched->num_new_events;
struct epoll_event *ev = sched->eventlist+idx;
int fd = ev->data.fd;
int is_eof = ev->events & EPOLLHUP;
if (is_eof) errno = ECONNRESET;
nty_coroutine *co = nty_schedule_search_wait(fd);
if (co != NULL) {
if (is_eof) {
co->status |= BIT(NTY_COROUTINE_STATUS_FDEOF);
}
nty_coroutine_resume(co);
}
is_eof = 0;
}
}
nty_schedule_free(sched);
return ;
}
NtyCo的调度策略比较简单,就是简单的先处理睡眠到期的协程,之后处理就绪协程最后是通过epoll_wait得到的协程。
NtyCo的hook
如果我们自己写的代码要引入协程,最傻的办法就是一个函数一个函数的改过来,把每个recv改成nty_recv,这样非常耗时耗力,于是hook就起到了非常好的作用。
socket_t socket_f = NULL;
read_t read_f = NULL;
recv_t recv_f = NULL;
recvfrom_t recvfrom_f = NULL;
write_t write_f = NULL;
send_t send_f = NULL;
sendto_t sendto_f = NULL;
accept_t accept_f = NULL;
close_t close_f = NULL;
connect_t connect_f = NULL;
int init_hook(void) {
socket_f = (socket_t)dlsym(RTLD_NEXT, "socket");
//read_f = (read_t)dlsym(RTLD_NEXT, "read");
recv_f = (recv_t)dlsym(RTLD_NEXT, "recv");
recvfrom_f = (recvfrom_t)dlsym(RTLD_NEXT, "recvfrom");
//write_f = (write_t)dlsym(RTLD_NEXT, "write");
send_f = (send_t)dlsym(RTLD_NEXT, "send");
sendto_f = (sendto_t)dlsym(RTLD_NEXT, "sendto");
accept_f = (accept_t)dlsym(RTLD_NEXT, "accept");
close_f = (close_t)dlsym(RTLD_NEXT, "close");
connect_f = (connect_t)dlsym(RTLD_NEXT, "connect");
}
这里用到了dlsym函数:
void *dlsym(void *handle, const char *symbol);
将send 、accept、recv等系统调用重定向为send_f,accept_f,recv_f(也就是改名),之后把我们写的函数改名为send 、accept、recv,上层代码就可以直接运行我们的协程了。
多核模式(暂缓)
测试
http://www.yidianzixun.com/article/0MGEbPPZ
结尾
本文借鉴了多个博客,若有错误或者哪里讲的不清楚,请留言