公众号转载自:汽车之家技术委员会1.后台性能优化是后端服务优化的重要课题。尤其是在广告业务中,服务超时不仅会让广告主担心预算消耗,还会直接影响C端用户的浏览体验。一个服务程序的性能往往是一个综合的外在表现,涵盖了编程语言特性、业务需求逻辑,甚至操作系统的底层原理等诸多因素。面对超时问题,无论是量化分解、问题复现、异常监控,以及后续的超时优化,对于后端开发同学来说都是极具挑战性的。如果改变主意,升级后端服务框架可能成为最彻底的解决方案。本文是结合到家广告引擎团队遇到的超时现象和问题分析过程,对服务框架升级背后的思考进行总结和沉淀。2.问题回顾到家的广告搜索服务自成立以来一直使用SPserver服务框架。Spserver是一个用C++实现的半同步和异步网络框架。虽然名气不大,但是在那个并发编程,尤其是协程概念还没有普及的年代,相比于很多free-for-all系统不同代码风格,不同封装独立性的研究网络框架,确实是更好的选择。特别是在家庭广告系统建立之初,以其良好的性能、稳定性和易用性,在引擎中得到了广泛的应用。可以说,SPserver为推动广告业务的发展做出了巨大的贡献。随着需求的不断迭代升级,广告系统在运行中遇到的问题也越来越突出,包括:内存消耗、并发、超时、生态整合等,其中超时问题最为重要.说到超时,恐怕会让人抓狂,因为它来得太突然,几乎不留痕迹。没错,监控能抓到,但问题是你分析全链路日志可能什么也得不到,因为你划分的每个业务逻辑阶段的耗时都没有超过预设的超时阈值,而且进程的CPU利用率不高(可以说是很低),奇怪的是上游请求者超时居然发生了。3、问题分析应用服务不是孤立的,其中出现的问题也应该是相互关联的。(图1:来自网络)从图1我们知道,在业务应用程序处理网络请求之前和发送网络响应之后,数据将流经网络和系统内核。那么,超时会不会是他们造成的呢?为此,我们通过压测环境复现了发生超时的系统上下文场景:(图2)可以发现,业务应用与其上下游之间通过多个tcp连接进行通信,部分tcp连接在接收端相应socket的buffer但是发送buffer中有数据积压,两个队列的数据积压分别固定在4xx和2xx。与grpc提倡的单通道通信模式不同,spserver原生支持多个tcp连接服务,无可厚非。基于tcp协议的拥塞控制机制,网络数据在进入应用层之前,会在操作系统内核态的fdbuffer中停留一段时间,所以一个进程中有一定的数据积压是正常的时间短;因为它是一个缓冲区,所以它是自然大小。那么,到底是缓冲区太小还是网络拥塞导致的超时呢?我们应该如何判断?(图3)可以看出系统的socket收发缓冲区默认值在80k以上,远大于图2中的431字节。而且spserver源码中并没有重新设置socket收发缓冲区的值通过setsockopt系统调用。所以图2中buffer的大小是合理的,但是接收队列中431的数据量可能有问题(实际上431是压测场景下单次请求的数据长度;在生产环境中,这个队列长度会根据C端不同的请求显示不同的值,完全没有规律)。不断刷新netstat,比较目标TCP连接的发送缓冲队列或接收队列的长度。发现这个场景下发送和接收队列的长度并没有立即消失,也没有减少,而是在大约1秒后发生了变化。由此可以推测有两种可能:1)网络拥塞;2)服务器cpu繁忙,无法及时从buffer中读取数据。第一个猜测很容易通过网络检测工具验证,是否定的;通过检查系统和进程cpu的利用率,也可以轻松排除第二种猜测。分析调查到了这个地步,似乎已经走到了死胡同。是不是真的?有一个细节似乎被忽略了:作为多线程服务,进程的CPU利用率≈线程的CPU利用率之和,但是进程的CPU利用率低并不代表线程的CPU利用率低一些或某个线程也很低。也就是说,个别线程所在的cpu利用率高,也会让上面的第二个猜测成立。(图4)如图4所示,如果反复施加不同程度的请求压力,会发现线程18194的CPU使用率一直保持在99%以上,其他线程的CPU使用率最多在40%左右.面对高压,服务无法平均分担cpu压力,这就是问题所在!4.根源上帝总是吝啬于创造完美的事物,SPserver的待遇也不例外。其实在查看线程的cpuutilization的时候,已经很接近真正的原因了,但还不是真的。为此,我翻遍了spserver的源码,得到了如下线程模型的示意图。(图5)不出所料,SPserver采用了单线程reactor网络模型,即单线程负责事件监听、socket读写,多线程负责业务逻辑处理。单线程io的优缺点很明显。可以利用好线程本地加速,不存在cpucachebounding的问题;但问题是,一旦一个socket上要读取或发送的数据量很大,其他socket就会被阻塞。在socket上发送和接收数据是比较致命的。退一步讲,即使每个socketfd上的数据量是统一的,当上述单线程CPU满载时,整个框架的数据收发效率也会成为服务的性能瓶颈。事实很清楚。原有SPserver框架的设计机制决定了它不适合高并发、高吞吐的业务场景。但是如果是在业务初期或者业务流量比较小,个人觉得还是可以算是一个不错的选择(SPserver框架的c++代码风格还是很不错的,很简洁,封装也很不错,值得学习)。5、选择BRPC现在rpc框架有很多。我们选择的是百度自研的brpc框架,主要基于以下考虑:1)高并发高性能个人理解后面会介绍。这里我只贴一张brpc和其他rpc框架的性能对比图:(图6:来自brpc官网)是的,你没看错,在多个客户端跨机器请求单个服务器的场景下,brpc框架绝对领先用国外知名的rpc框架来形容一点都不为过,尤其是grpc。2)丰富的文档。brpc有丰富的中英文文档。丰富程度有点难以置信。一时间,有人认为百度内部技术资料不小心公开了,哈哈。3)Apache的顶级项目目前有上千个企业级应用。说白了就是在生产中经过反复试验,质量有保证。当然brpc还有很多其他的特点,这里不再赘述。具体可以查看brpc官网或者移步到githubincubator-brpc项目。6、性能的初步探索,已经通过spserver框架,难免有些比较。在介绍brpc的线程模型之前,先了解一下bprc中的一个概念:bthread。来看看官方的解释:“bthread是brpc使用的M:N线程库,目的是在降低编码难度的同时提高程序的并发性,同时在CPU上提供更好的扩展性和缓存越来越多的cores.locality."M:N"表示M个bthreads会映射到N个pthreads,一般M远大于N。由于目前linux的pthreadimplementation[NPTL]是1:1,Mbthreads也映射到N[LWP]。bthread的前身是DistributedProcess(DP)中的fiber,一个N:1的协作线程库,相当于event-loop库,不过写的是同步代码。》个人理解bthread其实是一个运行在系统pthread上的低成本灵活的调度任务(queue),并且这个任务携带了自己的runtimecontext信息(如:stack,register,signal等),从而使其可以随意切换运行在不同的pthread上,它的低成本体现在几个方面:1)bthread实现了多种同步原语,可以和系统线程互相等待,我们可以像pthread一样使用bthread;2)Nano建立bthreadinseconds比pthread耗时少很多;3)几乎没有上下文切换,充分发挥了cpucachelocality和threadlocality,这部分后面会讲解,先来看看它的线程模型示意图:(图7)从图7可以看出:1)在brpc盒子中,系统级线程池pthread是高效运行的基础设施,不再直接绑定具体的业务逻辑,取而代之的是线程;2)bthread根据不同的职责分为不同的任务类型,不同类型的bthreads有不同的编号。比如:网络事件的监控和驱动由一个bthread专职处理,当然也可以在启动服务时通过命令行配置,或者在服务启动后通过web入口进行更新;而处理特定请求的bthreads数量是动态计算的;3)brpc支持非忙线程“窃取”忙线程的bthread任务,提高系统整体性能。那么问题来了,当在某个socketfd上接收或发送大量数据时,会不会像spserver一样发生Blocking?答案是不。首先,每个fd有两个bthread,分别负责接收和发送,可以保证发送和接收互不影响;其次,bthreads作为定时任务,会分配给不同的系统线程(也就是pthreads),一个系统线程同时只能执行一个bthread任务,加上bthread支持的窃取机制,保证了所有线程进程中有事可做,不会有空闲的pthreads(除非请求量很小,不够平分pthreads。)所以,“一处阻塞,处处阻塞”基本是不可能的。使用较大数量的压测请求对brpc服务进行压力,每个pthread的CPU消耗如下:(图8)从图8可以看出,系统的多核CPU得到了充分的调动,并且CPU的扩展性随着压力的增加而表现良好;再看网络队列:(图9)可以看出(同一台机器上grpc单通道压测,只有一个tcp连接)tcpfd的收发缓冲区已经被充分利用,队列长度可以迅速降为0。Socket缓冲区不再是摆设,整个系统都活了!此外,值得一提的是线程之间的上下文切换。因为过多的上下文切换,CPU时间会消耗在寄存器和线程栈的保存和恢复上,从而降低服务的整体性能。brpc框架的m:n线程库在这方面做得比较好。它使用固定的系统线程在用户态调度运行大量的bthreads,基本限制了所有切换到用户态,避免了内核态。与用户态的数据交换(用户态之间的切换需要100~200ns,而内核态和用户态之间的切换需要微秒)。这也可以通过命令来证明:(图10)如图10所示,使用brpc后,我们服务的上下文切换频率基本保持在1次/秒。反观spserver框架,由于没有用户态任务的概念,只是依赖系统级的线程池,这就不可避免地使得cpu在多线程调度和任务执行中不断游离,与内核之间的关系模式和用户态的上下文切换开销肯定是少不了的:(图11)用户态线程切换的另一个好处是内核态线程和CPU核可以很好的绑定在一起,可以尽可能避免不同CPU核之间的cacheline数据同步尽可能。从而提高性能,这也是brpc框架高性能的原因之一。7、应用实践brpc的编译、安装和基本使用在官方文档中介绍的比较详细,比较简单。这里分享一下我们在brpc的应用过程中遇到的一些值得注意的地方:1)threadlocal。是多线程程序常用的加速方式。比如tcmalloc就充分利用了这个技术,通过在线程内部设置本地缓存来加速小空间的应用效率。家庭广告引擎服务也无一例外地使用了这项技术。但需要注意的是引入brpc框架后,原来的pthreadid可能不再有效。如果硬要去做,可能会在程序运行过程中遇到莫名其妙的段错误。这是因为我们的业务代码托管在bthread中,bthread在系统pthread之间随机游走。自然不可能利用pthread1的标识信息从pthreadN的线程栈中读取缓存数据。我们的临时解决方案是在控制锁粒度的前提下,将线程局部缓存暂时剥离,改成全局缓存,暴力、简单、有效。2)cpuprofiler顾名思义,你的程序可能会被优化并运行得更快。但事实并非如此。如果你的业务程序的CMakeList来自demo或者网络程序,最好注意这个编译选项。其原理是通过调用相应的库函数,收集活动线程中的线程函数信息,根据栈反映的函数调用关系生成调用图,进而进行调用优化。所以,它会加速,但不会立即加速,因为它需要先收集数据,然后分析数据,最后才能优化它的操作。在我们刚开始实践的时候,肉眼可以看出它的性能比没有这个编译选项要低10%以上,所以我们在处理cpuprofiler的时候要慎用。3)前面说到grpc,gprc默认是基于单通道通信模式的。这不仅是谷歌的官方建议,也是微软的实用建议。下面的截图来自微软的《PerformancebestpracticeswithgRPC》一文:(图12:摘自微软官网)你不能总是按照别人说的去做。结合具体业务场景,我们的实际结论是多通道数据传输效率优于单通道。受限于tcpratelimit,在单通道(连接)的情况下,一旦遇到高吞吐量的数据传输业务场景,网络连接会明显阻塞。特别是对于广告业务,我们允许传输大数据块,但不允许传输大数据块影响其他正常的广告数据响应。因此,我们的建议是:要么使用多通道grpc或者其他协议方式,要么放弃grpc(其实grpc在生产中还有其他问题)。4)并发早在spserver时期,我们就在里面实现了一个并发线程库(准确的说是并发线程类),但是效果没有达到预期,因为一定程度上增加了多线程调度的成本.今天的brpc直接提供了一个比较简单的并发线程api,我们不用造轮子就可以直接使用。不过,会有一个新的选择:使用bthread_start_background,或者使用bthread_start_urgent。使用后者启动bthread后,任务会立即在当前pthread中执行,而前者会将新生成的bthread任务排队等待调度。在我们的广告检索过滤场景中,适合后者;在执行http请求时,前者更适合。建议brpc开发者一定要根据自己业务的实际情况来做决定。8、最后,从SPserver框架升级到BRPC框架,在相同业务场景下,首页广告服务的qps从5w+提升到10w左右,服务实例数也下降了一半以上,好处是显而易见的。此外,Brpc提供了一套相对丰富的内置服务。这里推荐两个具有代表性功能的web界面,都比较实用,推荐大家尝试。图13:我们可以看到服务的qps、延迟分布等数据,方便掌握服务的运行时信息。(图13)从下图中可以看到服务运行过程中等待锁的时间以及发生等待的函数,支持我们进行有针对性的性能优化。(图14)注限于作者水平,理解和描述难免有遗漏或错误。欢迎交流指正。文章供学习交流,转载请注明出处,谢谢!作者简介杨明哲于2018年加入汽车之家,目前就职于整车事业部-技术部-广告技术与系统团队,负责到家广告引擎架构的设计与开发。
