从实习到正式工作,我使用 Golang 作为主力编程语言也已经有两年多的时间了;绝大多数的服务和需求我都会选择使用 Golang 实现,只有对性能不敏感、需要大量文本处理 / 数据处理的场景下我才会选择我的老相好 Python
Golang是一门简单的语言,最为大家所推崇的莫过于 Go 的并发,协程加信道,sync 加 select,我觉得很难再有那么一门语言,并发能够做得像 Go 一样简单;其开发者团队一直秉承着“大道至简”的设计理念,但那些存在于各个角落的“简单”特性也让实际使用者们又爱又恨
这两年是我吸收知识最快的一段时间,加上工作上的忙碌,以至于我攒下了许多“要写的文章”,偶尔发出来的文章也是零零散散的一些知识点。我还是希望自己能够希望能够将这些知识真正的体系化起来,不断地完善,而不是仅仅讲零散的知识点打上 tag
那么这个系列我们就来聊一聊我们在Golang日常开发中就可以直接用到的一些简单的性能优化
性能的衡量基准,常常用到 ms/op, MB/op, allocs/op 等指标
首先,我们来谈一谈为什么要性能优化;这里我们不从理论展开,直接 showcase:
在这个降本增效的大环境下,我们首先直接从💰的角度来说:如果业务的后端服务规模足够大,那么一个程序员通过优化帮公司节省的成本,或许就可以负担他十年的工资了
再具体一点,将视线转移到我们的 K8S 集群,我们为所有的 deployment 设置了资源限制,有时我们会看到我们的一些 pod 正在重启——我们的OOM-killer 正在为我们“处理问题”,解决了我们的内存泄漏问题;当然,OOM 并不一定指向内存泄露,更通常指向常见的资源不足
不必要的资源消耗正在蚕食我们的钱包,我们必须做点什么!
那么现在我们来谈谈性能优化,之所以有性能优化这个步骤,是因为过早的优化 / 过度设计是无意义的,如果不从整个应用层面来看,我们无法知道某个子模块或是某个代码片段是否会成为性能瓶颈;我们需要的是快速构建出应用Demo,再来从应用层面考虑和测试其性能瓶颈
此外,每一次性能优化都得是合理的,有依据的;which means 每一次性能优化都得建立在完整建设的 benchmark 的基础上,这样才能量化其为我们带来的利益(以及确保自己没有写出负优化)
并且我们也不能忽略一件事情:大多数优化都会使代码的可读性 / 可维护性变差,我们还是需要把控好这个平衡
我认为从全局来看,首先我们在上一小节明确了,做优化就是为了省💰,把💰真正的花在刀刃上;具体到应用层面,则是为了解决性能瓶颈,保证我们的资源都得到合理的负载,才能最大化使用资源
那么我们再转换到资源的视角,从资源视角出发,我们需要审视CPU、内存、磁盘与网络这四个对于后台服务来说最重要的四种资源
对于计算密集型的程序来说,优化的主要精力会放在 CPU 上,要知道 CPU 基本的流水线概念,知道怎么样在使用少的 CPU 资源的情况下,达到相同的计算目标
对于 IO 密集型的程序(后端服务一般都是 IO 密集型)来说,优化可以是降低程序的服务延迟,也可以是提升系统整体的吞吐量
IO 密集型应用主要与磁盘、内存、网络打交道。因此我们需要知道一些基本的与磁盘、内存、网络相关的基本数据与常见概念:
在我看来,性能优化的一条最重要的准则是:
优化越靠近应用层效果越好
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 应用来说,优化工作基本遵从下面的工作流:
在编写一些核心组件 / library 组件时,我们会关注关键的函数性能,这时可以脱离系统自下而上地去探讨性能优化,Go 语言的 test 子命令集成了相关的功能,只要我们按照约定来写 Benchmark 前缀的测试函数,就可以实现函数级的基准测试,如下面的横向数组遍历和纵向数组遍历的基准测试对比:
package main
import "testing"
var x = make([][]int, 100)
func init() {
for i := 0; i < 100; i++ {
x[i] = make([]int, 100)
}
}
func traverseVertical() {
for i := 0; i < 100; i++ {
for j := 0; j < 100; j++ {
x[j][i] = 1
}
}
}
func traverseHorizontal() {
for i := 0; i < 100; i++ {
for j := 0; j < 100; j++ {
x[i][j] = 1
}
}
}
func BenchmarkHorizontal(b *testing.B) {
for i := 0; i < b.N; i++ {
traverseHorizontal()
}
}
func BenchmarkVertical(b *testing.B) {
for i := 0; i < b.N; i++ {
traverseVertical()
}
}
执行 go test -bench=.
输出为:
BenchmarkHorizontal-12 102368 10916 ns/op
BenchmarkVertical-12 66612 18197 ns/op
可见横向遍历数组要快得多,这提醒我们在写代码时要考虑 CPU 的 cache 设计及局部性原理,以使程序能够在相同的逻辑下获得更好的性能
除了 CPU 优化,我们还经常会碰到要优化内存分配的场景。只要带上 -benchmem 的 flag 就可以实现了
出于谨慎考虑,修改高并发接口时,拿不准的尽量都应进行简单的线下 benchmark 测试;当然,我们不能指望靠写一大堆 benchmark 帮我们发现系统的瓶颈,实际工作中还是要使用前文提到的优化工作流来进行系统性能优化。也就是尽量从接口整体而非函数局部考虑去发现与解决瓶颈
从整个服务的视角来看,如常见的 API 服务,我们可以使用两种方式对其进行压测:
压测过程中需要采集不同 QPS 下的 CPU profile,内存 profile,记录 goroutine 数。与历史情况进行 AB 对比
Go 的 pprof 还提供了 --base 的 flag,能够很直观地帮我们发现不同版本之间的指标差异:用 pprof 比较内存使用差异
总之记住一点,接口的性能一定是通过压测来进行优化的,而不是通过硬啃代码找瓶颈点。关键路径的简单修改往往可以带来巨大收益。如果只是啃代码,很有可能将 1% 优化到 0%,优化了 100% 的局部性能,对接口整体影响微乎其微
在压测时,我们通过以下步骤来逐渐提升接口的整体性能:
说完了性能优化本身,最后我们来为 Golang 中真正的实用优化点基于 Golang 中的基本概念分个类,也是为本系列文章先挖好坑:
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。