上一篇学习了 pprof,掌握了 CPU、内存、Goroutine 和锁竞争的性能分析方法。pprof 主要帮助我们找到性能瓶颈,而 Benchmark(基准测试)则用于衡量代码执行速度、内存分配次数和优化效果。Go 标准库 testing 内置了完善的基准测试功能,无需安装第三方框架,就能对函数、算法和数据结构进行性能测试。本篇通过实际案例,介绍 Benchmark 的编写方法、常用参数、测试结果分析及优化技巧。
一、什么是 Benchmark
Benchmark 是一种性能测试方法,通过重复执行指定代码,测量其平均耗时和资源消耗。
例如比较两种字符串拼接方式:
css
方案A:使用 + 拼接
方案B:使用 strings.Builder
通过 Benchmark 可以判断哪种方案在特定测试条件下更高效,而不是单纯依靠经验。
二、Benchmark 与普通测试的区别
普通单元测试主要验证结果是否正确:
scss
func TestAdd(t *testing.T) {
if add(1, 2) != 3 {
t.Fatal("结果错误")
}
}
Benchmark 则关注性能:
css
func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
add(1, 2)
}
}
普通测试使用 testing.T,基准测试使用 testing.B。
三、创建 Benchmark 项目
创建项目:
bash
mkdir benchmark-demo
cd benchmark-demo
go mod init benchmark-demo
创建 calc.go:
go
package benchmark
func Add(a, b int) int {
return a + b
}
再创建 calc_test.go:
go
package benchmark
import "testing"
var result int
func BenchmarkAdd(b *testing.B) {
var n int
for i := 0; i < b.N; i++ {
n = Add(i, 100)
}
result = n
}
使用包级变量保存结果,可以降低计算被完全优化掉的风险。
四、运行基准测试
执行:
ini
go test -bench=.
其中 -bench=. 表示运行匹配所有基准测试名称的测试函数。
只运行指定测试:
ini
go test -bench=BenchmarkAdd
默认情况下,普通测试也会运行,可以使用:
ini
go test -run='^$' -bench=.
跳过普通测试,只执行 Benchmark。
五、理解测试输出
示例:
bash
BenchmarkAdd-8 1000000000 0.35 ns/op
主要字段:
bash
BenchmarkAdd:测试函数名称
-8:测试时的GOMAXPROCS值
1000000000:执行次数
0.35 ns/op:每次操作平均耗时
这里的数值仅用于说明格式,实际结果取决于硬件、Go 版本和编译器优化。
六、b.N 是什么
b.N 是测试框架确定的执行次数。
css
for i := 0; i < b.N; i++ {
doWork()
}
Go 会调整测试执行次数,以获得具有一定统计意义的测量结果。
因此不要自行将循环次数固定为1000,否则可能影响基准测试的准确性。
七、使用 b.Loop
较新版本的 Go 提供 b.Loop(),Go 1.24 及以上可以使用:
scss
func BenchmarkAdd(b *testing.B) {
for b.Loop() {
result = Add(1, 2)
}
}
b.Loop() 可以简化基准测试循环,并帮助避免部分编译器优化影响测量。
如果项目使用较旧的 Go 版本,仍应使用 b.N。
八、查看内存分配
执行:
ini
go test -bench=. -benchmem
输出示例:
bash
BenchmarkAdd-8 1000000000 0.35 ns/op 0 B/op 0 allocs/op
其中:
bash
ns/op:每次操作平均耗时
B/op:每次操作平均分配字节数
allocs/op:每次操作平均堆分配次数
内存分配越少不一定代表整体性能越好,需要结合实际耗时判断。
九、使用 ReportAllocs
也可以在测试代码中启用内存分配统计:
css
func BenchmarkAdd(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
result = Add(i, 100)
}
}
这样即使没有添加 -benchmem 参数,也会输出内存分配指标。
十、字符串拼接性能对比
先测试普通字符串拼接:
css
func BenchmarkStringPlus(b *testing.B) {
for i := 0; i < b.N; i++ {
s := ""
for j := 0; j < 100; j++ {
s += "Go"
}
stringResult = s
}
}
再测试 strings.Builder:
css
func BenchmarkStringBuilder(b *testing.B) {
for i := 0; i < b.N; i++ {
var builder strings.Builder
builder.Grow(200)
for j := 0; j < 100; j++ {
builder.WriteString("Go")
}
stringResult = builder.String()
}
}
需要导入 strings,并声明:
csharp
var stringResult string
运行:
ini
go test -bench=BenchmarkString -benchmem
通常 Builder 在重复拼接场景中更有优势,但具体收益应以测试结果为准。
十一、Slice 预分配测试
不预分配:
css
func BenchmarkSliceAppend(b *testing.B) {
for i := 0; i < b.N; i++ {
var nums []int
for j := 0; j < 1000; j++ {
nums = append(nums, j)
}
sliceResult = nums
}
}
预分配:
css
func BenchmarkSlicePrealloc(b *testing.B) {
for i := 0; i < b.N; i++ {
nums := make([]int, 0, 1000)
for j := 0; j < 1000; j++ {
nums = append(nums, j)
}
sliceResult = nums
}
}
声明:
csharp
var sliceResult []int
预分配通常可以减少扩容次数和内存复制成本。
十二、调整测试时间
默认基准测试时间可能不足以稳定测量复杂操作。
可以使用:
ini
go test -bench=. -benchtime=5s
表示每个基准测试运行约5秒。
也可以指定固定次数:
ini
go test -bench=. -benchtime=1000x
表示每个基准测试执行1000次。
十三、重复测试
单次结果容易受到系统负载、CPU 调频和后台进程影响。
建议:
ini
go test -bench=. -count=5
连续执行5次,观察结果波动。
对于需要严谨对比的优化,可以增加重复次数,并使用统计工具分析结果。
十四、使用 benchstat 对比
Go 官方维护的 benchstat 工具可以比较两组基准测试结果。
安装:
bash
go install golang.org/x/perf/cmd/benchstat@latest
保存优化前结果:
ini
go test -run='^$' -bench=. -benchmem -count=10 > old.txt
修改代码后:
arduino
go test -run='^$' -bench=. -benchmem -count=10 > new.txt
比较:
arduino
benchstat old.txt new.txt
它可以帮助判断性能差异是否具有统计意义。
十五、使用子基准测试
如果需要测试不同输入规模,可以使用 b.Run():
go
func BenchmarkProcess(b *testing.B) {
for _, size := range []int{100, 1000, 10000} {
b.Run(fmt.Sprintf("size=%d", size), func(b *testing.B) {
data := make([]int, size)
for i := 0; i < b.N; i++ {
process(data)
}
})
}
}
需要导入 fmt,并提供实际的 process 函数。
这种方式适合观察算法在不同数据规模下的性能变化。
十六、避免把初始化时间算进去
错误示例:
css
func BenchmarkSearch(b *testing.B) {
for i := 0; i < b.N; i++ {
data := generateData()
search(data)
}
}
如果只想测量 search,就不应该把生成测试数据的开销计算进去。
可以使用:
css
func BenchmarkSearch(b *testing.B) {
data := generateData()
b.ResetTimer()
for i := 0; i < b.N; i++ {
search(data)
}
}
ResetTimer() 会重置计时和相关统计信息。
十七、StopTimer 与 StartTimer
对于需要在循环中准备数据的测试:
css
func BenchmarkProcess(b *testing.B) {
for i := 0; i < b.N; i++ {
b.StopTimer()
data := generateData()
b.StartTimer()
process(data)
}
}
这样可以排除准备数据的时间。
但频繁暂停和恢复计时器本身也存在开销,特别是测试操作非常快时,应尽量把准备工作移到循环外。
十八、并行基准测试
Go 支持 RunParallel():
go
func BenchmarkParallel(b *testing.B) {
var counter atomic.Int64
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
counter.Add(1)
}
})
}
需要导入 sync/atomic。
这种方式适合测试并发计数器、缓存和线程安全数据结构的吞吐量。
十九、调整并行度
可以使用:
ini
go test -bench=BenchmarkParallel -cpu=1,2,4,8
观察不同 GOMAXPROCS 设置下的性能。
还可以通过:
css
b.SetParallelism(4)
调整 RunParallel() 的目标并行工作数量比例。
注意,更多 Goroutine 不一定带来更高吞吐量。
二十、Benchmark 与 pprof 结合
如果某个 Benchmark 性能较差,可以直接采集 CPU 和内存数据:
ini
go test -run='^$' -bench=. -cpuprofile=cpu.prof -memprofile=mem.prof
分析 CPU:
go
go tool pprof cpu.prof
分析内存:
go
go tool pprof mem.prof
Benchmark 用于衡量性能,pprof 用于定位具体瓶颈,两者配合能够提高优化效率。
二十一、Benchmark 常见误区
第一,测试函数名称没有以 Benchmark 开头。第二,忘记使用 b.N 或 b.Loop()。第三,测试结果被编译器优化影响。第四,将初始化和业务处理混在一起测量。第五,只运行一次就判断性能优劣。第六,测试时后台程序大量占用 CPU。第七,认为 allocs/op 越低就一定越快。第八,使用与真实业务差异很大的测试数据。第九,直接比较不同硬件环境下的结果。
二十二、实际项目优化案例
例如 Go 开发的 Excel 批量合并工具,需要处理大量行数据。
可以分别测试:
css
方案A:逐行追加Slice
方案B:预分配Slice
方案C:流式处理
通过 Benchmark 对比处理耗时和内存分配,再使用 pprof 分析性能热点。
对于 PDF 转换、图片压缩、数据解析等工具,也可以使用相同方法评估缓冲区复用、并发数量和数据结构优化的效果。
二十三、正确的性能测试流程
确定测试目标
↓
编写Benchmark
↓
使用真实规模的数据
↓
重复测试并保存结果
↓
分析耗时与内存分配
↓
使用pprof定位瓶颈
↓
修改代码
↓
再次测试并对比
优化过程中必须确保业务结果正确,不能为了提高测试分数而改变功能语义。
二十四、总结
Benchmark 是 Go 性能优化的重要基础工具,能够帮助开发者客观比较不同实现的执行效率。
常用命令:
ini
go test -bench=.
go test -bench=. -benchmem
go test -bench=. -benchtime=5s
go test -bench=. -count=5
go test -bench=. -cpu=1,2,4
核心指标:
bash
ns/op:执行耗时
B/op:内存分配字节数
allocs/op:内存分配次数
掌握 Benchmark 后,就可以通过真实数据验证字符串处理、Slice 预分配、Map 查询、并发任务和缓存优化的效果。下一篇将进一步介绍 Go 性能优化的系统方法,从 CPU、内存、GC、并发、网络 I/O 和数据库访问等方面,构建完整的性能分析与优化流程。
下一篇:性能优化------CPU、内存、GC、并发调度、I/O 优化与高性能 Go 程序实战