linux源码解读(二十八):通过epoll实现协程(二)
1、协程只是一种思路,并且没有操作系统层面的参与,所以全靠3环的应用开发人员自己实现。市面上有各种协程框架,这里以微信的libco库为例,看看协程到底是怎么落地实现的!libco 是微信后台开发和使用的协程库,号称可以调度千万级协程;从使用上来说,libco 不仅提供了一套类 pthread 的协程通信机制,同时可以零改造地将三方库的阻塞 IO 调用进行协程化;正式介绍libco的源码前,先直观感受一下libco的效果,demo代码如下:
void A() { cout << 1 << " "; cout << 2 << " "; cout << 3 << " "; } void B() { cout << "x" << " "; cout << "y" << " "; cout << "z" << " "; } int main(void) { A(); B(); }
这个代码很简单,刚开始学编程的人都能看懂,结果如下:
1 2 3 x y z
如果用libco的协程api在A和B函数之间切换(注意这是简化后的伪代码,目的是抓住主干,避免被细枝末节的代码干扰),如下:
void A() { cout << 1 << " "; cout << 2 << " "; co_yield_ct(); // 切出到主协程 cout << 3 << " "; } void B() { cout << "x" << " "; co_yield_ct(); // 切出到主协程 cout << "y" << " "; cout << "z" << " "; } int main(void) { ... // 主协程 co_resume(A); // 启动协程 A co_resume(B); // 启动协程 B co_resume(A); // 从协程 A 切出处继续执行 co_resume(B); // 从协程 B 切出处继续执行 }
这时的结果就变了:
1 2 x 3 y z
可以看到代码在A和B函数之间来回切换执行,整个切换的顺序完全依靠co_yield_ct和co_resume两个函数人为控制!这样就实现了A函数阻塞时人为切换到B函数执行;B函数阻塞时再切换到A函数继续执行,不浪费一点CPU时间片!
2、之前解读linux源码时,遇到某些功能时都是先看结构体,再阅读函数功能,原因很简单:重要的字段和数据都会放在结构体中统一管理(本质是能快速寻址,利于读写),函数的所有代码都是围绕结构体中这些数据读写展开的!libco重要的结构体之一就是stCoRoutine_t(从名称包含routine就能大概才出来结构体和执行的函数相关),定义如下:
/*协程跳转(也即是yield、resume)执行的函数结构体, 包含了协程运行最重要的3要素: 协程运行环境、执行函数的入口、函数参数 */ struct stCoRoutine_t { stCoRoutineEnv_t *env;//协程运行环境 pfn_co_routine_t pfn;// 协程跳转执行的函数 void *arg;//函数的参数 coctx_t ctx;//保存协程的下文环境 char cStart;//协程是否开始 char cEnd;//协程是否结束 char cIsMain;//当前是main函数吗 char cEnableSysHook; //是否运行系统 hook,即非侵入式逻辑 char cIsShareStack;//多个协程之间是否共享栈 void *pvEnv; //char sRunStack[ 1024 * 128 ]; stStackMem_t* stack_mem;// 协程运行时的栈空间 //save stack buffer while confilct on same stack_buffer; char* stack_sp; unsigned int save_size; char* save_buffer; stCoSpec_t aSpec[1024]; };
看吧,这个结构体几乎包含了协程子重要的几个元素:协程的调度环境、协程要运行的函数及参数,协程切换时要保存的上下文等!这些结构体之间的关系如下:
结构体有了,接下来就是初始化这个结构体了,再co_create_env函数中做的,核心是把传入的函数入口、参数、env等变量纳入stCoRoutine_t统一管理,同时初始化栈和其他变量!
/*创建协程 env:协程跳转执行函数的结构体,也可以理解为函数的环境 pfn:协程跳转执行的函数入口 初始化stCoRoutine_t *lp结构体 */ struct stCoRoutine_t *co_create_env( stCoRoutineEnv_t * env, const stCoRoutineAttr_t* attr, pfn_co_routine_t pfn,void *arg ) { stCoRoutineAttr_t at; if( attr ) { memcpy( &at,attr,sizeof(at) ); } if( at.stack_size <= 0 ) { at.stack_size = 128 * 1024;//协程栈128k } else if( at.stack_size > 1024 * 1024 * 8 )//不能超过8M { at.stack_size = 1024 * 1024 * 8; } if( at.stack_size & 0xFFF ) { at.stack_size &= ~0xFFF;//栈大小的低12bit清零,也就是页对齐 at.stack_size += 0x1000; } stCoRoutine_t *lp = (stCoRoutine_t*)malloc( sizeof(stCoRoutine_t) ); memset( lp,0,(long)(sizeof(stCoRoutine_t))); /*stCoRoutine_t包含了协程运行最重要的3要素:环境、函数入口和参数*/ lp->env = env; lp->pfn = pfn; lp->arg = arg; stStackMem_t* stack_mem = NULL; if( at.share_stack )//如果用共享内存 { stack_mem = co_get_stackmem( at.share_stack); at.stack_size = at.share_stack->stack_size; } else//否则重新分配协程栈 { stack_mem = co_alloc_stackmem(at.stack_size); } lp->stack_mem = stack_mem; lp->ctx.ss_sp = stack_mem->stack_buffer; lp->ctx.ss_size = at.stack_size; lp->cStart = 0; lp->cEnd = 0; lp->cIsMain = 0; lp->cEnableSysHook = 0; lp->cIsShareStack = at.share_stack != NULL; lp->save_size = 0; lp->save_buffer = NULL; return lp; }
协程结构体初始化完成后就该使用了吧,还记得文章开头的那个demo案例么?main里面直接调用的co_resume函数,如下:
void co_resume( stCoRoutine_t *co ) { stCoRoutineEnv_t *env = co->env; // 获取当前正在运行的协程的结构 // 每次有新协程产生就放入数组管理 stCoRoutine_t *lpCurrRoutine = env->pCallStack[ env->iCallStackSize - 1 ]; if( !co->cStart ) { // 为将要运行的 co 布置上下文环境:初始化协程的栈,并把函数参数、返回地址入栈 coctx_make( &co->ctx,(coctx_pfn_t)CoRoutineFunc,co,0 ); co->cStart = 1; } //需要恢复的协程加入数组,表示正在运行 env->pCallStack[ env->iCallStackSize++ ] = co; co_swap( lpCurrRoutine, co ); }
唯一的参数就是协程结构体;前面做的都是各种准备工作,最关键的就是最后一个co_swap函数了,从名字和参数看就知道是协程结构体(本质上就是函数)互相切换!
void co_swap(stCoRoutine_t* curr, stCoRoutine_t* pending_co) { stCoRoutineEnv_t* env = co_get_curr_thread_env(); //get curr stack sp char c; /*记录curr协程栈位置,后续切换回curr时可用于恢复栈内容*/ curr->stack_sp= &c; if (!pending_co->cIsShareStack) { env->pending_co = NULL; env->occupy_co = NULL; } else //共享栈模式 { env->pending_co = pending_co; //get last occupy co on the same stack mem //取出共享栈中已有的协程 stCoRoutine_t* occupy_co = pending_co->stack_mem->occupy_co; //set pending co to occupy thest stack mem; //在共享栈中记录挂起的协程 pending_co->stack_mem->occupy_co = pending_co; /*env记录pending和occupy两个协程,便于切换后仍然能找到*/ env->occupy_co = occupy_co; if (occupy_co && occupy_co != pending_co) { /*换个地方保存协程*/ save_stack_buffer(occupy_co); } } //swap context /*协程切换最核心的函数:切换通用寄存器、esp+ebp、eip;*/ coctx_swap(&(curr->ctx),&(pending_co->ctx) ); //stack buffer may be overwrite, so get again; stCoRoutineEnv_t* curr_env = co_get_curr_thread_env(); stCoRoutine_t* update_occupy_co = curr_env->occupy_co; stCoRoutine_t* update_pending_co = curr_env->pending_co; if (update_occupy_co && update_pending_co && update_occupy_co != update_pending_co) { //resume stack buffer if (update_pending_co->save_buffer && update_pending_co->save_size > 0) { memcpy(update_pending_co->stack_sp, update_pending_co->save_buffer, update_pending_co->save_size); } } }
co_swap最核心的莫过于coctx_swap了,由于涉及到寄存器操作,C语言已经无能为力,这里直接用汇编简单粗暴来做:先把curr的上下文push到curr的栈里,然后通过movq %rsi, %rsp把rsp切换到pending的栈,最后通过一系列的pop把pending的context赋值给寄存器,最后一句ret把此时栈顶的地址赋值给eip,由此完成切换!
.globl coctx_swap .type coctx_swap, @function coctx_swap: leaq 8(%rsp),%rax # rax=(*rsp) + 8; # 此时栈顶元素是当前的%rip(即当前协程挂起后被再次唤醒时,需要执行的下一条指令 # 的地址),后面会把栈顶的这个地址保存到curr->ctx->regs[9]中,所以保存rsp的 # 时候就跳过这8个字节了 leaq 112(%rdi),%rsp#rsp=(*rdi) + (8*14); # %rdi存放的是函数第一个参数的地址,即curr->ctx的地址 # 然后加上需要保存的14个寄存器的长度,使rsp指向curr->ctx->regs[13] pushq %rax # curr->ctx->regs[13] = rax; # 保存rsp,看第一行代码的注释 pushq %rbx # curr->ctx->regs[12] = rbx; pushq %rcx # curr->ctx->regs[11] = rcx; pushq %rdx # curr->ctx->regs[10] = rcx; pushq -8(%rax) # curr->ctx->regs[9] = (*rax) - 8; # 把协程挂起后被再次唤醒时,需要执行的下一条指令的地址保存起来 pushq %rsi # curr->ctx->regs[8] = rsi; pushq %rdi # curr->ctx->regs[7] = rdi; pushq %rbp # curr->ctx->regs[6] = rbp; pushq %r8 # curr->ctx->regs[5] = r8; pushq %r9 # curr->ctx->regs[4] = r9; pushq %r12 # curr->ctx->regs[3] = r12; pushq %r13 # curr->ctx->regs[2] = r13; pushq %r14 # curr->ctx->regs[1] = r14; pushq %r15 # curr->ctx->regs[0] = r15; movq %rsi, %rsp # rsp = rsi; # rsi中存放的是函数的第二个参数的地址,即使rsp指向pending_co->ctx->regs[0] popq %r15 # r15 = pending_co->ctx->regs[0]; popq %r14 # r14 = pending_co->ctx->regs[1]; popq %r13 # r13 = pending_co->ctx->regs[2]; popq %r12 # r12 = pending_co->ctx->regs[3]; popq %r9 # r9 = pending_co->ctx->regs[4]; popq %r8 # r8 = pending_co->ctx->regs[5]; popq %rbp # rbp = pending_co->ctx->regs[6]; popq %rdi # rdi = pending_co->ctx->regs[7]; popq %rsi # rsi = pending_co->ctx->regs[8]; popq %rax # rax = pending_co->ctx->regs[9]; # 对照前面,ctx->regs[9]中存放的是协程被唤醒后需要执行的下一条指令的地址 popq %rdx # rdx = pending_co->ctx->regs[10]; popq %rcx # rcx = pending_co->ctx->regs[11]; popq %rbx # rbx = pending_co->ctx->regs[12]; popq %rsp # rsp = pending_co->ctx->regs[13]; rsp += 8; # 这句代码是理解整个过程的关键。和coctx_make函数中保存rsp时减8再保存相对应。 pushq %rax # rsp -= 8;*rsp = rax; # 此时栈顶元素就是协程被唤醒后需要执行的下一条指令的地址了 xorl %eax, %eax # eax = 0; # 使eax清零,eax中的内容作为函数的返回值 ret # 相当于popq %rip 这样就可以唤醒上次挂起的协程,接着运行
自此,调用resume完成了函数执行的切换!resume的代码分析完了,轮到另一个yield了!其实这两个函数核心功能都是切换函数,所以底层调用的都是co_swap,如下:
void co_yield_env( stCoRoutineEnv_t *env ) { //从数组从分别取出两个协程用于交换 stCoRoutine_t *last = env->pCallStack[ env->iCallStackSize - 2 ]; stCoRoutine_t *curr = env->pCallStack[ env->iCallStackSize - 1 ]; env->iCallStackSize--; co_swap( curr, last); }
看吧,代码是不是超级简单了?
3、上述代码完美地在一个线程内部完成了不同代码之间的切换,不过都是人工手动掌控切换时机的,如果是网络IO了?开发人员是无法精准预测收发数据时机的,所以也没法人为精准“埋点”resume、yield做协程切换,这种业务场景该怎么处理了?之前用的是epoll来监听socket是否有事件到达,这里该怎么复用epoll了?在example_echosrv.c文件中,微信官方提供了服务端协程的用例,代码不多,但是涉及到main、readwrite、accept等多个方法之间的来回切换,并且方法内部还有for死循环,整个流程比较复杂, 我画了个简单的草图,如下:
可以看出:整个过程只有一个线程;里面有专门负责接受客户端连接的accept_co协程,也有监听事件的mian主协程,也有添加事件和负责读写的readwrite_so!epoll还是用于网络IO的事件监听和触发,接着就是通过yield、resume切换导到合适的协程处理这些io事件!整个过程逻辑上不算难,就是很繁杂,需要静下心来慢慢捋!为了实现整个流程,有几个关键的函数需要着重说明。
(1)libco为了对统一网络IO,条件变量需要超时管理的事件,实现了基于时间轮(timing wheel)的超时管理器; 时间轮为图中深红色的轮状数组,数组的每一个单元我们称为一个槽(slot)。单个slot里存储一定时间内注册的事件列表(图中黄色链表)。在libco中,单个slot的精度为1毫秒(刚好是jiffies),整个时间轮由60000个slot组成,对应的整个时间轮覆盖60秒的时间;
为了实现时间轮,两个核心的方法如下:
/* 在时间轮中插入新项 * @param * apTimeout :时间轮结构 * apItem :新的超时项 * allNow :当前事件(timestamp in ms) * @return :0成功, else失败行数 */ int AddTimeout( stTimeout_t *apTimeout,stTimeoutItem_t *apItem ,unsigned long long allNow ) { if( apTimeout->ullStart == 0 ) { apTimeout->ullStart = allNow;// 设置时间轮的最早时间是当前时间 apTimeout->llStartIdx = 0; } /* 当前时间小于初始时间出错返回 */ if( allNow < apTimeout->ullStart ) { co_log_err("CO_ERR: AddTimeout line %d allNow %llu apTimeout->ullStart %llu", __LINE__,allNow,apTimeout->ullStart); return __LINE__; } /* 当前时间大于超时时间出错返回 */ if( apItem->ullExpireTime < allNow ) { co_log_err("CO_ERR: AddTimeout line %d apItem->ullExpireTime %llu allNow %llu apTimeout->ullStart %llu", __LINE__,apItem->ullExpireTime,allNow,apTimeout->ullStart); return __LINE__; } // 计算超时时间 unsigned long long diff = apItem->ullExpireTime - apTimeout->ullStart; /* 超时时间大于时间轮的最长时间出错返回 */ if( diff >= (unsigned long long)apTimeout->iItemSize ) { diff = apTimeout->iItemSize - 1; co_log_err("CO_ERR: AddTimeout line %d diff %d", __LINE__,diff); //return __LINE__; } /* 将时间加入到时间轮中 */ AddTail( apTimeout->pItems + ( apTimeout->llStartIdx + diff ) % apTimeout->iItemSize , apItem ); return 0; } /* 在时间轮中取出所有超时项 * @param * apTimeout:时间轮结构 * allNow :当前时间(timestamp in ms) * apResult :超时事件结果链表 */ inline void TakeAllTimeout( stTimeout_t *apTimeout,unsigned long long allNow,stTimeoutItemLink_t *apResult ) { if( apTimeout->ullStart == 0 ) { apTimeout->ullStart = allNow; apTimeout->llStartIdx = 0; } if( allNow < apTimeout->ullStart ) { return ; } int cnt = allNow - apTimeout->ullStart + 1; if( cnt > apTimeout->iItemSize ) { cnt = apTimeout->iItemSize; } if( cnt < 0 ) { return; } for( int i = 0;i<cnt;i++) { int idx = ( apTimeout->llStartIdx + i) % apTimeout->iItemSize; Join<stTimeoutItem_t,stTimeoutItemLink_t>( apResult,apTimeout->pItems + idx ); } apTimeout->ullStart = allNow; apTimeout->llStartIdx += cnt - 1; }
(2)每个main函数都需要调用的方法,用于不停的监听是否有事件发生,如下:
/* 事件循环:不停的监听是否有事件发生 * @param * ctx:epoll句柄 * pfn:退出事件循环检查函数 * arg:pfn参数 */ void co_eventloop( stCoEpoll_t *ctx,pfn_co_eventloop_t pfn,void *arg ) { if( !ctx->result ) { ctx->result = co_epoll_res_alloc( stCoEpoll_t::_EPOLL_SIZE ); } co_epoll_res *result = ctx->result; for(;;) { int ret = co_epoll_wait( ctx->iEpollFd,result,stCoEpoll_t::_EPOLL_SIZE, 1 ); stTimeoutItemLink_t *active = (ctx->pstActiveList); stTimeoutItemLink_t *timeout = (ctx->pstTimeoutList); memset( timeout,0,sizeof(stTimeoutItemLink_t) ); //清空超时队列 for(int i=0;i<ret;i++)//遍历有事件的fd { //获取event里数据指向的stTimeoutItem_t stTimeoutItem_t *item = (stTimeoutItem_t*)result->events[i].data.ptr; if( item->pfnPrepare )//如果有预处理函数,执行,由其加入就绪列表 { item->pfnPrepare( item,result->events[i],active ); } else//手动加入就绪列表 { AddTail( active,item ); } } unsigned long long now = GetTickMS(); /*时间轮中取出所有的超时项,并插入超时列表*/ TakeAllTimeout( ctx->pTimeout,now,timeout ); stTimeoutItem_t *lp = timeout->head; while( lp ) { //printf("raise timeout %p\n",lp); lp->bTimeout = true;//设置为超时 lp = lp->pNext; } //将超时列表合并入就绪列表 Join<stTimeoutItem_t,stTimeoutItemLink_t>( active,timeout ); lp = active->head; while( lp ) { PopHead<stTimeoutItem_t,stTimeoutItemLink_t>( active ); if (lp->bTimeout && now < lp->ullExpireTime) { //还未达到超时时间但已经标记为超时的,加回时间轮 int ret = AddTimeout(ctx->pTimeout, lp, now); if (!ret) { lp->bTimeout = false; lp = active->head; continue; } } /*调用stTimeoutItem_t项的执行函数,也就是OnPollProcessEvent 里面有co_resume*/ if( lp->pfnProcess ) { lp->pfnProcess( lp ); } lp = active->head; } if( pfn )//用于用户控制跳出事件循环 { if( -1 == pfn( arg ) ) { break; } } } }
(3)将 fd 交由 Epoll 管理,待 Epoll 的相应的事件触发时,再切换回来执行 read 或者 write 操作,从而实现由 Epoll 管理协程,如下:
/* poll内核:将 fd 交由 Epoll 管理,待 Epoll 的相应的事件触发时, 再切换回来执行 read 或者 write 操作, 从而实现由 Epoll 管理协程的功能 * @param * ctx:epoll句柄 * fds:fd数组 * nfds:fd数组长度 * timeout:超时时间ms * pollfunc:默认poll */ typedef int (*poll_pfn_t)(struct pollfd fds[], nfds_t nfds, int timeout); int co_poll_inner( stCoEpoll_t *ctx,struct pollfd fds[], nfds_t nfds, int timeout, poll_pfn_t pollfunc) { if (timeout == 0) { //调用系统原生poll(其实上层poll已经做过检查了,此处无需再做) return pollfunc(fds, nfds, timeout); } if (timeout < 0) { timeout = INT_MAX; } int epfd = ctx->iEpollFd; stCoRoutine_t* self = co_self(); //1.struct change /* 1. 初始化poll相关的数据结构 */ stPoll_t& arg = *((stPoll_t*)malloc(sizeof(stPoll_t))); memset( &arg,0,sizeof(arg) ); arg.iEpollFd = epfd; arg.fds = (pollfd*)calloc(nfds, sizeof(pollfd));//分配nfds个pollfd arg.nfds = nfds; stPollItem_t arr[2]; //nfds少于2且未使用共享栈的情况下 if( nfds < sizeof(arr) / sizeof(arr[0]) && !self->cIsShareStack) { // 栈中分配 arg.pPollItems = arr; } else { arg.pPollItems = (stPollItem_t*)malloc( nfds * sizeof( stPollItem_t ) ); } memset( arg.pPollItems,0,nfds * sizeof(stPollItem_t) ); //调用co_resume(arg.pArg), 唤醒参数arg.pArg所指协程 arg.pfnProcess = OnPollProcessEvent; arg.pArg = GetCurrCo( co_get_curr_thread_env() );//得到当前运行的协程 //2. add epoll把事件加入到epoll中监控 for(nfds_t i=0;i<nfds;i++) { arg.pPollItems[i].pSelf = arg.fds + i; arg.pPollItems[i].pPoll = &arg; arg.pPollItems[i].pfnPrepare = OnPollPreparePfn;// 预处理回调函数 struct epoll_event &ev = arg.pPollItems[i].stEvent; if( fds[i].fd > -1 ) { ev.data.ptr = arg.pPollItems + i; ev.events = PollEvent2Epoll( fds[i].events ); int ret = co_epoll_ctl( epfd,EPOLL_CTL_ADD, fds[i].fd, &ev ); //事件加入epoll的监听 if (ret < 0 && errno == EPERM && nfds == 1 && pollfunc != NULL) { if( arg.pPollItems != arr ) { free( arg.pPollItems ); arg.pPollItems = NULL; } free(arg.fds); free(&arg); return pollfunc(fds, nfds, timeout); } } //if fail,the timeout would work } //3.add timeout 给时间轮添加超时时间 unsigned long long now = GetTickMS(); arg.ullExpireTime = now + timeout; int ret = AddTimeout( ctx->pTimeout,&arg,now ); int iRaiseCnt = 0; if( ret != 0 ) { co_log_err("CO_ERR: AddTimeout ret %d now %lld timeout %d arg.ullExpireTime %lld", ret,now,timeout,arg.ullExpireTime); errno = EINVAL; iRaiseCnt = -1; } else { // 把执行权交给调用此协程的协程,也就是main线程 co_yield_env( co_get_curr_thread_env() ); iRaiseCnt = arg.iRaiseCnt; } /*当 main 协程的事件循环 co_eventloop 中触发了对应的监听事件时,会恢复执行 此时,将开始执行下半段,即将上半段添加的句柄 fds 从 epoll 中移除, 清理残留的数据结构*/ { //clear epoll status and memory // 将该项从时间轮中删除 RemoveFromLink<stTimeoutItem_t,stTimeoutItemLink_t>( &arg ); for(nfds_t i = 0;i < nfds;i++) { int fd = fds[i].fd; if( fd > -1 ) { co_epoll_ctl( epfd,EPOLL_CTL_DEL,fd,&arg.pPollItems[i].stEvent ); } fds[i].revents = arg.fds[i].revents; } if( arg.pPollItems != arr ) { free( arg.pPollItems ); arg.pPollItems = NULL; } free(arg.fds); free(&arg); } return iRaiseCnt; }
总结:
1、协程:在各个不同的方法之间切换,从汇编层面看,就是jmp到不同的代码执行
参考:
1、https://www.cyhone.com/articles/analysis-of-libco/ 微信 libco 协程库源码分析
2、https://github.com/tencent/libco libco源码
3、https://www.infoq.cn/article/CplusStyleCorourtine-At-Wechat C/C++ 协程库 libco:微信怎样漂亮地完成异步化改造
4、https://zhuanlan.zhihu.com/p/27409164 libco协程上下文切换原理
5、https://cloud.tencent.com/developer/article/1459729 libco的设计与实现
6、https://nifengz.com/libco_context_swap/
7、http://kaiyuan.me/2017/07/10/libco/ libco分析
8、https://www.changliu.me/post/libco-auto/ 自动切换
9、https://blog.csdn.net/MOU_IT/article/details/115033799 事件注册poll
10、http://kaiyuan.me/2017/10/20/libco2/ 协程的管理