博客: www.cyhone.com
公众号:编程沉思录
libco是微信后台开发和使用的协程库,同时应该也是极少数的将C/C++协程直接运用到如此大规模的生成环境中的案例了。
性能上来说,号称可以调度千万级协程。
从使用上来说,不仅提供了一套类pthread的协程通信机制,同时可以零改造地将三方库的阻塞IO调用协程异步化。
在另外一篇文章《云风coroutine协程库源码分析》中,我介绍了有栈协程的实现原理。
而相比于coroutine协程库, libco整体更成熟,性能更高,使用上也更加方面。主要体现在以下几个方面:
本文将根据这几方面深入分析下libco的实现源码。
在正式阅读本文之前,如果对有栈协程的实现原理不是特别了解的话,建议可以提前阅读另外一篇文章《云风coroutine协程库源码分析》。
同时,我也提供了libco注释版,辅助大家理解libco的代码。
<!--more-->
关于libco的如何实现有栈协程的切换,co_resume、co_yield是如何实现的。此部分内容已在云风coroutine协程库源码分析中进行了详细的剖析。各个协程库这里的实现大同小异,本文就不再重复讲述此部分内容了。
不过,libco在协程的栈空间上有不一样的地方:
stCoRoutineAttr_t* attr
)除此之外,libco不使用ucontext进行用户态上下文的切换,而是自行写了一套汇编来进行上下文切换。
另外,libco利用co_create
创建的协程, 需要自行调用co_release
进行释放。这里和coroutine不太一样。
我们之前提到,云风的coroutine库使用ucontext来实现用户态的上下文切换,这也是实现协程的关键。
而libco基于性能优化的考虑,没有使用ucontext,而是自行编写了一套汇编来处理上下文的切换, 具体代码在coctx_swap.S。
libco的上下文切换大体只保存和交换了两类东西:
相比于ucontext,缺少了浮点数上下文和sigmask(信号屏蔽掩码)。具体可对比glibc的相关源码。
此外,libco的上下文切换只支持x86,不支持其他架构的cpu,这是因为在服务端也几乎都是x86架构的,不用太考虑CPU的通用性。
据知乎网友的实验证明:libco的上下文切换效率大致是ucontext的3.6倍。
总结来说,libco牺牲了通用性,把运营环境中用不到的寄存器拷贝去掉,对代码进行了极致优化,但是换取到了很高的性能。
我们希望的是,当协程中遇到阻塞IO的调用时,协程可以自行yield出去,等到调用结束,可以再resume回来,这些流程不用用户关心。
然而难点在于: 对于自己代码中的阻塞类调用尚且容易改造,可以把它改成非阻塞IO,然后框架内部进行yield和resume。但是大量三方库也存在着阻塞IO调用,如知名的mysqlclient就是阻塞IO,对于此类的IO调用,我们无法直接改造,不便于和我们现有的协程框架进行配合。
然而,libco的协程不仅可以做到IO阻塞协程的自动切换,甚至包括三方库的阻塞IO调用都可以零改造的自动切换。
libco巧妙运用了Linux的hook技术,同时配合了epoll事件循环,完美的完成了阻塞IO的协程化改造。
所谓系统函数hook,简单来说,就是替换原有的系统函数,例如read、write等,替换为自己的逻辑。所有关于hook系统函数的代码都在co_hook_sys_call.cpp
中可以看到。
在分析具体代码之前,有个点需要先注意下:libco的hook逻辑用于client行为的阻塞类IO调用。
client行为指的是,本地主动connect一个远程的服务,使用的时候一般先往socket中write数据,然后再read回包这种形式。
我们以read函数为例,看下都做了什么:
ssize_t read( int fd, void *buf, size_t nbyte )
{
struct pollfd pf = { 0 };
pf.fd = fd;
pf.events = ( POLLIN | POLLERR | POLLHUP );
int pollret = poll( &pf,1,timeout );
ssize_t readret = g_sys_read_func( fd,(char*)buf ,nbyte );
return readret;
}
上述代码对原有代码进行了简略,只保留了最核心的hook逻辑。
注意:这里poll函数实际上也是被hook过的函数,在这个函数中,最终会交由co_poll_inner
函数处理。
co_poll_inner
函数主要有三个作用:
可以看到,调用poll函数之后,相关事件注册到了EventLoop中后,该协程就yield走了。
那么,什么时候,协程会再resume回来呢?
答案是:当epoll相关事件触发或者超时触发时,会再次resume该协程,处理接下来的流程。
协程resume之后,会接着处理poll之后的逻辑,也就是调用了g_sys_read_func
。这个函数就是真实的linux的read函数。
libco使用dlsym函数获取了系统函数, 如下:
typedef ssize_t (*read_pfn_t)(int fildes, void *buf, size_t nbyte);
static read_pfn_t g_sys_read_func = (read_pfn_t)dlsym(RTLD_NEXT,"read");
这个逻辑就非常巧妙了:
这个就是libco的巧妙之处了,通过hook系统函数的方式,几乎无感知的改造了阻塞IO调用。
此外,libco也hook了系统的socket函数。在libco实现的socket函数中,会将fd变成非阻塞的(O_NONBLOCK)。
那么,为什么libco连mysql_client都可以一并协程化改造呢?
这是因为mysql_client里面的具体网络IO实现,也是用的Linux的那些系统函数connect、read、write这些函数。
所以,libco只用hook十几个socket相关的api,就可以将用到的三方库中的IO调用也一起协程化改造了。
libco的read函数和普通的阻塞IO中的read函数,行为上稍微有一点不一样。
普通的read函数,如果一直没有消息可读,则会一直阻塞。
但是libco中的read函数,如果1秒钟之内socket依然不可读,则就认为read失败,返回-1。这也是read中注册超时事件的原因。
在client侧网络的IO调用里面,一般行为都是,write请求,然后read回包。
所以一定是会引入一个超时判断,判断该次调用是否超时。
同时,还要保证要保证read的行为和语义,与原有的系统函数保持一致。毕竟hook的目标是mysql_client这种三方库。
所以这个超时只能做在read内部,把超时当成一次read失败处理。
这样即能保证read原有行为,也能保证read不会一直阻塞。
但这里有个问题:libco把read的超时时间硬编码为1s,那么所有被hook的阻塞IO的read,一旦超过1s,就会被认为失败。
但对于某些特殊场景,会存在一些耗时请求,server端的处理时间确实有可能会超过1s。
对于这种情况,libco似乎也没有提供一个自定义超时时间的办法。
libco的事件循环同时支持epoll和kqueue,libco会在每个线程维护一个stCoEpoll_t
对象。
stCoEpoll_t
结构体中维护了事件循环需要的数据。
struct stCoEpoll_t
{
int iEpollFd;
co_epoll_res *result;
struct stTimeout_t *pTimeout;
struct stTimeoutItemLink_t *pstTimeoutList;
struct stTimeoutItemLink_t *pstActiveList;
};
此外,libco使用了时间轮来做超时管理,关于时间轮的原理分析网上比较多,这块也不是libco最核心的东西,就不在本文讨论了。
libco的协程可以嵌套创建,协程内部可以创建一个新的协程。这里其实没有什么黑科技,只不过云风coroutine中不能实现协程嵌套创建,所以在这里单独讲下。
libco使用了一个栈维护协程调用过程。
我们模拟下这个调用栈的运行过程, 如下图所示:
图中绿色方块代表栈顶,同时也是当前正在运行的协程。
libco的协程调用栈维护stCoRoutineEnv_t结构体中,如下:
struct stCoRoutineEnv_t
{
stCoRoutine_t *pCallStack[ 128 ];
int iCallStackSize;
stCoEpoll_t *pEpoll;
};
其中pCallStack即是协程的调用栈,从参数可以看出,libco只能支持128层协程的嵌套调用,这个深度已经足够使用了。
iCallStackSize代表当前的调用深度。
libco的负责人leiffyli在purecpp大会上分享了libco的一些运营经验,个人觉得还是非常值得学习的,这里直接引用过来。
协程栈大小有限,接入协程的服务谨慎使用栈空间;
libco中默认每个协程的栈大小是128k,虽然可以自定义每个协程栈的大小,但是其大小依然是有限资源。避免在栈上分配大内存对象(如大数组等)。
池化使用,对系统中资源使用心中有数。随手创建与释放协程不是一个好的方式,有可能系统被过多的协程拖垮;
关于这点,libco的实例example_echosvr.cpp
就是一个池化使用的例子。
协程不适合运行cpu密集型任务。对于计算较重的服务,需要分离计算线程与网络线程,避免互相影响;
这是因为计算比较耗时的任务,会严重拖慢EventLoop的运行过程,导致事件响应和协程调度受到了严重影响。
过载保护。对于基于事件循环的协程调度框架,建议监控完成一次事件循环的时间,若此时间过长,会导致其它协程被延迟调度,需要与上层框架配合,减少新任务的调度;
libco巧妙的利用了hook技术,将协程的威力发挥的更加彻底,可以改良C++的RPC框架异步化后的回调痛苦。整个库除了基本的协程函数,又加入类pthread的一些辅助功能,让协程的通信更加好用。
然而遗憾的是,libco在开源方面做得并不是很好,后续bug维护和功能更新都不是很活跃。
但好消息是,据leiffyli的分享,目前有一些libco有一些实验中的特性,如事件回调、类golang的channel等,目前正在内部使用。相信后期也会同步到开源社区中。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。