Go 用 testing.B 写基准测试:b.ResetTimer、b.ReportAllocs 与别让编译器把你的代码优化掉
写完一段自认为很快的 Go 代码,想量化到底有多快,很多人第一反应是自己time.Now()掐表跑一遍循环。结果测出来的数字忽大忽小,还没法和同事对齐。其实 Go 标准库自带了一套严谨的基准测试框架testing.B,它会自动决定跑多少轮、把结果换算成每次操作的耗时,还能顺带统计内存分配。这篇就把基准测试从「能跑」写到「测得准」。
一个最小的基准测试
基准测试函数以Benchmark开头,放在_test.go文件里,参数是*testing.B。核心是那个for i := 0; i < b.N; i++循环——b.N由框架动态决定,你不用管它是多少。
// strjoin_test.gopackagestrjoinimport("strings""testing")funcBenchmarkStringPlus(b*testing.B){words:=[]string{"go","is","fast","and","simple"}fori:=0;i<b.N;i++{s:=""for_,w:=rangewords{s+=w// 每次 += 都新分配一个字符串}_=s}}funcBenchmarkStringBuilder(b*testing.B){words:=[]string{"go","is","fast","and","simple"}fori:=0;i<b.N;i++{varsb strings.Builderfor_,w:=rangewords{sb.WriteString(w)}_=sb.String()}}跑起来:
gotest-bench=.-benchmem-bench=.表示跑所有基准(参数是正则,-bench=Builder只跑名字含 Builder 的);-benchmem打开内存统计。输出大概长这样:
BenchmarkStringPlus-8 8123456 142.3 ns/op 48 B/op 4 allocs/op BenchmarkStringBuilder-8 19876543 60.1 ns/op 24 B/op 2 allocs/op-8是 GOMAXPROCS;ns/op是每次操作纳秒数,越小越快;B/op是每次操作分配的字节;allocs/op是分配次数。一眼看出 Builder 快一倍多、分配也少一半。
b.N 是什么:为什么不能自己写死循环次数
框架会先用很小的b.N(比如 1)试跑,根据耗时不断加大b.N,直到总运行时间够长(默认约 1 秒),这样统计才稳定。所以你的代码必须严格用b.N控制循环次数,自己写死for i := 0; i < 1000000; i++会让框架的自适应逻辑失效,测出来的数字毫无意义。
坑一:初始化开销污染了结果,用 b.ResetTimer
如果测试前有一段昂贵的准备工作(建大 map、读文件、造测试数据),它会被算进计时里,把结果拉偏。用b.ResetTimer()把计时器归零,只测你真正关心的部分。
funcBenchmarkLookup(b*testing.B){// 造 100 万条数据是准备工作,不该计入m:=make(map[int]int,1_000_000)fori:=0;i<1_000_000;i++{m[i]=i*2}b.ResetTimer()// 关键:把上面建 map 的耗时从计时里剔掉fori:=0;i<b.N;i++{_=m[i%1_000_000]}}配套的还有b.StopTimer()/b.StartTimer(),用于循环内部每轮都要重新造数据、但又不想把造数据算进去的场景:
funcBenchmarkProcess(b*testing.B){fori:=0;i<b.N;i++{b.StopTimer()data:=makeFreshInput()// 每轮都要新数据,但不计时b.StartTimer()process(data)// 只测这一行}}注意StopTimer/StartTimer每轮都调有额外开销,数据准备很轻时反而得不偿失,能用ResetTimer就别用它。
坑二:编译器把你的代码整个优化掉了
这是基准测试最阴险的陷阱。如果计算结果没被使用,Go 编译器会判定这段代码是「死代码」直接删掉,你测出个0.3 ns/op还以为自己写出了世界最快函数。
// 错误:result 没被用,整个 Abs 调用可能被优化掉funcBenchmarkAbsWrong(b*testing.B){fori:=0;i<b.N;i++{Abs(-3.14)// 结果被丢弃,编译器可能直接删掉}}正确做法:把结果赋值给一个包级变量(逃逸到堆,编译器不敢删),循环外再消费一次。
varresultfloat64// 包级变量,阻止编译器消除funcBenchmarkAbs(b*testing.B){varrfloat64fori:=0;i<b.N;i++{r=Abs(-3.14)// 结果被局部变量接住}result=r// 逃逸到包级变量,确保调用不被优化掉}判断有没有被优化掉的经验法则:如果一个明明有计算量的函数测出接近 0 或诡异地稳定,八成是被删了,检查结果有没有真正被消费。
用 b.ReportAllocs() 强制显示分配,不依赖命令行
-benchmem是全局开关,如果你想让某个基准无论命令行加不加都报告内存,在函数里调一次b.ReportAllocs():
funcBenchmarkJSONMarshal(b*testing.B){b.ReportAllocs()// 这个基准总是报告 B/op 和 allocs/opv:=map[string]int{"a":1,"b":2,"c":3}b.ResetTimer()fori:=0;i<b.N;i++{_,_=json.Marshal(v)}}allocs/op往往比ns/op更值得盯:分配次数直接决定 GC 压力,减少分配通常是 Go 性能优化里回报最高的一环。
让结果可信:多跑几轮再对比
单次基准会受机器负载波动影响。要下「A 比 B 快」的结论,用-count跑多轮,再用官方工具benchstat做统计显著性判断:
# 每个基准跑 10 轮,存到文件gotest-bench=.-benchmem-count=10>new.txt# 对比改动前后(需要 go install golang.org/x/perf/cmd/benchstat@latest)benchstat old.txt new.txtbenchstat会给出均值、波动范围(±%)和差异是否显著。只看一次输出就说「优化了 5%」是不靠谱的,那点差距可能全在噪声里。
小结
- 基准函数以
Benchmark开头,循环次数必须用b.N,别写死;跑用go test -bench=. -benchmem。 - 昂贵的一次性准备用
b.ResetTimer()剔除;循环内每轮准备数据用StopTimer/StartTimer,但能避免就避免。 - 结果一定要被消费(赋给包级变量),否则编译器把整段代码优化掉,数字失真。
- 盯
allocs/op往往比盯ns/op更有价值,减少分配就是减 GC 压力。 - 下结论前用
-count=10+benchstat判断差异是否显著。
一句话记忆:b.N 让框架决定跑多少,ResetTimer 决定测什么,消费结果决定测的是不是真的在跑。