郑建勋:Go程序性能分层优化 | CPU篇
点击上方蓝字关注我们
责编 | 韩楠
约 5,500 字 | 11 分钟阅读
世界上唯一不变的,就是变化。
性能问题,是所有程序和系统随着时间都可能面临的问题,虽然本文中以Go程序作为切入点,但其思考方式,我认为仍然适用于其他语言编写的系统,并有启发意义。
不过早优化 ≠ 不优化
但实际上,这句话隐含的第一层含义是开发者应该更多关注于程序中的关键部分,而忽略掉不关键的部分。可以优化并不意味着值得优化。
这句话隐含的第二层含义就是迟早也要对关键代码做优化,逃得过初一,躲不过十五……
另外,这句话强调的是不要过早,而不是不进行优化。
实际上,一个完全不考虑设计的系统,最终带来的就是难以维护的“屎山”,逐渐又慢又臭。就像《人月神话》[2] 中描述的焦油坑,所有进入的努力都慢慢地陷入其中并被埋葬。我们多数人应该都深有体会吧。
图1 拉布雷亚沥青坑 (图源:维基百科)
所以,程序的性能问题,实际上贯穿于程序的整个生命周期。不管是程序正确的架构设计、快速的算法和数据结构设计,还是为了应对快速增长的需求或降低成本需要做的攻坚,可以说,这些都离不开性能的分析与优化。
性能优化是成本与收益的权衡
性能是指计算机系统响应特定工作负载时的行为方式。按照系统响应时间、吞吐量和资源利用率来测量性能。 ——IBM [3]
优化最开始需要明确最终到达的目标是什么。性能优化是一种手段,最后需要为目标服务。很多时候一种资源比另一种资源更稀缺,因此可能牺牲其他资源来优化某一种资源(例如时间换空间的方式)。
优化的资源可能是多方面的,比如内存、磁盘I/O、网络、CPU等。可能出现的瓶颈或者可以优化的地方,也可能是多方面的,例如代码层面、算法与数据结构层面、操作系统层面、硬件层面。特别是现在云原生技术的兴起,给排查程序性能问题带来了新的挑战。
Go程序的性能分析,显然是复杂的。考验开发者计算机综合素养和具备的知识体系。虽然复杂,但是在这一系列文章中,笔者依然试图抽丝剥茧,构建起分析性能问题的模型和思路。在本文中,专注于程序的CPU性能问题优化。
一、性能优化分层抽象
由于性能的复杂性,首先让我们有的放矢,构建起性能分析依赖的优化级别,在这里参考了《Efficient Go》[4] 的划分方法,自上而下划分为了这样的几个级别:图2 性能优化分层抽象
上图中性能优化的分层抽象,有助于我们将复杂的性能问题拆解开,来并逐个击破。接下来笔者将对照图2,带着大家一同分别看看每一层在性能上面临的问题和挑战。
(一)系统级别
图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 管理、设备访问等。
(图源:IBM developer [7])
(五)硬件级别
借助操作系统,开发人员或运营商可以从硬件的复杂性中脱离出来,抽象处理。
然而,应用程序的性能,确实受到硬件设计和规格的限制。下图为具有多核CPU和统一内存访问 (UMA) 的高级计算机体系结构,CPU具有多级缓存,可能会遇到CPU缓存失效、CPU缓存假共享等问题。
另外新的CPU架构(例如多核CPU的NUMA架构)、CPU 和内存节点之间的有限内存总线带宽,都在不同程度上影响程序的运行。
图9 具有多核CPU和统一内存访问 (UMA) 的高级计算机体系结构 (来自《Efficient Go》)
前面对于性能分析优化级别进行了抽象,但是要强调的是,并不是每一个级别,Go开发者都有深入的涉及。比如操作系统层面和硬件层面就很少涉及到,而第一层系统级别的抽象又常常是更大规模架构的一部分,例如涉及到分布式架构的选择。
更多的时候,开发者可能关注的范围,在第二层代码设计层面和第三层代码具体实施过程中错误的代码导致的性能问题。
在本文接下来的小节中,将主要探讨CPU这一资源在系统级别和程序设计阶段,面临的性能瓶颈原因,观察指标、排查手段和解决方法。
希望可以帮助你在实际程序设计,以及性能问题排查分析阶段,能够把问题拆解到特定的层次,聚焦问题并逐个击破。
二、Go语言协程运行模型
GMP模型了解Go调度模型
在GMP 模型中,G 代表的是Go语言中的协程(Goroutine),M 代表的是实际的线程,而P 代表的是Go逻辑处理器(Process)。
图10 GMP模型含义
Go语言为了方便协程调度与缓存考虑,抽象出了逻辑处理器P。G、M、P之间的对应关系可对照下图来看。在任一时刻,一个P可能在其本地包含多个G,同时,一个P在任一时刻只能绑定一个M。
协程上下文切换速度明显快于线程
协程的速度要快于线程,其原因在于:
01协程切换不用进入操作系统内核。
02图12 线程切换 VS 协程切换
(图源:《Go底层原理剖析》[8] )
接下来,让我们分别看待一下在前两层可能会遇到的CPU性能瓶颈问题,并思考如何进行规避、分析和解决。
三、系统级别优化
因此,现代大型系统普遍采用了分布式、微服务的系统架构,借助灵活的程序扩展快速适应动态的外部变化,这要求系统设计时服务本身是可扩展的,并规划好负载均衡的策略。
分布式系统设计是另一门艰深的学问
最后,系统级别优化还包括服务治理,涉及到服务限流、重试、降级、熔断等策略,避免异常情况下的大流量打垮整个服务,甚至服务雪崩的问题。还需要压测好单个服务本身的负载水平,必要时进行手动和自动扩容。
系统性能监控必不可少
CPU相关的指标、分析工具与思考方式,可查阅《Systems Performance, 2nd Edition》一书。
注意:如果是在容器内,不能直接通过top等指令得到容器cpu指标,需要借助其他手段计算得出,例如cAdvisor工具。
四、程序内部设计和组织优化
异步化求快速返回
例如任务执行完毕后需要将一些数据存入缓存中,这时,可以直接返回结果,并异步的写入数据库。再比如调用一个执行周期很长的函数,可以先直接返回,并在执行完毕后请求用户给的回调地址。但是无论如何异步化,终究是需要执行任务的。
并行化缩短关键路径
大家可能都听说过华罗庚烧水泡茶的故事,讲的就是将整个大任务分割为小任务,而关键任务并行进行处理,从而大大减少整个任务的处理时间。
例如任务分别耗时 T1、T2、T3,如果串行调用总耗时 T=T1+T2+T3。而如果三个任务并行执行,总耗时为max(T1,T 2,T3)。在程序设计中也遵循类似的思路。只有做到真正的并行,才能充分发挥多核CPU的性能。
并发模型发挥运行时的最大威力
虽然协程是非常轻量级的资源,但也不是免费的。过多的协程,只会额外增加调度器的负担和内存的数量,由于并行的线程是有限的,而调度器又需要保证公平性。
网络I/O底层多路复用
在实际中,很多项目是基于tcp、http这样的网络服务器,这时候需要掌握Go语言的网络模型。Go原生的网络库每一个请求,都会新创建一个协程。
由于Go网络库底层封装了(epoll/kqueue/iocp)库,使用了I/O 多路复用技术,并巧妙地利用协程的调度创建了一个看似同步,实则异步的网络模型,这也是Go语言在网络方面处理效率十分优良的原因。
注意:除非在非常极端大量的请求情况下,才会考虑Go网络库创建大量协程带来的性能瓶颈。
磁盘I/O同步阻塞
虽然现代操作系统,并不是所有写操作都直接访问磁盘,而是写入到page cache中,并有内核线程定时写入到磁盘,这加快了处理速度。在一些关键场景,需要手动调用文件file.Sync() 函数强制将在缓存中的数据落盘。
另外当一些特殊场景需要立即落盘时,可调用direct I/O,数据将不经过缓存,直接操作磁盘。
多种手段应对磁盘I/O堵塞
GOMAXPROCS反应的是可以并行处理的线程数量。03
cat /proc/pidxx/status | grep Threads
无锁化减少并行的沟通成本
考虑一个极端的不合理的锁设计,可能会让所有的用户协程等待某一个协程执行完成,从而退化为了串行执行。
无锁化并不是完全不加锁,指的是合理设计并发控制,例如设计无锁的结构,在多读少写场景用读锁替代写锁,用局部缓存来减少对于全局结构的访问(可以在sync.pool、go内存分配、go调度器等组件的巧妙设计中看到这种努力,具体可参考《Go底层原理剖析》)。
由于锁和通道导致请求耗时增加的情况,可以通过pprof观察到。
go tool pprof http://localhost:9981/debug/pprof/block
go 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
最好的观察手段,需要使用更好的工具,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
图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