高质量编程与性能调优实战(下) | 青训营

83 阅读7分钟

前文指路:高质量编程与性能调优实战(上) | 青训营

本文主要总结Go语言相关的性能优化建议。

1、性能优化建议

(1) Benchmark

Go语言提供了很好的基准性能测试工具——Benchmark,使用方法如下:

先将待测试函数写在一个.go文件里,再另开一个.go文件用于调用测试函数。在测试函数里,测试函数必须以Benchmark开头,传入参数必须是b *testing.B,然后从0b.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)
	}
}

输出结果:

QQ图片20230827040706.png

让我们来逐一解析。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]
}

QQ图片20230827041105.png

可以看到,效率从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)
	}
}

测试结果为:

QQ图片20230827042047.png

当我们提供容量信息时:

func process(size int) {
	a := make([]int, 0,size)
	for i := 0; i < size; i++ {
		a = append(a, i)
	}
}

测试结果为:

QQ图片20230827042013.png

这是因为slice在容量不够时是倍增扩容的,而切片本质是对数组片段的描述,因此扩容时需要将原本的元素拷贝一次,效率较低。

cf4b8369-3b60-4ac3-b3b4-68525cdd074d.png

(3) 大内存未释放

在前面的笔记中,我们提到过切片是引用类型,这意味着在已有切片的基础上创建切片是不会创建新的底层数组的

考虑这样的场景:代码在原切片的基础上创建了若干小切片,而原切片较大。原切片数组在内存中有引用而得不到释放。

因此在创建小切片时,可以用copy替代re-slice。即:

image.png

(4) map预分配内存

不断向map中加入元素会触发map的扩容,而提前分配好空间可以减少内存拷贝和Refresh的消耗。

未预分配内存:

func process(size int) {
	a := make(map[int]int)
	for i := 0; i < size; i++ {
		a[i] = i
	}
}

结果: QQ图片20230827131923.png

预分配内存:

func process(size int) {
	a := make(map[int]int,size)
	for i := 0; i < size; i++ {
		a[i] = i
	}
}

结果:

QQ图片20230827131954.png

(5) 字符串处理

与众多语言一样,字符串在Go语言中是不可变类型,因此使用++=每次都会重新分配内存。 直接拼接如下:

func process(n int, str string) string {
	s := ""
	for i := 1; i <= n; i++ {
		s += str
	}
	return s
}

结果为:

QQ图片20230827143820.png 那么如何高效地拼接字符串呢?可以使用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()
}

结果为:

QQ图片20230827145740.png

除此之外,还可以使用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()
}

结果为:

QQ图片20230827145856.png

(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有点吃紧。

QQ图片20230827225059.png

保持程序运行,我们点开http://localhost:6060/debug/pprof/。 页面上展示的是程序的运行采样数据,allocsblocksgoroutineheapmutex分别表示内存分配情况、阻塞操作情况、协程堆栈信息、堆上内存使用情况、锁争用情况采样。

QQ图片20230827225236.png

但这样不够直观。我们保持程序运行,在命令行中输入:

go tool pprof http://localhost:6060/debug/pprof/profile

在默认情况下,分析结果是从程序启动以来累积的profiling数据。我们可以在后面加上?seconds=10,表示最近10秒内的使用情况。

QQ图片20230827225744.png

(2) pprof排查实战(CPU)

在前置工作做完后,我们就进入了pprof交互模式。输入top指令,可以查看占用资源排名top的函数。

QQ图片20230827225915.png

在上图中,flat表示当前函数自身占用CPU的时间,flat%表示占总CPU时间的百分比;sum%表示前若干函数的flat%之前缀和,cum表示当前函数及其调用函数占用CPU的时间,cum%同理。

想象一下,什么情况下flat == cum?显然该函数本身运行时间就等于函数运行链时间,那么可以说该函数没有调用其他函数

什么情况下flat == 0?那么显然该函数本身没花时间,函数中只有其他函数的调用。

显然在上图中,过高的CPU消耗是(*Tiger).Eat函数导致的,现在我们利用正则表达式来定位它。输入:

list eat

QQ图片20230827230425.png

定位到tiger.go21~27行,点开看看:

func (t *Tiger) Eat() {
	log.Println(t.Name(), "eat")
	loop := 10000000000
	for i := 0; i < loop; i++ {
		// do nothing
	}
}

终于发现了CPU炸弹的罪魁祸首!这里的一百亿次空循环造成了巨大的CPU消耗。至此,我们成功排查出了CPU占用异常的根源所在。

如果我们想让上面的查询结果更加可视化,可以在刚刚的命令行中输入web

QQ图片20230827231223.png

这样我们可以明显地发现问题所在。现在我们把这一百亿次空循环注释掉,再重新运行。

QQ图片20230827231346.png

可以看到CPU占用异常问题已被排查完成。

(3) pprof排查实战(内存)

然而看起来内存使用还是不正常的。运行程序,在命令行中输入

go tool pprof http://localhost:6060/debug/pprof/heap

注意到结尾是heap,这样查看的就是内存了。同样,我们利用toplist来进行问题排查和定位。

QQ图片20230827231636.png 发现异常出在(*Mouse).Steal函数。定位:

QQ图片20230827231720.png

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。其原理和前文类似,就不在此赘述了。