前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >微信libco协程库源码分析

微信libco协程库源码分析

原创
作者头像
cyhone
发布2019-10-08 17:52:31
1.6K0
发布2019-10-08 17:52:31
举报
文章被收录于专栏:编程沉思录编程沉思录

博客: www.cyhone.com

公众号:编程沉思录


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

性能上来说,号称可以调度千万级协程。

从使用上来说,不仅提供了一套类pthread的协程通信机制,同时可以零改造地将三方库的阻塞IO调用协程异步化。

在另外一篇文章《云风coroutine协程库源码分析》中,我介绍了有栈协程的实现原理。

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

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

本文将根据这几方面深入分析下libco的实现源码。

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

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

<!--more-->

libco和coroutine的基本差异

关于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函数为例,看下都做了什么:

代码语言:txt
复制
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函数获取了系统函数, 如下:

代码语言:txt
复制
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结构体中维护了事件循环需要的数据。

代码语言:txt
复制
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使用了一个栈维护协程调用过程。

我们模拟下这个调用栈的运行过程, 如下图所示:

co_process_stack.png
co_process_stack.png

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

  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结构体中,如下:

代码语言:txt
复制
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等,目前正在内部使用。相信后期也会同步到开源社区中。

参考

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • libco和coroutine的基本差异
  • 协程上下文切换性能更好
  • 协程在IO阻塞时可自动切换
    • read函数的hook流程
      • read的超时处理
        • stCoEpoll_t结构体分析
        • 协程可以嵌套创建
        • libco的运营经验
        • 总结
        • 参考
        相关产品与服务
        数据保险箱
        数据保险箱(Cloud Data Coffer Service,CDCS)为您提供更高安全系数的企业核心数据存储服务。您可以通过自定义过期天数的方法删除数据,避免误删带来的损害,还可以将数据跨地域存储,防止一些不可抗因素导致的数据丢失。数据保险箱支持通过控制台、API 等多样化方式快速简单接入,实现海量数据的存储管理。您可以使用数据保险箱对文件数据进行上传、下载,最终实现数据的安全存储和提取。
        领券
        问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档