4、Benchmark基准测试:编写基准测试函数、benchmem与benchtime、避免编译器优化、对比不同实现的性能
性能优化这件事,最怕的就是「我觉得」。我觉得这段代码慢,我觉得那个函数快——但感觉靠不住。我入行第三年的时候,就因为「觉得」一个字符串拼接方式没问题,结果线上压测直接被打穿。从那以后,我养成了一个习惯:任何性能相关的决策,必须用基准测试说话。
Go 语言自带的 testing 包提供了非常完善的基准测试工具。说白了,它就是一把尺子,能精确量出你的代码到底跑多快、吃多少内存。这一章,我就带你把这把尺子用明白。
4.1 编写基准测试函数
基准测试的写法,和单元测试很像。文件名必须是 _test.go 结尾,函数名以 Benchmark 开头,参数是 *testing.B。
来看个最简单的例子。假设我要测试一个整数加法函数:
// add.go
func Add(a, b int) int {
return a + b
}
// add_test.go
func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
Add(1, 2)
}
}
注意这个 b.N。它不是你自己设的固定值,而是框架自动调整的。框架会先跑一小段,估算出你的函数大概耗时,然后自动增加循环次数,直到总运行时间稳定在 1 秒左右。你想想看,这样测出来的结果才够准。
运行基准测试的命令是:
go test -bench=.
输出大概长这样:
BenchmarkAdd-8 1000000000 0.25 ns/op
解释一下:-8 表示用了 8 个 CPU 核心,1000000000 是循环次数,0.25 ns/op 是每次操作的平均耗时。嗯,0.25 纳秒,这基本就是 CPU 指令级别的速度了。
-bench 后面加个正则,比如 -bench=BenchmarkAdd$,只跑我关心的那个函数。不然整个包里的测试全跑一遍,浪费时间。
4.2 benchmem 与 benchtime
光看耗时还不够。很多时候,性能瓶颈不在 CPU,而在内存分配。每次分配内存都要触发 GC,GC 一跑,整个程序就卡一下。
用 -benchmem 参数,就能看到每次操作分配了多少内存、发生了多少次分配:
go test -bench=. -benchmem
输出会多两列:
BenchmarkAdd-8 1000000000 0.25 ns/op 0 B/op 0 allocs/op
0 B/op 表示每次操作分配 0 字节,0 allocs/op 表示 0 次内存分配。这当然是最理想的情况。但实际项目中,很多函数都会产生分配。
举个例子,字符串拼接:
func ConcatWithPlus(a, b string) string {
return a + b
}
func ConcatWithBuilder(a, b string) string {
var sb strings.Builder
sb.WriteString(a)
sb.WriteString(b)
return sb.String()
}
跑一下基准测试:
BenchmarkConcatWithPlus-8 50000000 30.5 ns/op 16 B/op 2 allocs/op
BenchmarkConcatWithBuilder-8 100000000 12.3 ns/op 8 B/op 1 allocs/op
看到了吗?+ 操作符每次拼接都会产生新的字符串,分配了 2 次内存。而 strings.Builder 只分配了 1 次。这就是为什么我建议你在循环拼接字符串时,一定要用 Builder。
再说说 -benchtime。默认情况下,基准测试跑 1 秒。但有些函数特别慢,1 秒跑不了几次,结果可能不稳定。这时候可以手动指定时间:
go test -bench=. -benchtime=5s
这样框架会尽量跑满 5 秒,结果更可靠。我个人习惯是:对于毫秒级的函数,用 5 秒;对于微秒级的,用 2 秒就够了。
-benchtime 调到 30 秒,才拿到稳定数据。所以,样本量太少时,别信那个平均值。
4.3 避免编译器优化
这是新手最容易忽略的问题。Go 编译器很聪明,它发现你的函数结果没被使用,可能直接把它优化掉了。你测出来的「高性能」,其实是编译器帮你作弊。
看这个例子:
func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
Add(1, 2) // 结果没被使用
}
}
编译器一看:嗯,这个 Add 的返回值没人要,那我还算它干嘛?直接跳过。结果你测出来 0.25 纳秒,其实什么都没跑。
正确的做法是把结果赋值给一个包级变量:
var result int
func BenchmarkAdd(b *testing.B) {
var r int
for i := 0; i < b.N; i++ {
r = Add(1, 2)
}
result = r
}
注意这里用了局部变量 r,最后才赋值给包级变量 result。为什么?因为如果直接在循环里写 result = Add(1, 2),编译器可能还是会优化——它觉得每次赋值都一样,没必要重复执行。
还有一种更稳妥的方式,用 testing.B 提供的 StopTimer 和 StartTimer:
func BenchmarkExpensive(b *testing.B) {
data := prepareLargeData() // 准备数据不计时
b.ResetTimer() // 重置计时器
for i := 0; i < b.N; i++ {
b.StopTimer()
input := generateInput(data) // 生成输入不计时
b.StartTimer()
result = process(input)
}
}
嗯,这里要注意:ResetTimer 和 StopTimer/StartTimer 是两套不同的机制。前者是重置整个计时,后者是暂停/恢复。我一般只在准备数据阶段用 ResetTimer,循环内部的辅助操作才用 StopTimer。
4.4 对比不同实现的性能
基准测试最大的价值,就是用来做对比。同一个功能,不同实现方式,性能可能差一个数量级。
我拿一个实际案例来说。假设我们要判断一个字符串是否包含某个子串:
// 方式一:strings.Contains
func ContainsStandard(s, substr string) bool {
return strings.Contains(s, substr)
}
// 方式二:自己写的循环
func ContainsCustom(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
写个基准测试对比一下:
var result bool
func BenchmarkContainsStandard(b *testing.B) {
s := "hello world, this is a long string for testing"
substr := "testing"
var r bool
for i := 0; i < b.N; i++ {
r = ContainsStandard(s, substr)
}
result = r
}
func BenchmarkContainsCustom(b *testing.B) {
s := "hello world, this is a long string for testing"
substr := "testing"
var r bool
for i := 0; i < b.N; i++ {
r = ContainsCustom(s, substr)
}
result = r
}
跑一下看看:
BenchmarkContainsStandard-8 100000000 12.5 ns/op 0 B/op 0 allocs/op
BenchmarkContainsCustom-8 50000000 35.2 ns/op 0 B/op 0 allocs/op
标准库快了将近 3 倍。为什么?因为 strings.Contains 底层用了 Rabin-Karp 算法,比简单的逐字符比较高效得多。这个案例告诉我们:标准库能搞定的,别自己造轮子。
但有时候,标准库不一定是最优解。比如你要频繁地从一个 map 中读取数据,但 map 的并发读写不安全。你可以对比 sync.Map 和 map + sync.RWMutex:
| 实现方式 | 读操作 (ns/op) | 写操作 (ns/op) | 内存分配 (B/op) |
|---|---|---|---|
| sync.Map | 45.2 | 120.5 | 32 |
| map + RWMutex | 38.7 | 95.3 | 0 |
你看,在这个场景下,map + RWMutex 反而更快,而且没有额外内存分配。sync.Map 适合的是「读多写少」且 key 分布均匀的场景。没有基准测试,你根本不知道哪个更适合你的业务。
4.5 总结
基准测试是性能优化的基石。没有它,你所有的优化都是盲人摸象。记住几个要点:
- 用
-benchmem看内存分配,很多性能问题出在 GC 上 - 用
-benchtime控制测试时长,样本量要够大 - 用包级变量防止编译器优化,别让编译器「帮你作弊」
- 对比不同实现时,控制变量,只改一个因素
最后说一句:别迷信直觉,让数据说话。你觉得自己写的算法很牛?跑个基准测试看看。你觉得标准库肯定最快?跑个基准测试看看。我见过太多「我觉得很快」的代码,一测就原形毕露。嗯,基准测试就是那面照妖镜。