本文主要总结Go语言相关的性能优化建议。
1、性能优化建议
(1) Benchmark
Go语言提供了很好的基准性能测试工具——Benchmark,使用方法如下:
先将待测试函数写在一个.go文件里,再另开一个.go文件用于调用测试函数。在测试函数里,测试函数必须以Benchmark开头,传入参数必须是b *testing.B,然后从0到b.N调用待测试函数。随后在命令行输入go test -bench="." -benchmem。
下面以测试斐波那契数列代码的相关数据为例:
fib.go:
package main
func Fib(n int) int {
if n < 2 {
return n
}
return Fib(n-1) + Fib(n-2)
}
fib_test.go:
package main
import "testing"
// from fib_test.go
func BenchmarkFib(b *testing.B) {
// 运行Fib 函数b.N次
for n := 0; n < b.N; n++ {
Fib(20)
}
}
输出结果:
让我们来逐一解析。BenchmarkFib是用于测试的函数名,43383表示调用了多少次待测试的函数(即b.N),27470 ns/op表示每次调用函数花费多少ns,后两个参数分别表示每次执行申请多少内存/申请几次内存。
为了做对照,我们可以给求斐波那契的代码加上记忆化,看看再跑一下效率如何?
修改后的fib.go:
package main
var memo [100]int
func Fib(n int) int {
if memo[n] != 0 {
return memo[n]
}
if n < 2 {
return n
}
memo[n] = Fib(n-1) + Fib(n-2)
return memo[n]
}
可以看到,效率从27470 ns/op优化到了1.441 ns/op。
(2) slice预分配内存
在我们创建切片时,虽然切片会自动扩容,但是预分配的容量越接近真实效率越高,因为预分配的效率高过自动扩容。
当我们没有初始化切片时提供容量信息时:
func process(size int) {
a := make([]int, 0)
for i := 0; i < size; i++ {
a = append(a, 0)
}
}
测试结果为:
当我们提供容量信息时:
func process(size int) {
a := make([]int, 0,size)
for i := 0; i < size; i++ {
a = append(a, i)
}
}
测试结果为:
这是因为slice在容量不够时是倍增扩容的,而切片本质是对数组片段的描述,因此扩容时需要将原本的元素拷贝一次,效率较低。
(3) 大内存未释放
在前面的笔记中,我们提到过切片是引用类型,这意味着在已有切片的基础上创建切片是不会创建新的底层数组的。
考虑这样的场景:代码在原切片的基础上创建了若干小切片,而原切片较大。原切片数组在内存中有引用而得不到释放。
因此在创建小切片时,可以用copy替代re-slice。即:
(4) map预分配内存
不断向map中加入元素会触发map的扩容,而提前分配好空间可以减少内存拷贝和Refresh的消耗。
未预分配内存:
func process(size int) {
a := make(map[int]int)
for i := 0; i < size; i++ {
a[i] = i
}
}
结果:
预分配内存:
func process(size int) {
a := make(map[int]int,size)
for i := 0; i < size; i++ {
a[i] = i
}
}
结果:
(5) 字符串处理
与众多语言一样,字符串在Go语言中是不可变类型,因此使用+或+=每次都会重新分配内存。
直接拼接如下:
func process(n int, str string) string {
s := ""
for i := 1; i <= n; i++ {
s += str
}
return s
}
结果为:
那么如何高效地拼接字符串呢?可以使用
bytes.buffer,其实现了对字节切片的操作,实现了io的各个接口。主要特点在于提供了一个缓冲区,动态扩容:
func process(n int, str string) string {
buf := new(bytes.Buffer)
for i := 1; i <= n; i++ {
buf.WriteString(str)
}
return buf.String()
}
结果为:
除此之外,还可以使用string.Builder,其底层也是byte数组。
func process(n int, str string) string {
var builder strings.Builder
for i := 1; i <= n; i++ {
builder.WriteString(str)
}
return builder.String()
}
结果为:
(6) 空结构体
使用空结构体作为占位符,struct{}不占用任何内存空间,甚至bool都需要占一字节,所以可以节省资源。
例如我们要在Go中实现set,那么可以用map来实现。判断是否元素存在只需写成_,ok:=mp[x],判断ok即可。因此value无论是什么都无所谓,所以放struct{}是最节省资源的。
用bool:
func makemap(n int) {
mp := make(map[int]bool)
for i := 1; i <= n; i++ {
mp[i] = false
}
}
用struct{}:
func makemap(n int) {
mp := make(map[int]struct{})
for i := 1; i <= n; i++ {
mp[i] = struct{}{}
}
}
(7) 原子函数
在解决并发竞争时,原子函数拥有比读写互斥锁更高的效率。详见:Go语言并发编程入门 | 青训营。
2、性能调优实战
(1) 性能分析工具pprof
pprof是用于可视化和分析性能分析数据的工具,可以用来分析程序运行时占用的CPU、内存等数据。
我们下载用来测试的性能堪忧的“炸弹”程序:github.com/wolfogre/go…
该项目运行后,会占用2G内存和最高100%CPU。 在获取炸弹程序后,我们运行一下:
go build
./go-pprof-practice
运行后可以看到CPU有点吃紧。
保持程序运行,我们点开http://localhost:6060/debug/pprof/。
页面上展示的是程序的运行采样数据,allocs、blocks、goroutine、heap、mutex分别表示内存分配情况、阻塞操作情况、协程堆栈信息、堆上内存使用情况、锁争用情况采样。
但这样不够直观。我们保持程序运行,在命令行中输入:
go tool pprof http://localhost:6060/debug/pprof/profile
在默认情况下,分析结果是从程序启动以来累积的profiling数据。我们可以在后面加上?seconds=10,表示最近10秒内的使用情况。
(2) pprof排查实战(CPU)
在前置工作做完后,我们就进入了pprof交互模式。输入top指令,可以查看占用资源排名top的函数。
在上图中,flat表示当前函数自身占用CPU的时间,flat%表示占总CPU时间的百分比;sum%表示前若干函数的flat%之前缀和,cum表示当前函数及其调用函数占用CPU的时间,cum%同理。
想象一下,什么情况下flat == cum?显然该函数本身运行时间就等于函数运行链时间,那么可以说该函数没有调用其他函数。
什么情况下flat == 0?那么显然该函数本身没花时间,函数中只有其他函数的调用。
显然在上图中,过高的CPU消耗是(*Tiger).Eat函数导致的,现在我们利用正则表达式来定位它。输入:
list eat
定位到tiger.go的21~27行,点开看看:
func (t *Tiger) Eat() {
log.Println(t.Name(), "eat")
loop := 10000000000
for i := 0; i < loop; i++ {
// do nothing
}
}
终于发现了CPU炸弹的罪魁祸首!这里的一百亿次空循环造成了巨大的CPU消耗。至此,我们成功排查出了CPU占用异常的根源所在。
如果我们想让上面的查询结果更加可视化,可以在刚刚的命令行中输入web。
这样我们可以明显地发现问题所在。现在我们把这一百亿次空循环注释掉,再重新运行。
可以看到CPU占用异常问题已被排查完成。
(3) pprof排查实战(内存)
然而看起来内存使用还是不正常的。运行程序,在命令行中输入
go tool pprof http://localhost:6060/debug/pprof/heap
注意到结尾是heap,这样查看的就是内存了。同样,我们利用top和list来进行问题排查和定位。
发现异常出在
(*Mouse).Steal函数。定位:
即Mouse.go的第56~62行:
func (m *Mouse) Steal() {
log.Println(m.Name(), "steal")
max := constant.Gi
for len(m.buffer)*constant.Mi < max {
m.buffer = append(m.buffer, [constant.Mi]byte{})
}
}
这里存在循环会一直向m.buffer里追加长度为1 MB的数组,直到总容量到达1 GB,并且一直没有释放内存。同样注释掉即可。
按照类似的原理,程序协程过多时可能导致内存泄漏,可以用go tool pprof http://localhost:6060/debug/pprof/goroutine排查;同理可以排查锁的争用:go tool pprof http://localhost:6060/debug/pprof/mutex;还可以排查除锁以外导致的阻塞:go tool pprof http://localhost:6060/debug/pprof/block。其原理和前文类似,就不在此赘述了。