《微信 libco 协程库原理剖析.docx》由会员分享,可在线阅读,更多相关《微信 libco 协程库原理剖析.docx(16页珍藏版)》请在taowenge.com淘文阁网|工程机械CAD图纸|机械工程制图|CAD装配图下载|SolidWorks_CaTia_CAD_UG_PROE_设计图分享下载上搜索。
1、微信libco协程库原理剖析alexzmzheng同Go语言一样libco也是提供了同步风格编程形式同时还能保证系统的高并发才能本文主要剖析libco中的协程原理。简介libco是微信后台大规模使用的c/c协程库2021年度至今稳定运行在微信后台的数万台机器上。libco通过仅有的几个函数接口co_create/co_resume/co_yield再配合co_poll可以支持同步或异步的写法如线程库一样轻松。同时库里面提供了socket族函数的hook使得后台逻辑效劳几乎不用修改逻辑代码就可以完成异步化改造。开源地址s:/github/Tencent/libco准备知识协程是什么协程本质上就是
2、用户态线程又名纤程将调度的代码在用户态重新实现。有极高的执行效率因为子程序切换不是线程切换而是由程序自身控制没有线程切换的开销。协程通常是纯软件实现的多任务与CPU以及操作系统通常没有关系跨平台跨体系架构。协程在执行经过中可以调用别的协程自己那么中途退出执行之后又从调用别的协程的地方恢复执行。这有点像操作系统的线程执行经过中可能被挂起让位于别的线程执行稍后又从挂起的地方恢复执行。对于线程而言其上下文切换流程如下需要两次权限等级切换以及三次栈切换。上下文存储在内核栈上。线程的上下文切换必须先进入内核态并切换上下文,这就造成了严重的调度开销。线程的构造体存在于内核中在pthread_create时
3、需要进入内核态频繁创立开销大。Linux程序内存布局Linux使用虚拟地址空间大大增加了进程的寻址空间由低地址到高地址分别为只读段/代码段只能读不可写可执行代码、字符串字面值、只读变量数据段已初始化且初值非0全局变量、静态变量的空间BSS段未初始化或者初值为0的全局变量以及静态部分变量堆就是平时所讲的动态内存malloc/new大局部都来源于此。文件映射区域如动态库、分享内存等映射物理空间的内存一般是mmap函数所分配的虚拟地址空间。栈用于维护函数调用的上下文空间部分变量、函数参数、返回地址等内核虚拟空间用户代码不可见的内存区域由内核管理(页表就存放在内核虚拟空间)。其中需要注意的是栈以及堆的
4、这两种不同的地址增长方向栈从高到低地址增长。堆从低到高增长后面协程切换中就涉及到该布局的不同。栈帧栈帧是从栈上分配的一段内存每次函数调用时用于存储自动变量。从物理介质角度看栈帧是位于esp栈指针及ebp基指针之间的一块区域。每个栈帧对应着一个未运行完的函数。栈帧中保存了该函数的函数参数、返回地址以及部分变量等数据。部分变量等分配均在栈帧上分配函数完毕自动释放。ESP栈指针存放器指向当前栈帧的栈顶。EBP基址指针存放器指向当前栈帧的底部。C函数调用调用者将一些参数放在栈上调用函数然后弹出栈上存放的参数。这里涉及调用约定调用约定涉及参数的入栈顺序从左到右还是从右到左、参数入栈以及清理的是调用者(c
5、aller)还是被调用者(callee)函数名的处理。采用_cdecl调用约定的调用者会将参数从右到左的入栈最后将返回地址入栈。这个返回地址是指函数调用完毕后的下一行执行的代码地址。_cdeclisthedefaultcallingconventionforCandCprograms.Becausethestackiscleanedupbythecaller,itcandovarargfunctions.The_cdeclcallingconventioncreateslargerexecutablesthan_stdcall,becauseitrequireseachfunctioncall
6、toincludestackcleanupcode.Thefollowinglistshowstheimplementationofthiscallingconvention.The_cdeclmodifierisMicrosoft-specific.关键数据构造libco的协程控制块stCoRoutine_tstructstCoRoutine_tstCoRoutineEnv_t*env;pfn_co_routine_tpfn;void*arg;coctx_tctx;charcStart;charcEnd;charcIsMain;charcEnableSysHook;charcIsShareS
7、tack;void*pvEnv;/charsRunStack1024*128;stStackMem_t*stack_mem;/savestackbufferwhileconfilctonsamestack_buffer;char*stack_sp;unsignedintsave_size;char*save_buffer;stCoSpec_taSpec1024;env即协程执行的环境libco协程一旦创立便跟对应线程绑定了不支持在不同线程间迁移这里env即同属于一个线程所有协程的执行环境包括了当前运行协程、嵌套调用的协程栈以及一个epoll的封装构造。这个构造是跟运行的线程绑定了的运行在同一个
8、线程上的各协程是分享该构造的是个全局性的资源。structstCoRoutineEnv_tstCoRoutine_t*pCallStack128;intiCallStackSize;stCoEpoll_t*pEpoll;/forcopystackloglastcoandnextcostCoRoutine_t*pending_co;stCoRoutine_t*occupy_co;pfn实际等待执行的协程函数arg上面协程函数的参数ctx上下文即ESP、EBP、EIP以及其他通用存放器的值structcoctx_t#ifdefined(_i386_)void*regs8;#elsevoid*reg
9、s14;#endifsize_tss_size;char*ss_sp;cStart、cEnd、cIsMain、cEnableSysHook、cIsShareStack一些状态以及标志变量后面会细讲pvEnv保存程序系统环境变量的指针stack_mem协程运行时的栈内存这个栈内存是固定的128KB的大小。structstStackMem_tstCoRoutine_t*occupy_co;intstack_size;char*stack_bp;/stack_bufferstack_sizechar*stack_buffer;stack_sp、save_size、save_buffer这里要提到实现
10、stackful协程与之相对的还有一种stackless协程的两种技术Separatecoroutinestacks以及Copyingthestack又叫分享栈。这三个变量就是用来实现这两种技术的。实现细节上前者为每一个协程分配一个单独的、固定大小的栈而后者那么仅为正在运行的协程分配栈内存当协程被调度切换出去时就把它实际占用的栈内存copy保存到一个单独分配的缓冲区当被切出去的协程再次调度执行时再一次copy将原来保存的栈内存恢复到那个分享的、固定大小的栈内存空间。假如是独享栈形式分配在堆中的一块作为当前协程栈帧的内存stack_mem这块内存的默认大小为128K。假如是分享栈形式协程切换的时
11、候用来拷贝存储当前分享栈内容的save_buffer长度为实际的分享栈使用长度。通常情况下一个协程实际占用的从esp到栈底栈空间相比预分配的这个栈大小比方libco的128KB会小得多这样一来copyingstack的实现方案所占用的内存便会少很多。当然协程切换时拷贝内存的开销有些场景下也是很大的。因此两种方案各有利弊而libco那么同时实现了两种方案默认使用前者也允许用户在创立协程时指定使用分享栈。生命周期创立协程Createcoroutine调用co_create将协程创立出来后这时候它还没有启动也即是讲我们传递的routine函数还没有被调用。本质上这个函数内部仅仅是分配并初始化stCo
12、Routine_t构造体、设置任务函数指针、分配一段“栈内存和分配以及初始化coctx_t。ppco输出参数co_create内部为新协程分配一个协程控制块ppco将指向这个分配的协程控制块。attr指定要创立协程的属性栈大小、指向分享栈的指针使用分享栈形式pfn协程的任务业务逻辑函数arg传递给任务函数的参数intco_create(stCoRoutine_t*ppco,conststCoRoutineAttr_t*attr,pfn_co_routine_tpfn,void*arg)if(!co_get_curr_thread_env()co_init_curr_thread_env();s
13、tCoRoutine_t*coco_create_env(co_get_curr_thread_env(),attr,pfn,arg);*ppcoco;return0;启动协程Resumecoroutine在调用co_create创立协程返回成功后便可以调用co_resume函数将它启动了。取当前协程控制块指针将待启动的协程压入pCallStack栈然后co_swap切换到指向的新协程上取执行co_swap不会就此返回而是要等当前执行的协程主动让出cpu时才会让新的协程切换上下文来执行自己的内容。voidco_resume(stCoRoutine_t*co)stCoRoutineEnv_t*e
14、nvco-stCoRoutine_t*lpCurrRoutineenv-pCallStackenv-iCallStackSize-1;if(!co-cStart)coctx_make(co-ctx,(coctx_pfn_t)CoRoutineFunc,co,0);co-cStartenv-pCallStackenv-iCallStackSizeco;co_swap(lpCurrRoutine,co);挂起协程Yieldcoroutine在非对称协程理论yield与resume是个相对的操作。A协程resume启动了B协程那么只有当B协程执行yield操作时才会返回到A协程。在上一节剖析协程启动
15、函数co_resume()时也提到了该函数内部co_swap()会执行被调协程的代码。只有被调协程yield让出CPU调用者协程的co_swap()函数才能返回到原点即返回到原来co_resume()内的位置。在被调协程要让出CPU时会将它的stCoRoutine_t从pCallStack弹出“栈指针iCallStackSize减1然后co_swap()切换CPU上下文到原来被挂起的调用者协程恢复执行。这里“被挂起的调用者协程即是调用者co_resume()中切换CPU上下文被挂起的那个协程。voidco_yield_env(stCoRoutineEnv_t*env)stCoRoutine_t
16、*lastenv-pCallStackenv-iCallStackSize-2;stCoRoutine_t*currenv-pCallStackenv-iCallStackSize-1;env-iCallStackSize-;co_swap(curr,last);voidco_yield_ct()co_yield_env(co_get_curr_thread_env();voidco_yield(stCoRoutine_t*co)co_yield_env(co-env);同一个线程上所有协程是分享一个stCoRoutineEnv_t构造的因此任意协程的co-env指向的构造都一样。切换协程Sw
17、itchcoroutine上面的启动协程以及挂起协程都设计协程的切换本质是上下文的切换发生在co_swap()中。假如是独享栈形式将当前协程的上下文存好读取下一协程的上下文。假如是分享栈形式libco对分享栈做了个优化可以申请多个分享栈循环使用当目的协程所记录的分享栈没有被其它协程占用的时候整个切换经过以及独享栈形式一致。否那么就是将协程的栈空间内容从分享栈拷贝到自己的save_buffer中将下一协程的save_buffer中的栈内容拷贝到分享栈中将当前协程的上下文存好读取下一协程上下文。协程的本质是使用ContextSwap来代替汇编中函数call调用在保存存放器上下文后把需要执行的协程入
18、口push到栈上。voidco_swap(stCoRoutine_t*curr,stCoRoutine_t*pending_co)stCoRoutineEnv_t*envco_get_curr_thread_env();/getcurrstackspcharc;curr-stack_spif(!pending_co-cIsShareStack)env-pending_coNULL;env-occupy_coNULL;elseenv-pending_copending_co;/getlastoccupycoonthesamestackmemstCoRoutine_t*occupy_copendi
19、ng_co-stack_mem-occupy_co;/setpendingcotooccupytheststackmem;pending_co-stack_mem-occupy_copending_co;env-occupy_cooccupy_co;if(occupy_cooccupy_co!pending_co)save_stack_buffer(occupy_co);/swapcontextcoctx_swap(curr-ctx),(pending_co-ctx);/stackbuffermaybeoverwrite,sogetagain;stCoRoutineEnv_t*curr_env
20、co_get_curr_thread_env();stCoRoutine_t*update_occupy_cocurr_env-occupy_co;stCoRoutine_t*update_pending_cocurr_env-pending_co;if(update_occupy_coupdate_pending_coupdate_occupy_co!update_pending_co)/resumestackbufferif(update_pending_co-save_bufferupdate_pending_co-save_size0)memcpy(update_pending_co-
21、stack_sp,update_pending_co-save_buffer,update_pending_co-save_size);这里起存放器拷贝切换作用的coctx_swap函数是用汇编来实现的。coctx_swap承受两个参数第一个是当前协程的coctx_t指针第二个参数是待切入的协程的coctx_t指针。该函数调用前还处于第一个协程的环境调用之后就变成另一个协程的环境了。externCexternvoidcoctx_swap(coctx_t*,coctx_t*)asm(coctx_swap;.globlcoctx_swap#if!defined(_APPLE_).typecoctx
22、_swap,function#endifcoctx_swap:#ifdefined(_i386_)movl4(%esp),%eaxmovl%esp,28(%eax)movl%ebp,24(%eax)movl%esi,20(%eax)movl%edi,16(%eax)movl%edx,12(%eax)movl%ecx,8(%eax)movl%ebx,4(%eax)movl8(%esp),%eaxmovl4(%eax),%ebxmovl8(%eax),%ecxmovl12(%eax),%edxmovl16(%eax),%edimovl20(%eax),%esimovl24(%eax),%ebpmo
23、vl28(%eax),%espret#elifdefined(_x86_64_)leaq(%rsp),%raxmovq%rax,104(%rdi)movq%rbx,96(%rdi)movq%rcx,88(%rdi)movq%rdx,80(%rdi)movq0(%rax),%raxmovq%rax,72(%rdi)movq%rsi,64(%rdi)movq%rdi,56(%rdi)movq%rbp,48(%rdi)movq%r8,40(%rdi)movq%r9,32(%rdi)movq%r12,24(%rdi)movq%r13,16(%rdi)movq%r14,8(%rdi)movq%r15,(
24、%rdi)xorq%rax,%raxmovq48(%rsi),%rbpmovq104(%rsi),%rspmovq(%rsi),%r15movq8(%rsi),%r14movq16(%rsi),%r13movq24(%rsi),%r12movq32(%rsi),%r9movq40(%rsi),%r8movq56(%rsi),%rdimovq80(%rsi),%rdxmovq88(%rsi),%rcxmovq96(%rsi),%rbxleaq8(%rsp),%rsppushq72(%rsi)movq64(%rsi),%rsiret#endif退出协程同协程挂起一样协程退出时也应将CPU控制权交给
25、它的调用者这也是调用co_yield_env()函数来完成的。我们调用co_create()、co_resume()启动协程执行一次性任务当任务完毕后要记得调用co_free()或者co_release()销毁这个临时性的协程否那么将引起内存泄漏。voidco_free(stCoRoutine_t*co)if(!co-cIsShareStack)free(co-stack_mem-stack_buffer);free(co-stack_mem);/walkerdufixat2018-01-20/存在内存泄漏elseif(co-save_buffer)free(co-save_buffer);i
26、f(co-stack_mem-occupy_coco)co-stack_mem-occupy_coNULL;free(co);voidco_release(stCoRoutine_t*co)co_free(co);补充协程的调度co_eventloop()即“调度器的核心所在。这里讲的“调度器严格意义上算不上真正的调度器只是为了表述的方便。libco的协程机制是非对称的没有什么调度算法。在执行yield时当前协程只能将控制权交给调用者协程没有任何可调度的余地。Resume灵敏性稍强一点不过也还算不得调度。假如非要讲有什么“调度算法的话那就只能讲是“基于epoll/kqueue事件驱动的调度算法
27、。“调度器就是epoll/kqueue的事件循环。voidco_eventloop(stCoEpoll_t*ctx,pfn_co_eventloop_tpfn,void*arg)if(!ctx-result)ctx-resultco_epoll_res_alloc(stCoEpoll_t:_EPOLL_SIZE);co_epoll_res*resultctx-result;for(;)intretco_epoll_wait(ctx-iEpollFd,result,stCoEpoll_t:_EPOLL_SIZE,1);stTimeoutItemLink_t*active(ctx-pstActiv
28、eList);stTimeoutItemLink_t*timeout(ctx-pstTimeoutList);memset(timeout,0,sizeof(stTimeoutItemLink_t);for(intiii)stTimeoutItem_t*item(stTimeoutItem_t*)result-eventsi.data.ptr;if(item-pfnPrepare)item-pfnPrepare(item,result-eventsi,active);elseAddTail(active,item);unsignedlonglongnowGetTickMS();TakeAllT
29、imeout(ctx-pTimeout,now,timeout);stTimeoutItem_t*lptimeout-head;while(lp)/printf(raisetimeout%pn,lp);lp-bTimeouttrue;lplp-pNext;JoinstTimeoutItem_t,stTimeoutItemLink_t(active,timeout);lpactive-head;while(lp)PopHeadstTimeoutItem_t,stTimeoutItemLink_t(active);if(lp-bTimeoutnowlp-ullExpireTime)intretAd
30、dTimeout(ctx-pTimeout,lp,now);if(!ret)lp-bTimeoutfalse;lpactive-head;continue;if(lp-pfnProcess)lp-pfnProcess(lp);lpactive-head;if(pfn)if(-1pfn(arg)break;在关键数据构造stCoRoutineEnv_t中有一个变量stCoEpoll_t类型的指针即与epoll事件循环相关。iEpollFdepoll实例的文件描绘符_EPOLL_SIZE一次epoll_wait最多返回的就绪事件个数pTimeout时间轮定时器pstTimeoutList存放超时事
31、件pstActiveList存放就绪事件/超时事件resultepoll_wait得到的结果集structstCoEpoll_tintiEpollFd;staticconstint_EPOLL_SIZE1024*10;structstTimeout_t*pTimeout;structstTimeoutItemLink_t*pstTimeoutList;structstTimeoutItemLink_t*pstActiveList;co_epoll_res*result;一般而言使用定时功能时我们首先向定时器中注册一个定时事件TimerEvent在注册定时事件时需要指定这个事件在将来的触发时间。
32、在到了触发时间点后我们会收到定时器的通知。网络框架里的定时器可以看做由两局部组成第一局部是保存已注册timerevents的数据构造第二局部那么是定时通知机制。保存已注册的timerevents一般选用红黑树比方nginx另外一种常见的数据构造便是时间轮libco就使用了这种构造。第二局部是高精度的定时准确到微秒级通知机制一般使用getitimer/setitimer这类接口用epoll/kqueue这样的系统调用来完成定时通知。何时挂起何时恢复libco中有3种调用yield的场景用户程序中主动调用co_yield_ct()程序调用了epoll()或者co_cond_timedwait()陷入“阻塞等待程序调用了connect(),read(),write(),recv(),send()等系统调用陷入“阻塞等待。resume启动一个协程有3种情况对应用户程序主动yield的情况这种情况也有赖于用户程序主动将协程co_resume()起来epoll()的目的文件描绘符事件就绪或者超时co_cond_timedwait()等到了其他协程的co_cond_signal()通知信号或者等待超时read(),write()等I/O接口成功读到或者写入数据或读写超时。腾讯程序员视频号最新视频腾讯技术工程