0%

微信libco协程库源码分析

libco是微信后台开发和使用的协程库,同时应该也是极少数的将C/C++协程直接运用到如此大规模的生成环境中的案例了。

《云风coroutine协程库源码分析》中,介绍了有栈协程的实现原理。相比coroutine,libco在性能上号称可以调度千万级协程。 从使用上来说,不仅提供了一套类pthread的协程通信机制,同时可以零改造地将三方库的阻塞IO调用协程异步化。
本文将从源码角度着重分析libco的高效之道。

在正式阅读本文之前,如果对有栈协程的实现原理不是特别了解,建议提前阅读《云风coroutine协程库源码分析》

同时,我也提供了libco注释版,辅助大家理解libco的代码。

libco和coroutine的基本差异

相比于coroutine协程库, libco整体更成熟,性能更高,使用上也更加方便。主要体现在以下几个方面:

  1. 协程上下文切换性能更好
  2. 协程在IO阻塞时可自动切换,包括gethostname、mysqlclient等。
  3. 协程可以嵌套创建,即一个协程内部可以再创建一个协程。
  4. 提供了超时管理,以及一套类pthread的接口,用于协程间通信。

关于libco的如何实现有栈协程的切换,co_resume、co_yield是如何实现的。此部分内容已在云风coroutine协程库源码分析中进行了详细的剖析。各个协程库这里的实现大同小异,本文就不再重复讲述此部分内容了。

不过,libco在协程的栈空间上有不一样的地方:

  1. 共享栈是可选的,如果想要使用共享栈模式,则需要用户自行创建栈空间,在co_create时传递给libco。(参数stCoRoutineAttr_t* attr)
  2. 支持协程使用独立的栈空间,不使用共享栈模式。(默认每个协程有128k的栈空间)
  3. libco默认是独立的栈空间,不使用共享栈。

除此之外,libco不使用ucontext进行用户态上下文的切换,而是自行写了一套汇编来进行上下文切换。

另外,libco利用co_create创建的协程, 需要自行调用co_release进行释放。这里和coroutine不太一样。

协程上下文切换性能更好

我们之前提到,云风的coroutine库使用ucontext来实现用户态的上下文切换,这也是实现协程的关键。

libco基于性能优化的考虑,没有使用ucontext,而是自行编写了一套汇编来处理上下文的切换, 具体代码在coctx_swap.S

libco的上下文切换大体只保存和交换了两类东西:

  1. 寄存器:函数参数类寄存器、函数返回值、数据存储类寄存器等。
  2. 栈:rsp栈顶指针

相比于ucontext,缺少了浮点数上下文和sigmask(信号屏蔽掩码)。具体可对比glibc的相关源码

  • 取消sigmask是因为sigmask会引发一次syscall,在性能上会所损耗。
  • 取消浮点数上下文,主要是在服务端编程几乎用不到浮点数计算。

此外,libco的上下文切换只支持x86,不支持其他架构的cpu,这是因为在服务端也几乎都是x86架构的,不用太考虑CPU的通用性。

知乎网友的实验证明:libco的上下文切换效率大致是ucontext的3.6倍。

总结来说,libco牺牲了通用性,把运营环境中用不到的寄存器拷贝去掉,对代码进行了极致优化,但是换取到了很高的性能。

协程在IO阻塞时可自动切换

我们希望的是,当协程中遇到阻塞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函数的hook流程

我们以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函数主要有三个作用:

  1. 将poll的相关事件转换为epoll相关事件,并注册到当前线程的epoll中。
  2. 注册超时事件,到当前的epoll中
  3. 调用co_yield_ct, 让出该协程。

可以看到,调用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");

这个逻辑就非常巧妙了:

  • 从内部来看,本质上是个异步流程,在EventLoop中注册相关事件,当事件触发时就执行接下来的处理函数。
  • 从外部来看,调用方使用的时候函数行为和普通的阻塞函数基本一样,无需关系底层的注册事件、yield等过程。

这个就是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调用也一起协程化改造了。

read的超时处理

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似乎也没有提供一个自定义超时时间的办法。

stCoEpoll_t结构体分析

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;
};
  1. iEpollFd:epoll或者kqueue的fd
  2. result: 当前已触发的事件,给epoll或kevent用。如果是epoll的话,则是epoll_wait的已触发事件
  3. pTimeout:时间轮定时管理器。记录了所有的定时事件
  4. pstTimeoutList:本轮超时的事件
  5. pstActiveList: 本轮触发的事件。

此外,libco使用了时间轮来做超时管理,关于时间轮的原理分析网上比较多,这块也不是libco最核心的东西,就不在本文讨论了。

协程可以嵌套创建

libco的协程可以嵌套创建,协程内部可以创建一个新的协程。这里其实没有什么黑科技,只不过云风coroutine中不能实现协程嵌套创建,所以在这里单独讲下。
libco使用了一个栈维护协程调用过程。
我们模拟下这个调用栈的运行过程, 如下图所示:
协程调用栈

图中绿色方块代表栈顶,同时也是当前正在运行的协程。

  1. 当在主协程中co_resume到A协程时,当前运行的协程变更为A,同时协程A入栈。
  2. A协程中co_resume到B协程,当前运行的协程变更为B,同时协程B入栈。
  3. 协程B中调用co_yield_ct。协程B出栈,同时当前协程切换到协程A。
  4. 协程A中调用co_yield_ct。协程B出栈,同时当前协程切换到主协程。

libco的协程调用栈维护stCoRoutineEnv_t结构体中,如下:

struct stCoRoutineEnv_t
{
stCoRoutine_t *pCallStack[ 128 ];
int iCallStackSize;
stCoEpoll_t *pEpoll;
};

其中pCallStack即是协程的调用栈,从参数可以看出,libco只能支持128层协程的嵌套调用,这个深度已经足够使用了。
iCallStackSize代表当前的调用深度。

libco的运营经验

libco的负责人leiffyli在purecpp大会上分享了libco的一些运营经验,个人觉得还是非常值得学习的,这里直接引用过来。

协程栈大小有限,接入协程的服务谨慎使用栈空间;

libco中默认每个协程的栈大小是128k,虽然可以自定义每个协程栈的大小,但是其大小依然是有限资源。避免在栈上分配大内存对象(如大数组等)。

池化使用,对系统中资源使用心中有数。随手创建与释放协程不是一个好的方式,有可能系统被过多的协程拖垮;

关于这点,libco的实例example_echosvr.cpp就是一个池化使用的例子。

协程不适合运行cpu密集型任务。对于计算较重的服务,需要分离计算线程与网络线程,避免互相影响;

这是因为计算比较耗时的任务,会严重拖慢EventLoop的运行过程,导致事件响应和协程调度受到了严重影响。

过载保护。对于基于事件循环的协程调度框架,建议监控完成一次事件循环的时间,若此时间过长,会导致其它协程被延迟调度,需要与上层框架配合,减少新任务的调度;

总结

libco巧妙的利用了hook技术,将协程的威力发挥的更加彻底,可以改良C++的RPC框架异步化后的回调痛苦。整个库除了基本的协程函数,又加入类pthread的一些辅助功能,让协程的通信更加好用。

然而遗憾的是,libco在开源方面做得并不是很好,后续bug维护和功能更新都不是很活跃。

但好消息是,据leiffyli的分享,目前有一些libco有一些实验中的特性,如事件回调、类golang的channel等,目前正在内部使用。相信后期也会同步到开源社区中。

参考

cyhone wechat
欢迎关注公众号【编程沉思录】