剑客
关注科技互联网

腾讯libco协程库学习笔记

在跟踪libco库时候发现一位网友提的issue,实在是看不下去了,哔了狗了。人家说程序员最喜欢的事是别人的项目有详细的wiki或文档,最讨厌的事情就是自己写文档,看来果真如此啊。不过libco自带了好几个例子,算是把libco的功能都展示了出来,过一遍也就知道怎么使用了。

腾讯libco协程库学习笔记

libco算是一个比较轻巧的协程库吧,看着代码不多,都是用朴实的C语言写的,而且完全不依赖于外部的ucontext或Boost.Context库,就想着读下代码彻底了解一下这个腾讯内部大规模用的协程库是怎么炼成的。从背景资料看来,一般对于新立项开发的系统,很多公司可能选择异步的方式来搞定,但是对于历史遗留的大规模同步模型的业务,异步化改造将会极具挑战性的事情,因为异步的方式需要代码分割重布,算是大换血的手术了;而如果采用协程和hook阻塞调用的方式,可以对传统同步类型业务基本无侵入的情况下享受异步带来的好处,这种手段确实很诱人。

额外想说的是,人家说隔行如隔山,其实当前在分工这么明确的环境下,隔业也同隔山啊,据说协程在游戏引擎行业早就大规模的被应用了以至于游戏开发者都不屑于提及这些,反而在互联网的后台压力越来越大的情况下,传统搞后台的兴起这个概念出来了。还有一点好奇的是,代码里面居然用了 APPLE 关键字和对kqueue异步的支持,腾讯不是一直是SuSE的粉丝么,难道后台也用到了很多BSD的服务器?

一、协程的创建和调度

libco支持的协程原语包括:co_create、co_resume、co_yield、co_yield_ct、co_release。

1.1 协程的创建管理

(1). co_create():创建一个协程。因为协程寄生于线程中的,所以每个线程需要有自己线程级别的资源来维护管理自己的协程。这里程序没有使用到线程局部存储TLS的方式,而是采用全局的stCoRoutineEnv_t类型的指针数组,然后采用线程tid进行索引的方式获取线程独立的存储结构。虽然定义上pid_t一般是int类型,但是系统一般不会用到这么大的范围,如果没有额外配置系统,默认最大的线程ID值定义在/proc/sys/kernel/pid_max为32768,所以这里使用上没什么问题,且空间浪费也不是很大。

structstCoRoutineEnv_t {
 stCoRoutine_t *pCallStack[ 128];
intiCallStackSize;
 stCoEpoll_t *pEpoll;
 stCoRoutine_t* pending_co; stCoRoutine_t* ocupy_co;
};

在创建上述线程相关资源的时候,还会自动创建一个没有执行体的主协程,同时线程中还会创建一个stCoEpoll_t结构用于异步事件相关的操作,默认侦听fd数目最多为1024×10,同时在pTimeout->pItem上还创建了60×1000个stTimeoutItemLink_t结构(但是事件最大支持20s的超时,注释说40s,这里实际是60s!!!),且后续在事件循环中,根据fd的事件状态会挂载到pstActiveList和pstTimeoutList链表上面去。

真正创建协程的函数是co_create_env(),每个协程有自己密切相关的结构stCoRoutine_t。对于协程可以分配的栈空间是128k~8M的范围,并且以4k对齐,传统的stackfull协程基本都是采用独立栈的方式实现的。

libco除了协程独立栈支持外,最大的创新是支持共享栈。原理就是通常stackfull实现所使用的fixed_stack分配的内存都用不了多少,内存浪费巨大导致系统整体创建的协程数目有限;而segment_stack的效率性能低下;所以共享栈采用的方式就是每次发生协程切换的时候,把实际用到的栈内容stack_bp-stack_sp通过save_stack_buffer来保存到malloc的内存中去,然后调用coctx_swap进行寄存器信息的切换,再把切换进来的新协程之前以相同方式保存的栈数据再拷贝到上面的共享栈空间的对应的内存位置上去(栈指针在coctx_swap已经更新完了,这里只是填补数据的作用,而且每个协程切换前后一直使用相同的共享栈,即使有局部指针也没有问题),从而大大增加了内存的利用效率。

(2). co_release、co_free

只是释放了stCoRoutine_t的资源,虽然对于共享栈没有问题,但是对于独立栈貌似连栈空间内存资源都没释放啊?

(3). co_resume、co_yield、co_yield_ct

这几个函数都是跟协程切换相关的,他们底层都会调用co_swap进行在独立栈/共享栈环境下的切换,只是在操作前会进行协程管理相关资源的更新。

co_resume可以恢复协程的执行,同时创建的协程第一次启动也是使用这个接口,并且在第一次启动的时候会初始化特殊的coctx_t结构(具体啥东西就不细究了)。在协程执行结束后,会自动设置cEnd=1,同时将自己yield出去,虽然是CPP的代码但是没有用到RAII,所以相关的资源需要调用者手动释放。

1.2 协程的切换

libco的协程调度比较的有意思,env->pCallStack[ env->iCallStackSize-1 ]永远指向了当前执行的协程,所以co_self()最终返回的就是相同的内容。

当co_resume调用时候实际是将新协程添加到这个pCallStack并iCallStackSize++,co_yield实际将当前协程和上一个协程pCallStack[ env->iCallStackSize-2 ]进行切换并iCallStackSize–。所以从这个原理上看来,pCallStack永远包含了可以被运行的协程,并通过co_resume、co_yield将协程从这个可运行Stack中进行添加和删除操作,当然大多处时候都是使用手动或者异步事件驱动来实现。在最开始环境初始化的时候创建了一个主协程,这个主协程coctx_t是默认初始化的且没有执行体(?),驻守在了pCallStack结构的顶部。

当创建协程时候传入的函数执行完毕后,会在CoRoutineFunc可见其设置co->cEnd=1;并自动将自己yield出去。

基于异步事件的程序开发,通常是由主线程不断的调用epoll_wait收取就绪和超时事件,然后对这些Item依次执行OnPollProcessEvent()函数,这个函数通常就是co_resume()让那个等待的协程继续执行下去,所以协程的调度完全算是由事件驱动完成并串行执行的。

二、协程应用开发接口

当上面创建协程、释放协程、切换协程的原语有了,就可以进行一些高级接口和特性的封装,方便协程库用户的使用,并尽可能对现有的业务代码做最小化侵入了。这些封装和接口其实也是其他协程库在设计中可以考虑实现的。

2.1 阻塞调用Hook

涉及到的接口有:co_enable_hook_sys、co_disable_hook_sys、co_is_enable_sys_hook。

在libco里面,大多数的默认阻塞例程基本都打开了sys_hook的支持了。这个Hook的原理,就是通过glibc中dlfcn.h的dlsym和RTLD_NEXT结合起来,从而给标准库函数添加钩子或者产生一个wrapper的效果。比如下面的这个常见的默认阻塞的read()函数,在没有打开sys_hook或套接字是阻塞类型的情况下,通过RTLD_NEXT将直接调用后面链接库的原始标准read()版本,否则会插入一个poll的操作,当然这个poll本身也是打了Hook的,详细的内容后面会有介绍。

staticread_pfn_tg_sys_read_func = (read_pfn_t)dlsym(RTLD_NEXT,"read");

ssize_tread(intfd,void*buf,size_tnbyte ) {
 HOOK_SYS_FUNC( read );

if( !co_is_enable_sys_hook() )
returng_sys_read_func( fd,buf,nbyte );

rpchook_t*lp = get_by_fd( fd );

if( !lp || ( O_NONBLOCK & lp->user_flag ) ) {
ssize_tret = g_sys_read_func( fd, buf, nbyte );
returnret;
 }
inttimeout = ( lp->read_timeout.tv_sec *1000)
 + ( lp->read_timeout.tv_usec / 1000);

structpollfd pf = {0};
 pf.fd = fd; 
 pf.events = ( POLLIN | POLLERR | POLLHUP );
intpollret = poll(&pf,1, timeout );

ssize_treadret = g_sys_read_func( fd, (char*)buf, nbyte );
if( readret <0)
 co_log_err("CO_ERR: read fd %d ret %ld errno %d poll ret %d timeout %d",
 fd, readret, errno, pollret, timeout);

returnreadret;
}

基本大多数的C库和系统调用函数都被打上了hook,当然原文档里面特别提到了gethostbyname(),有兴趣的可以单独深入了解一下。

2.2 异步Event

涉及到的接口有:co_poll、co_eventloop、co_get_epoll_ct。

co_get_epoll_ct() 就是用于查找返回当初在线程级初始化中,创建stCoRoutineEnv_t的时候所创建的stCoEpoll_t类型pEpoll指针。

co_eventloop() 就是对epoll_wait调用处理的一个死循环,epoll_wait会进行一个1ms超时的短时间blocking调用,然后将可用的Item添加到pstActiveList中去,然后检查超时的事件并把超时的事件也添加到pstActiveList中去。当收集了这么多的active事件后,接下来依次调用各个Item的pfnProcess函数(这个函数通常是执行co_resume唤醒协程)。

co_poll() 该接口不仅可以在用户协程程序中直接使用,在hook_sys中也是被hook成poll()的形式而被大量使用。其操作较为复杂,分为以下几个步骤:

(1). 首先创建stPoll_t对象,设置完描述符相关的参数后,最重要的是设置pfnProcess=OnPollProcessEvent、pArg=co_self();当事件就绪后就是通过这个函数和参数将自己切换回来;

(2). 将自己添加到ctx->pTimeout队列中去。关于这个ctx->pTimeout队列,其实是一个简单的链表数组结构,可以维持60x1000ms=60s的超时间隔,然后新的Item要插入进去的话,是就算其相对表头绝对超时时间的偏移长度来定位到指定的数组位置并进行插入的。当然这个接口设计的有点问题,就是poll的系统调用可以设置timeout=-1表示永不超时,但是这里的超时是必须设置且不能超过相对超时表头(理论是)60s,使用起来可能会有些误解。通过这样的数据结构,每次epoll_wait循环后取超时事件就十分方便快速了。

(3). epoll_ctl通过EPOLL_CTL_XXX将实际的事件侦听添加到操作系统中去。这里才发现poll和epoll的事件类型好像不兼容,所以两者常常会用函数转来转去的。

(4). co_yield_env()将自己切换出去;

(5). 后续执行表明因为事件就绪或者超时的因素又被切换回来了,此时调用epoll_ctl将事件从操作系统中删除掉(这也暗示了是ONE SHOT模式的哦),保存返回得到的就绪事件。

(6). 释放资源,本次调用完成。

2.3 协程局部存储

涉及到的接口有:co_setspecific、co_getspecific。

这个还是比较简单实现的,对于非协程执行环境或者主协程环境,直接调用pthread库的pthread_setspecific、pthread_getspecific的线程局部存储接口,而如果是在一般工作协程的环境,每个协程预留分配了1024个指针用于存储这些value的地址。

2.4 协程同步

涉及到的接口有:co_cond_alloc、co_cond_signal、co_cond_broadcast、co_cond_timedwait。

大家都知道mutex和条件变量是多线程环境下的开发利器,由于协程是用户态主动切换的,所以感觉对mutex需求不是很大,但是条件变量很有作用,在生产者-消费者模型中可以快速唤醒等待资源的合作者,增加调度效率。

co_cond其实也是一个小Trick:

(1). 首先通过co_cond_alloc()创建一个stCoCond_t对象管理所有的条件事件,其实是一个stCoCondItem_t类型的链表头尾;

(2). 当co_cond_timedwait()等待的时候实际是创建一个stCoCondItem_t,设置pArg=co_self()、pfnProcess=OnSignalProcessEvent,然后将其添加到先前的stCoCond_t链表中去,当然如果设置了超时参数还需要添加到线程的pEpoll->pTimeout中去,然后通过co_yield_ct();把自己切换出去;

(3). 当生产者资源就绪的时候,通过co_cond_signal/co_cond_broadcast在stCoCond_t链表中取出后添加到pEpoll->pstActiveList中等待被调度,当然如果超时了还没有被生产者唤醒,如上所述线程epoll_wait循环中,会自动将其添加到就绪队列中执行回调的。

所以条件变量实际上就是除却使用poll的方式外,由协程自己控制将需要唤醒的协程添加到pEpoll->pstActiveList队列中去来实现的。

2.5 闭包操作

感觉怪怪的,那就先不看了,C++的std::bind、lambda用起来也是爽哒哒滴!

三、小结

设计的亮点在于:可运行协程Stack管理结构,调度的效率更高;shared_stack共享栈设计,节约内存提高创建协程的数量;hook阻塞系统调用和标准库函数,对原有业务代码侵入性小;抽象出多种协程应用开发常用接口,使用简洁方便。

我的libto小轮子还有不少的东西可以完善哈!

本文完!

参考

libco

揭秘:微信是如何用libco支撑8亿用户的

腾讯开源的 libco 号称千万级协程支持,那个共享栈模式原理是什么?

链接程序和库指南

分享到:更多 ()

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址