Golang-optimization「0」: 序章
前言
从实习到正式工作,我使用 Golang 作为主力编程语言也已经有两年多的时间了;绝大多数的服务和需求我都会选择使用 Golang 实现,只有对性能不敏感、需要大量文本处理 / 数据处理的场景下我才会选择我的老相好 Python
Golang是一门简单的语言,最为大家所推崇的莫过于 Go 的并发,协程加信道,sync 加 select,我觉得很难再有那么一门语言,并发能够做得像 Go 一样简单;其开发者团队一直秉承着“大道至简”的设计理念,但那些存在于各个角落的“简单”特性也让实际使用者们又爱又恨
这两年是我吸收知识最快的一段时间,加上工作上的忙碌,以至于我攒下了许多“要写的文章”,偶尔发出来的文章也是零零散散的一些知识点。我还是希望自己能够希望能够将这些知识真正的体系化起来,不断地完善,而不是仅仅讲零散的知识点打上 tag
那么这个系列我们就来聊一聊我们在Golang日常开发中就可以直接用到的一些简单的性能优化
需要了解的词
L1, L2, L3缓存
L1, L2, L3缓存都是计算机中的高速缓存,是计算机中的快速存储器,它们保存着CPU经常使用的数据,具体区别如下图:benchmark
性能的衡量基准,常常用到 ms/op, MB/op, allocs/op 等指标
pprof
pprof是一个用于分析 Golang 应用性能数据的可视化和分析工具
为什么要做性能优化
首先,我们来谈一谈为什么要性能优化;这里我们不从理论展开,直接 showcase:
在这个降本增效的大环境下,我们首先直接从💰的角度来说:如果业务的后端服务规模足够大,那么一个程序员通过优化帮公司节省的成本,或许就可以负担他十年的工资了
再具体一点,将视线转移到我们的 K8S 集群,我们为所有的 deployment 设置了资源限制,有时我们会看到我们的一些 pod 正在重启——我们的OOM-killer 正在为我们“处理问题”,解决了我们的内存泄漏问题;当然,OOM 并不一定指向内存泄露,更通常指向常见的资源不足
不必要的资源消耗正在蚕食我们的钱包,我们必须做点什么!
那么现在我们来谈谈性能优化,之所以有性能优化这个步骤,是因为过早的优化 / 过度设计是无意义的,如果不从整个应用层面来看,我们无法知道某个子模块或是某个代码片段是否会成为性能瓶颈;我们需要的是快速构建出应用Demo,再来从应用层面考虑和测试其性能瓶颈
此外,每一次性能优化都得是合理的,有依据的;which means 每一次性能优化都得建立在完整建设的 benchmark 的基础上,这样才能量化其为我们带来的利益(以及确保自己没有写出负优化)
并且我们也不能忽略一件事情:大多数优化都会使代码的可读性 / 可维护性变差,我们还是需要把控好这个平衡
如何在全局看如何做优化
我认为从全局来看,首先我们在上一小节明确了,做优化就是为了省💰,把💰真正的花在刀刃上;具体到应用层面,则是为了解决性能瓶颈,保证我们的资源都得到合理的负载,才能最大化使用资源
那么我们再转换到资源的视角,从资源视角出发,我们需要审视CPU、内存、磁盘与网络这四个对于后台服务来说最重要的四种资源
对于计算密集型的程序来说,优化的主要精力会放在 CPU 上,要知道 CPU 基本的流水线概念,知道怎么样在使用少的 CPU 资源的情况下,达到相同的计算目标
对于 IO 密集型的程序(后端服务一般都是 IO 密集型)来说,优化可以是降低程序的服务延迟,也可以是提升系统整体的吞吐量
IO 密集型应用主要与磁盘、内存、网络打交道。因此我们需要知道一些基本的与磁盘、内存、网络相关的基本数据与常见概念:
- 要了解内存的多级存储结构:L1,L2,L3,主存,还要对这些不同层级的存储操作时的大致延迟有基本的概念和意识:latency numbers every programmer should know
- 要知道基本的文件系统读写 syscall,批量 syscall,数据同步 syscall,如果大家有去看过一些 pprof 的 gragh view 的话,就会发现往往大多数底层应用库的性能消耗都是在 syscall
- 要熟悉项目中使用的网络协议,至少要对 TCP, HTTP 有所了解
性能优化的黄金准则
在我看来,性能优化的一条最重要的准则是:
优化越靠近应用层效果越好
Performance tuning is most effective when done closest to where the work is performed. For workloads driven by applications, this means within the application itself.
这也很好理解,我们在应用层的逻辑优化能够帮助应用提升几十倍的性能,而最底层的优化可能也就只能提升几个百分点了,因为应用层是对底层的调用,自上而下的优化自然是效率更高的
这里也有一个广为人知的例子:来自GTA Online 的新闻——rockstar thanks gta online player who fixed poor load times
简单来说,GTA online 的游戏启动过程让玩家等待时间过于漫长,经过各种工具分析,发现一个 10M 的文件加载就需要几十秒,用户 diy 进行优化之后,将加载时间减少 70%,并分享出来:how I cut GTA Online loading times by 70%
这就是一个非常典型的案例,GTA 在商业上取得了巨大的成功,但不妨碍它局部的代码是a piece of shit
。我们只要把这里的重复逻辑干掉,就可以完成三倍的优化效果。同样的案例,如果我们去优化磁盘的读写速度,则可能收效甚微
优化的一般流程
我认为对于一个典型的 API 应用来说,优化工作基本遵从下面的工作流:
- 建立评估指标,例如固定 QPS 压力下的延迟或内存占用,或模块在满足 SLA 前提下的极限 QPS
- 通过自研、开源压测工具进行压测,直到模块无法满足预设性能要求:如大量超时,QPS 不达预期,OOM
- 通过内置 profile 工具寻找性能瓶颈
- 本地 benchmark 证明优化效果
- 集成 patch 到业务模块,回到 2
自下而上的性能优化
在编写一些核心组件 / library 组件时,我们会关注关键的函数性能,这时可以脱离系统自下而上地去探讨性能优化,Go 语言的 test 子命令集成了相关的功能,只要我们按照约定来写 Benchmark 前缀的测试函数,就可以实现函数级的基准测试,如下面的横向数组遍历和纵向数组遍历的基准测试对比:
1 | package main |
执行 go test -bench=.
输出为:
1 | BenchmarkHorizontal-12 102368 10916 ns/op |
可见横向遍历数组要快得多,这提醒我们在写代码时要考虑 CPU 的 cache 设计及局部性原理,以使程序能够在相同的逻辑下获得更好的性能
除了 CPU 优化,我们还经常会碰到要优化内存分配的场景。只要带上 -benchmem 的 flag 就可以实现了
出于谨慎考虑,修改高并发接口时,拿不准的尽量都应进行简单的线下 benchmark 测试;当然,我们不能指望靠写一大堆 benchmark 帮我们发现系统的瓶颈,实际工作中还是要使用前文提到的优化工作流来进行系统性能优化。也就是尽量从接口整体而非函数局部考虑去发现与解决瓶颈
自上而下的性能优化
从整个服务的视角来看,如常见的 API 服务,我们可以使用两种方式对其进行压测:
- 固定 QPS 压测:在每次系统有大的特性发布时,都应进行固定 QPS 压测,与历史版本进行对比,需要关注的指标包括,相同 QPS 下的系统的 CPU 使用情况,内存占用情况(监控中的 RSS 值,即包括内存碎片),goroutine 数,GC 触发频率和相关指标(是否有较长的 stw,mark 阶段是否时间较长等),平均延迟,p99 延迟
- 极限 QPS 压测:极限 QPS 压测一般只是为了 benchmark show,没有太大意义。系统满负荷时,基本 p99 已经超出正常用户的忍受范围了
压测过程中需要采集不同 QPS 下的 CPU profile,内存 profile,记录 goroutine 数。与历史情况进行 AB 对比
Go 的 pprof 还提供了 --base 的 flag,能够很直观地帮我们发现不同版本之间的指标差异:用 pprof 比较内存使用差异
总之记住一点,接口的性能一定是通过压测来进行优化的,而不是通过硬啃代码找瓶颈点。关键路径的简单修改往往可以带来巨大收益。如果只是啃代码,很有可能将 1% 优化到 0%,优化了 100% 的局部性能,对接口整体影响微乎其微
寻找性能瓶颈
在压测时,我们通过以下步骤来逐渐提升接口的整体性能:
- 使用固定 QPS 压测,以阶梯形式逐渐增加压测 QPS,如 1000 -> 每分钟增加 1000 QPS
- 压测过程中观察系统的延迟是否异常
- 观察系统的 CPU 使用情况
- 如果 CPU 使用率在达到一定值之后不再上升,反而引起了延迟的剧烈波动,这时大概率是发生了阻塞,进入 pprof 的 web 页面,点击 goroutine,查看 top 的 goroutine 数,这时应该有大量的 goroutine 阻塞在某处,比如 Semacquire
- 如果 CPU 上升较快,未达到预期吞吐就已经过了高水位,则可以重点考察 CPU 使用是否合理,在 CPU 高水位进行 profile 采样,重点关注火焰图中较宽的“平顶山”
挖坑
说完了性能优化本身,最后我们来为 Golang 中真正的实用优化点基于 Golang 中的基本概念分个类,也是为本系列文章先挖好坑:
- 数组和切片 Done
- 字符串 Done
- 结构体
- 函数
- 映射表
- 接口
- 指针
- Goroutine
- 通道(channel)
- 边界检测消除
- gc mark / 变量逃逸