查看原文
其他

郑建勋:Go程序性能分层优化 | CPU篇

郑建勋 ITPUB 2023-04-23

点击上方蓝字关注我们









责编 | 韩楠

约 5,500 字 | 11 分钟阅读






世界上唯一不变的,就是变化



性能问题,是所有程序和系统随着时间都可能面临的问题,虽然本文中以Go程序作为切入点,但其思考方式,我认为仍然适用于其他语言编写的系统,并有启发意义。




不过早优化 ≠ 不优化



计算机科学中有一句名言:过早的优化是万恶之源。[1]
但实际上,这句话隐含的第一层含义是开发者应该更多关注于程序中的关键部分,而忽略掉不关键的部分。可以优化并不意味着值得优化。
这句话隐含的第二层含义就是迟早也要对关键代码做优化,逃得过初一,躲不过十五……
另外,这句话强调的是不要过早,而不是不进行优化。
实际上,一个完全不考虑设计的系统最终带来的就是难以维护的“屎山”,逐渐又慢又臭。就像《人月神话》[2] 中描述的焦油坑,所有进入的努力都慢慢地陷入其中并被埋葬。我们多数人应该都深有体会吧。



图1 拉布雷亚沥青坑 (图源:维基百科)


所以,程序的性能问题实际上贯穿于程序的整个生命周期不管是程序正确的架构设计、快速的算法和数据结构设计,还是为了应对快速增长的需求或降低成本需要做的攻坚,可以说,这些都离不开性能的分析与优化。




性能优化是成本与收益的权衡



同时,性能分析与优化,又是一个比较庞大的话题。首先我们必须要明确一点,就是性能指的是什么。这里引入IBM关于性能的一个说法,一起来看下:
性能是指计算机系统响应特定工作负载时的行为方式。按照系统响应时间、吞吐量和资源利用率来测量性能。                                                              ——IBM [3]




简单来说性能优化就是用有限的最的资源满足人们对程序的期待
优化最开始需要明确最终到达的目标是什么。性能优化是一种手段,最后需要为目标服务。很多时候一种资源比另一种资源更稀缺,因此可能牺牲其他资源来优化某一种资源(例如时间换空间的方式)。
优化的资源可能是多方面的,比如内存、磁盘I/O、网络、CPU等。可能出现的瓶颈或者可以优化的地方,也可能是多方面的,例如代码层面、算法与数据结构层面、操作系统层面、硬件层面。特别是现在云原生技术的兴起,给排查程序性能问题带来了新的挑战。

Go程序的性能分析,显然是复杂的。考验开发者计算机综合素养和具备的知识体系。虽然复杂,但是在这一系列文章中,笔者依然试图抽丝剥茧,构建起分析性能问题的模型和思路。在本文中,专注于程序的CPU性能问题优化。





一、性能优化分层抽象



由于性能的复杂性,首先让我们有的放矢,构建起性能分析依赖的优化级别,在这里参考了《Efficient Go》[4] 的划分方法,自上而下划分为了这样的几个级别

图2 性能优化分层抽象


上图中性能优化的分层抽象,有助于我们将复杂的性能问题拆解开,来并逐个击破。接下来笔者将对照图2,带着大家一同分别看看每一层在性能上面临的问题和挑战。





(一)系统级别



在大多数情况下,我们的软件是某个系统的一部分。也许它是许多分布式进程之一,也许它是更大的单体应用程序中的一个线程。在系统级别进行优化意味着如何对功能和模块进行拆分、如何将它们链接在一起、组件调用的关系以及调用频率,API如何设计等。


图3 系统级别优化考虑因素


特别是在当下硬件越来越便宜、容器化以及大数据时代,分布式、微服务架构应用得越来越多,这一级别变得更加重要。


图4 单体服务(左) VS 微服务(右) 

(图源:《Kubernetes in Action》[5] )





(二)程序设计和组织



程序内好的架构设计是构建高性能、可维护程序的基础。这包括了如何完成功能组件的拆分、流程的抽象(例如一个打车流程可以抽象为:预估->派单->接驾->开始计费->行程中->结束计费...)适应业务或外部的需求。
使用何种形式组织代码,定义清晰的模块间的接口边界。使用何种框架、缓存、并发模型。甚至包括搭建的开发流程和规范,重点指标体系的设计和监控。


图5 程序设计和组织考虑因素


好的架构设计,能够比较轻松地进行扩展和后续的优化,也能够总体上提升程序的性能。
前两层我们从系统外部模块交互,到系统内部架构设计进行了梳理。接下来再更加深入看看系统内部的实际代码实施阶段、到代码的实际运行环境(操作系统和硬件层面),我们所需要考虑的性能问题。





(三)代码实施



代码实施这一级别,简单来说就是实际的代码开发,如下图在emacs中编写代码。在编写代码时,避免性能问题有3个阶段:1.合理的代码:避免常见的陷阱和坑等;2.刻意的优化:结合编译时和运行时原理等;3.危险的尝试: 指针和汇编等。


图6 代码实施阶段


这一层要考虑的性能内容,包括了遵守常见的代码规范,为程序某一模块选择正确合适的算法,降低复杂度解决我们面临的问题,例如二分搜索、快速排序、合并排序、map-reduce等等。


也包含了选择何种数据结构,例如数组、哈希映射、链表、堆栈、环形队列等。还包括了增加缓存、提高缓存命中率、优化程序结构、减少锁争用甚至无锁。甚至包括了标准库、运行时与编译时的调优。


结合下面这图来看下,在Go1.14中,对运行时对于内存管理进行了优化,将原先基于Treap平衡树的内存分配算法,替换为了基于位图的基数树分配方法[6],解决锁争用问题,加速了并发的内存分配。

图7 Go1.14内存管理基数树结构





(四)操作系统级别



当今的软件,通常不会直接在机器硬件上直接执行,相反,我们通过操作系统将程序分为了多个线程,并将线程调度到 CPU 上运行。


操作系统调度、硬/软中断、线程上下文切换等因素,都与程序性能息息相关。同时操作系统提供了其他服务,如内存和 IO 管理、设备访问等。


图8  linux CFS 调度算法
 (图源:IBM developer [7])



特别是在当前云原生兴起的时代,操作系统层面还包括了额外的虚拟化层(虚拟机、容器),这给性能分析带来了新的挑战。




(五)硬件级别



代码转换而来的一组指令,由计算机 CPU 单元执行,其内部连接到主板中的其他重要部分:RAM、本地磁盘、网络接口、输入和输出设备等等。
借助操作系统,开发人员或运营商可以从硬件的复杂性中脱离出来,抽象处理。
然而,应用程序的性能,确实受到硬件设计和规格的限制。下图为具有多核CPU和统一内存访问 (UMA) 的高级计算机体系结构,CPU具有多级缓存,可能会遇到CPU缓存失效、CPU缓存假共享等问题。
另外新的CPU架构(例如多核CPU的NUMA架构)、CPU 和内存节点之间的有限内存总线带宽,都在不同程度上影响程序的运行。


图9 具有多核CPU和统一内存访问 (UMA) 的高级计算机体系结构 (来自《Efficient Go》)


前面对于性能分析优化级别进行了抽象,但是要强调的是,并不是每一个级别,Go开发者都有深入的涉及。比如操作系统层面和硬件层面就很少涉及到,而第一层系统级别的抽象又常常是更大规模架构的一部分,例如涉及到分布式架构的选择。
更多的时候,开发者可能关注的范围,在第二层代码设计层面和第三层代码具体实施过程中错误的代码导致的性能问题。







在本文接下来的小节中,将主要探讨CPU这一资源在系统级别和程序设计阶段,面临的性能瓶颈原因,观察指标、排查手段和解决方法。

希望可以帮助你在实际程序设计,以及性能问题排查分析阶段,能够把问题拆解到特定的层次,聚焦问题并逐个击破。







二、Go语言协程运行模型


Go语言在操作系统线程的基础上创造了轻量级的协程。在解释每一层可能遇到的性能瓶颈之前,首先需要简单介绍一下Go语言运行和调度的GMP模型。




GMP模型了解Go调度模型



经典的GMP概念模型,生动地概括了线程与协程的关系:Go进程中的众多协程其实依托于线程,借助操作系统将线程调度到CPU执行,从而最终执行协程。
在GMP 模型中,G 代表的是Go语言中的协程(Goroutine),M 代表的是实际的线程,而P 代表的是Go逻辑处理器(Process)。


图10 GMP模型含义



Go语言为了方便协程调度与缓存考虑,抽象出了逻辑处理器P。G、M、P之间的对应关系可对照下图来看。在任一时刻,一个P可能在其本地包含多个G,同时,一个P在任一时刻只能绑定一个M。

图11 GMP模型 (图源:《Go底层原理剖析》[8] )






协程上下文切换速度明显快于线程



协程的速度要快于线程,其原因在于:

01

协程切换不用进入操作系统内核。

02Go 语言中的协程切换,只需要保留极少的状态和寄存器变量值(SP/BP/PC),而线程切换,会保留额外的寄存器变量值(例如浮点寄存器)。


上下文切换的速度受到诸多因素的影响,这里列出一些值得参考的量化指标:线程切换的速度大约为1~2 微秒,Go 语言中协程切换的速度比它快数倍,为0.2 微秒左右。

图12 线程切换 VS 协程切换

 (图源:《Go底层原理剖析》[8] )



Go原语级别支持协程,由于上述原因,使得Go语言更容易书写并发程序,可以在程序轻易存在成千上万的协程,借助Go运行时强大的调度器公平调度
接下来,让我们分别看待一下在前两层可能会遇到的CPU性能瓶颈问题,并思考如何进行规避、分析和解决。







三、系统级别优化


现代大型项目通常要处理的qps规模和数据规模,都是海量的。在这种背景下,难以靠单台机器去承受所有流量。这时候面临的瓶颈,可能是来自单台机器CPU无法处理大量负载带来的处理延迟。
因此,现代大型系统普遍采用了分布式、微服务的系统架构,借助灵活的程序扩展快速适应动态的外部变化,这要求系统设计时服务本身是可扩展的,并规划好负载均衡的策略。



分布式系统设计是另一门艰深的学问


本文无意过多涉及分布式系统架构设计的内容,关于如何构建合适的分布式系统,推荐阅读堪称神作的《Designing Data-Intensive Applications》一书[9]。
最后,系统级别优化还包括服务治理,涉及到服务限流、重试、降级、熔断等策略,避免异常情况下的大流量打垮整个服务,甚至服务雪崩的问题。还需要压测好单个服务本身的负载水平,必要时进行手动和自动扩容。




系统性能监控必不可少



不难发现,为系统建立合适的观测监控指标,了解服务的运行状态有重要意义。机器和系统cpu重要观测指标包括用户 CPU 使用率,系统 CPU 使用率,等待 I/O 的 CPU 使用率,软中断和硬中断的 CPU 使用率,loadavg平均活跃进程数、线程上下文切换等。
CPU相关的指标、分析工具与思考方式,可查阅《Systems Performance, 2nd Edition》一书。
注意:如果是在容器内,不能直接通过top等指令得到容器cpu指标,需要借助其他手段计算得出,例如cAdvisor工具。







四、程序内部设计和组织优化




前面介绍的系统级别优化将服务看作了一个黑盒,在此基础上做架构设计。然而,这一节介绍的程序内部架构设计,将把黑盒打开,考虑如何最大限度地利用好现有资源,满足需求。


这里面涉及多个话题,即如何进行有效地设计,在明确优化目标后,如何发现现有系统中存在的CPU性能瓶颈,如何通过工具和指标分析找到造成瓶颈的根因,如何针对问题进行有效地优化。


在程序设计时,咱们需要围绕着需要实现的性能目标,结合Go在不同场景下的并发特点,充分压榨多核CPU的性能。



异步化求快速返回



第一是求异步化,为了外部用户的体验,降低延迟,有时我们可以结合业务对流程进行异步化,快速返回结果给外部用户。这可以提高服务的qps与吞吐量。

 

例如任务执行完毕后需要将一些数据存入缓存中,这时,可以直接返回结果,并异步的写入数据库。再比如调用一个执行周期很长的函数,可以先直接返回,并在执行完毕后请求用户给的回调地址。但是无论如何异步化,终究是需要执行任务的。



并行化缩短关键路径



第二是在执行的关键阶段求并行化,尽可能把串行改为并行。
大家可能都听说过华罗庚烧水泡茶的故事,讲的就是将整个大任务分割为小任务,而关键任务并行进行处理,从而大大减少整个任务的处理时间。
例如任务分别耗时 T1、T2、T3,如果串行调用总耗时 T=T1+T2+T3。而如果三个任务并行执行,总耗时为max(T1,T 2,T3)。在程序设计中也遵循类似的思路。只有做到真正的并行,才能充分发挥多核CPU的性能。


图13 并发与并行  (图源:《Go底层原理剖析》)





并发模型发挥运行时的最大威力



第三是要合理选择与实际系统匹配的并发模型,根据自身服务的不同,需要了解Go语言在网络I/O、磁盘I/O,CPU密集型系统在程序处理过程中的不同处理模型。
虽然协程是非常轻量级的资源,但也不是免费的。过多的协程,只会额外增加调度器的负担和内存的数量,由于并行的线程是有限的,而调度器又需要保证公平性。


因此控制程序中工作的协程数量,是一个改善程序性能的思路。


经典的fin-in、fin-out、job-worker并发模型,都是为了解决类似的问题。



网络I/O底层多路复用



在实际中,很多项目是基于tcp、http这样的网络服务器,这时候需要掌握Go语言的网络模型。Go原生的网络库每一个请求,都会新创建一个协程。


图14 Go网络模型 (图源:腾讯技术工程 [10] )
由于Go网络库底层封装了(epoll/kqueue/iocp)库,使用了I/O 多路复用技术,并巧妙利用协程的调度创建了一个看似同步,实则异步的网络模型,这也是Go语言在网络方面处理效率十分优良的原因。
注意:除非在非常极端大量的请求情况下,才会考虑Go网络库创建大量协程带来的性能瓶颈。




磁盘I/O同步阻塞



对于磁盘I/O密集型的系统,并不像网络I/O那样底层是异步,而是同步的。这也意味着执行文件的读写操作,需要等待系统调用read/write操作结束才能继续执行。
虽然现代操作系统,并不是所有写操作都直接访问磁盘,而是写入到page cache中,并有内核线程定时写入到磁盘,这加快了处理速度。在一些关键场景,需要手动调用文件file.Sync() 函数强制将在缓存中的数据落盘。
另外当一些特殊场景需要立即落盘时,可调用direct I/O,数据将不经过缓存,直接操作磁盘。


图15 linux文件I/O系统





多种手段应对磁盘I/O堵塞



对于磁盘I/O密集型的系统,面临的一个性能问题可能是由于同步I/O的堵塞导致延迟上涨。这在三个方面启发了程序的设计。


01第一是:在读写时,在内存中加入buffer批量写入从而进一步减少系统调用。关于在内存中加入buffer,和直接写入文件描述符的benchmark对比测试,可查看文末的参考资料[11]。02第二是:合理规划程序的GOMAXPROCS数量。
GOMAXPROCS反应的是可以并行处理的线程数量。03第三是:观测线程数量指标,特别是在磁盘I/O密集型的系统,线程会随着系统堵塞而发生增长。在实际中,甚至有磁盘I/O堵塞,程序超过上万个线程导致panic的案例[12]。如下指令可以在linux操作系统中查看某程序创建的线程数量,根据线程数量反映出当前程序的运行状态。


cat /proc/pidxx/status | grep Threads


对于陷入到I/O堵塞状态的协程,go语言有系统监控,当检测到10ms的系统调用堵塞,可能会新建线程去处理其他的协程。这时候增加GOMAXPROCS的数量就显得更重要了,因为其可以增加并行处理的线程数量,从而增加系统的吞吐量。


图16 Go运行时定时系统监控源码





无锁化减少并行的沟通成本



除了要考虑并发模型,还有一点是要考虑无锁化,保证并发的威力。
考虑一个极端的不合理的锁设计,可能会让所有的用户协程等待某一个协程执行完成,从而退化为了串行执行。
无锁化并不是完全不加锁,指的是合理设计并发控制,例如设计无锁的结构,在多读少写场景用读锁替代写锁,用局部缓存来减少对于全局结构的访问(可以在sync.pool、go内存分配、go调度器等组件的巧妙设计中看到这种努力,具体可参考《Go底层原理剖析》)。
由于锁和通道导致请求耗时增加的情况,可以通过pprof观察到。
go tool pprof http://localhost:9981/debug/pprof/blockgo tool pprof http://localhost:9981/debug/pprof/mutex


注意:我们一般不会考虑标准库和运行时中锁带来的瓶颈问题,只有在非常大量的并发访问下,例如上万次qps,才会考虑标准库可能面临的锁竞争问题。






验证并行效率,做到心中有数



当我们考虑到了上面的几点,如何来验证程序实际并行的效率?
如果瞬时协程的数量大于了GOMAXPROCS,也就是当前线程数量,那么CPU才有可能被充分压榨。因此协程的瞬时数量,其实是一个重要的观测指标,其表征了当前程序的并行处理状况。获取协程数量的方式有多种[13]:

图17 获取协程数量的几种方式


这种瞬时的协程数,通过metric 采样的方式采集到监控平台,从而变得有时序性,更有监控意义。在这里要注意的是,协程数量并不是一个准确的东西,因为有一些协程,比如是初始化时候的定时任务,并不占用用户太长时间内的CPU运行。再比如,两个协程由于锁的原因并不能够同时运行。
因此,除了观察协程的数量,还需要分析整个调度器的运行状态,有这样俩思路:

图18 调度器分析


首先,我们查看cpu idle 随着负载的增加是不是已经很难上涨了,例如只有50%,无法完全压榨CPU资源,同时请求的耗时增加。这可能是由于并发不够导致的。
第一种思路咱们结合代码看一下,是在启动时加入启动参数,scheddetail=1,并且schedtrace指定为1000毫秒即1秒钟打印一次调度器瞬时的运行情况。


GODEBUG=schedtrace=1000,scheddetail=1 ./main


打印出来的信息结合下图来看看,其可以打印出GMP之间的对应关系,并且打印出局部运行队列与全局运行队列的个数。如果当前M都绑定了G,那么curg对应的是G的协程id。如果当前所有的M都有对应的G运行,那么表明当前线程都已充分运行。关于调度器打印信息的详细说明,可查看文末的参考资料[14]。


图19  调度器打印日志细节
最好的观察手段,需要使用更好的工具,pprof 和trace 工具是分析Go性能的常见工具。


curl -o trace.out http://127.0.0.1:9981/debug/pprof/trace?seconds=30 go tool trace -http=localhost:8000 trace.out


下面通过trace工具查看调度信息,能够看到每一个P中正在运行的G的情况,下图中的空白区域代表P找不到接下来需要运行的G,表明CPU利用率低。


图20 trace查看调度细节


造成这种现象的原因,可能是错误的并发模型导致的锁延迟、调度延迟等,需要进一步具体分析。我们会在下一篇文章中看到进一步分析的例子。







结语



性能优化重要且非常复杂,考验开发者的内功。对不懂的人来说,就是一个知识的大杂烩,摸不着头脑。 
然而,通过对知识的分层抽象与梳理,可以让我们有的放矢,将问题聚焦于特定的层面。


在本文中,程序面临的任何性能优化问题,都可对应到系统设计到硬件层面的5层抽象模型中。


自上而下逐个击破,找到对应的设计思路,瓶颈问题,观察指标、排查手段和解决方法。这将帮助你更早地规避性能问题、更快地定位性能问题、更有效地解决性能问题。


对于本文的知识,总地来说,性能分析与优化在思考上要抽象分层逐个击破、在系统设计上要合理拆分与组合、在程序设计上要异步化、求并行、无锁化、设计和系统特性匹配的并发模型。


图21 全文思维导图

(可帮助你回顾内容要点)




最后咱们延申思考下:你实际中常用的分析性能的工具,又是什么呢?
在后面的分享我们还将看到在代码实施、操作系统和硬件级别面临的性能挑战和优化手段,更多强悍的性能分析工具等着你









参考资料:


[1] 过早的性能优化是万恶之源,查看这句话的上下文:《Structured Programming With Go To Statements》by Donald Knuth


[2] 人月神话是项目管理的经典书籍,论述焦油坑《Mythical Man-Month》


[3]  性能的定义:https://www.ibm.com/docs/zh/db2/10.5?topic=fundamentals-performance-tuning


[4] 效率优化的级别:《Efficient Go》


[5] 单体服务 VS 微服务:《Kubernetes in Action》


[6] go1.14内存分配基数树:https://go.googlesource.com/proposal/+/a078ea9d72b99dc88fdfd2cb6ee150a8ce202ea2/design/35112-scaling-the-page-allocator.md


[7] linux调度器原理:https://developer.ibm.com/tutorials/l-completely-fair-scheduler/


[8] Go调度器原理,可以参考笔者的著作:《Go语言底层原理剖析》


[9] 分布式系统设计原理:《Designing Data-Intensive Applications》


[10] Go语言网络模型:https://zhuanlan.zhihu.com/p/299041493


[11] 文件buffer与直接写入性能对比:https://www.instana.com/blog/practical-golang-benchmarks/#file-i-o


[12] io堵塞导致创建上万个线程,panic的案例与原理:https://mp.weixin.qq.com/s/0nwe-YrMGrl2futS5wkT6A


[13] Go运行时metric指标详解:https://mp.weixin.qq.com/s/4mFFbzrviLWViws8NbRzbA


[14]  调度器打印指标解释:https://www.ardanlabs.com/blog/2015/02/scheduler-tracing-in-go.html


[15] 系统性能的思考方式、指标与工具《Systems Performance, 2nd Edition》







THE END 

转载请联系本公众号获得授权




FOLLOW US关注我们欢迎各领域技术人投稿投稿邮箱 | hannan@it168.com



往期精彩回顾 | 延申阅读



迎接中国基础软件创新的春天

基于ClickHouse百亿级广告平台实时数仓构建实战

实时数仓方案五花八门,实际落地如何选型和构建

Delta Lake数据湖原理和实战

十年技术进阶路,让我明白了三件要事

时序数据库破局开放探讨




文章好看就点这里

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存