张俊强
腾讯互娱工程师,目前负责腾讯互娱心悦俱乐部的后台开发,先后参与过心悦用户体系下沉,后台服务协程化改造,心悦积分体系搭建等项目,致力于海量、高可用、高性能的分布式系统设计及研发。
改造背景
工作后一直在做c++后台服务开发,框架基本都是多进程多线程的模型,也基本能解决绝大部分问题。不同的应用场景下,可以通过同步或者异步的方式来满足业务或者性能的要求。
但是如果原有业务是同步方式实现的,如果性能需要优化改为异步,一般都是通过重构服务来实现,重构的过程大家都懂的,特别是涉及到整个框架的异步化重构,是很痛苦的。
现在我们有一个用户触达的api接口服务,主要的工作是根据用户游戏充值情况,经过业务逻辑判断来通过不同通道进行触达,例如QQ Tips、短信、邮件、微信消息等等。原有的服务设计是单线程处理,一个用户充值请求到达后,串行化的进行多个触达,也就是一个请求处理的耗时实际是多个触达所需时间之和,通过多进程来提高并发性。当网络抖动或者请求量突增时某个后端触达的延时增加会拉长整个请求的处理耗时,恶性循环服务很快会到达容量上线,过载后会使不少请求被丢弃,影响用户体验。
改造选型
怎么优化呢?首先想到的是增加服务的处理容量。考虑到增加更多的进程显然意义不大,多线程改造受到系统资源的影响和切换的开销,开上万的线程不太现实,所以也不合适。根据原有业务的特点,服务的目的是用户触达,只需请求发送给触达服务,对响应并没有特殊的处理需求,只要最后记录个成功还是失败的结果即可,很符合异步处理的模型,那如何进行异步化改造呢?
有两种办法:
A 线程异步化:把所有服务改造成异步模型,通过状态机,处理请求后对其记录状态,由异步的线程收到响应后根据请求的状态来完成后续的处理。等同于从框架到业务逻辑代码的彻底改造。
B 协程异步化:通过协程库对业务逻辑非侵入的异步化改造,即只修改少量框架代码。
第一种优点是改造模型常见,比较熟悉,缺点就是状态维护繁琐,整个框架需要修改,工作量比较大。第二种通过协程的方式,通过底层网络IO进行hook的方式,实现串行的并行化,优点是改动量比较少,整个框架不需要大改,缺点是以前没用过,需要一个熟悉上手的过程。
两者分析后决定使用协程的方式进行异步改造,其改动量少,并发量大而且很容易做到上万并发且资源占用率小的特点还是很吸引人的。目前c++的协程库主要有boost协程库和libco,因为libco是微信开源的库,微信的业务体量众所周知,同时也是经过实践验证行之有效的,所以我们选了libco。
libco开源地址:https://github.com/Tencent/libco(点击文末阅读原文可直接前往访问,喜欢给个Star或者提出你的pull request哦!)
libco介绍
废话这么多,下面先谈谈libco的一些基本的概念。
libco协程库是微信后台服务随着业务体量的巨大化过程中诞生的,目的就是解决后端服务高并发的问题,libco的实现原理简单来说就是在线程的基础上用户自己实现了协程栈的切换,同时将各个协程使用到的socket句柄都通过epoll进行管理。操作系统对协程是无感知的,当某个遇到需要网络IO的时候,libco库就把A协程切换出去,换回一个待运行的B协程继续执行,当epoll检测A协程有网络时间到达后,就将A加入待运行协程队列在下次切换时候运行。当然libco对底层的网络IO函数进行hook,我们在使用read,write等系统函数和第三方库用到系统函数的时候不需要做其他任务处理。
既然说到协程栈,libco默认是使用stackfull模式的协程栈,各个协程有自己的运行栈,当然这些栈实际都是在用户进程的堆内存上分配的,每个栈的大小是128k,这里就需要大家注意协程内部不要定义过大的局部变量以免溢出导致core。另外libco为了提高内存的使用率增加协程并发,还可以使用共享栈的模式,也就是多个协程使用同一个运行栈,这里就涉及到切入切出的问题,也是时间换空间的一种方案,不过默认方式下支持上万协程已经不是问题,这里我们使用默认模式即可。
libco常用的函数如下:
int co_create(stCoRoutine_t** co, const stCoRoutineAttr_t* attr, void* (*routine)( void*), void* arg);
co_create是协程创建函数,co是协程控制块,attr创建协程属性,一般用来设置共享栈模式和栈空间大小,默认为NULL,routine是协程的入口函数,arg是函数的参数,和pthrad_create类似。
void co_resume(stCoRoutine_t* co);
co_resume是切换到指定的协程co,因为操作系统对协程是无感知的,所以切换调度都是由协程自己来完成。这里既可以是协程第一次运行,也可能已经运行过了。
void co_yield( stCoRoutine_t *co )
co_yield也是切换协程,不过和co_resume不同的是,co_yield是指协程让出运行权给之前调用它的协程,而co_resume是有指向,相当于调用某个协程。
void co_eventloop(stCoEpoll_t *ctx, pfn_co_eventloop_t pfn, void *arg)
co_eventloop是主协程的调度函数,函数的主要作用就是通过epoll负责各个协程的时间监控,如果有网络事件到了或者等待时间超时了,就切换到相应的协程处理。ctx是库函数co_get_epoll_ct(),pfn是一个钩子函数,用户自定义,在函数co_eventloop的最后会执行,arg是pfn的参数。
void co_enable_hook_sys()
co_enable_hook_sys函数是用来打开libco的钩子标示,这样你在进行系统io函数的时候才会调用到libco的函数而不是原系统函数。
int poll(struct pollfd fds[], nfds_t nfds, int timeout)
poll相当于我们平时用到的sleep函数,协程毕竟是在线程内活动的。若想只挂起某个协程,如果用sleep,其实作用到的是整个线程,所以需要通过poll来实现协程的挂起,协程环境下实际调用的函数是co_poll,主要是完成回调函数的设置,超时事件的挂入然后把自己切出去。
另外类似线程私有变量的函数接口有co_setspecific和co_getspecific。涉及到同步的接口有co_cond_alloc、co_cond_signal、co_cond_broadcast、co_cond_timedwait。这里就不一一介绍了,有兴趣的读者可以看看源码。
改造方案和遇到的问题
了解完libco接口函数的使用后,我们api接口的改造也可以动手了,首先现在线程的入口处通过函数co_create创建初始化需要的所有协程,并通过co_resume切换到协程内部完成初始化,待所有协程都处理完后,主协程调用co_eventloop进行IO事件监控,其他业务处理逻辑完全不变,在涉及到网络IO的部分通过函数co_enable_hook_sys使能底层IO的钩子函数,其他涉及到sleep和全局变量,私有变量的部分根据场景进行替换处理,这样我们的服务异步化改造就顺利的完成了,通过测试上线很好的解决了同步模型下容量的问题。
当然在实际改造过程中也遇到很多非业务的问题,这里我就把自己遇到的问题罗列一下,如果后续有同学遇到也方便解决。
1
首先是在编译链接的过程中遇到的问题,把libco加入到我们的服务后,makefile编译链接一切都没有问题,但是当运行程序的时候,程序总会在初始化日志模块调用系统函数fcntl时候core,起初怀疑是第三方库和libco有冲突等问题,尝试屏蔽日志模块的初始化后,发现在其他系统函数的地方也会core。经过libco的作者的协助,发现libco的函数是hook了,但是底层的系统函数却是null,原来libco对底层函数hook后,是通过动态链接dlsym经过逻辑后再重新调用系统函数的。而我们的服务是通过全静态链接的,自然实际的系统函数就不存在了,将-static编译选项去掉后,把系统库改成动态调用,问题解决。
2
协程中不能使用sleep,如果想让协程休眠,可以使用poll(NULL, 0, ms),最开始使用sleep后业务逻辑不正常。
3
libco例子中连接使用的是长连接,正常流程是不会释放连接的,如果你的业务中用到的是短连接,别忘记正常流程也要释放连接。
4
改造的过程中之前一些全局变量尤其要注意,虽然libco协程是串行的执行,不会因为并行而导致core,但是如果使用不当,还是会造成数据混乱,因此改造的工作主要是复核系统中线程私有变量、全局变量、线程锁的使用,确保在协程切换的时候不会数据错乱或者重入。
总结
对于libco本身用到的数据结构和函数具体的内部实现我这里就不多废话了,毕竟代码就在那里而且整个库代码也不是很多,想知道其中奥妙的同学可以亲自下载开源库一探究竟,开源地址:https://github.com/Tencent/libco(点击文末阅读原文可直接前往访问,喜欢给个Star或者提出你的pull request哦!)。libco库本身代码很少只有几个文件,可是效率却极高,而且最重要的是如果是要修改已有的服务,是又快又好用,让自己也体验了一把飞一般的感觉。最后感谢libco作者的大力协助,谢谢。