Go 编程实战:Benchmark——基准测试、testing.B、benchmem、性能对比与代码优化实战

0 阅读4分钟

上一篇学习了 pprof,掌握了 CPU、内存、Goroutine 和锁竞争的性能分析方法。pprof 主要帮助我们找到性能瓶颈,而 Benchmark(基准测试)则用于衡量代码执行速度、内存分配次数和优化效果。Go 标准库 testing 内置了完善的基准测试功能,无需安装第三方框架,就能对函数、算法和数据结构进行性能测试。本篇通过实际案例,介绍 Benchmark 的编写方法、常用参数、测试结果分析及优化技巧。

一、什么是 Benchmark

Benchmark 是一种性能测试方法,通过重复执行指定代码,测量其平均耗时和资源消耗。
例如比较两种字符串拼接方式:

方案A:使用 + 拼接
方案B:使用 strings.Builder

通过 Benchmark 可以判断哪种方案在特定测试条件下更高效,而不是单纯依靠经验。

二、Benchmark 与普通测试的区别

普通单元测试主要验证结果是否正确:

func TestAdd(t *testing.T) {
	if add(1, 2) != 3 {
		t.Fatal("结果错误")
	}
}

Benchmark 则关注性能:

func BenchmarkAdd(b *testing.B) {
	for i := 0; i < b.N; i++ {
		add(1, 2)
	}
}

普通测试使用 testing.T,基准测试使用 testing.B。

三、创建 Benchmark 项目

创建项目:

mkdir benchmark-demo
cd benchmark-demo
go mod init benchmark-demo

创建 calc.go:

package benchmark

func Add(a, b int) int {
	return a + b
}

再创建 calc_test.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
}

使用包级变量保存结果,可以降低计算被完全优化掉的风险。

四、运行基准测试

执行:

go test -bench=.

其中 -bench=. 表示运行匹配所有基准测试名称的测试函数。
只运行指定测试:

go test -bench=BenchmarkAdd

默认情况下,普通测试也会运行,可以使用:

go test -run='^$' -bench=.

跳过普通测试,只执行 Benchmark。

五、理解测试输出

示例:

BenchmarkAdd-8  1000000000  0.35 ns/op

主要字段:

BenchmarkAdd:测试函数名称
-8:测试时的GOMAXPROCS值
1000000000:执行次数
0.35 ns/op:每次操作平均耗时

这里的数值仅用于说明格式,实际结果取决于硬件、Go 版本和编译器优化。

六、b.N 是什么

b.N 是测试框架确定的执行次数。

for i := 0; i < b.N; i++ {
	doWork()
}

Go 会调整测试执行次数,以获得具有一定统计意义的测量结果。
因此不要自行将循环次数固定为1000,否则可能影响基准测试的准确性。

七、使用 b.Loop

较新版本的 Go 提供 b.Loop(),Go 1.24 及以上可以使用:

func BenchmarkAdd(b *testing.B) {
	for b.Loop() {
		result = Add(1, 2)
	}
}

b.Loop() 可以简化基准测试循环,并帮助避免部分编译器优化影响测量。
如果项目使用较旧的 Go 版本,仍应使用 b.N。

八、查看内存分配

执行:

go test -bench=. -benchmem

输出示例:

BenchmarkAdd-8  1000000000  0.35 ns/op  0 B/op  0 allocs/op

其中:

ns/op:每次操作平均耗时
B/op:每次操作平均分配字节数
allocs/op:每次操作平均堆分配次数

内存分配越少不一定代表整体性能越好,需要结合实际耗时判断。

九、使用 ReportAllocs

也可以在测试代码中启用内存分配统计:

func BenchmarkAdd(b *testing.B) {
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		result = Add(i, 100)
	}
}

这样即使没有添加 -benchmem 参数,也会输出内存分配指标。

十、字符串拼接性能对比

先测试普通字符串拼接:

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:

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,并声明:

var stringResult string

运行:

go test -bench=BenchmarkString -benchmem

通常 Builder 在重复拼接场景中更有优势,但具体收益应以测试结果为准。

十一、Slice 预分配测试

不预分配:

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
	}
}

预分配:

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
	}
}

声明:

var sliceResult []int

预分配通常可以减少扩容次数和内存复制成本。

十二、调整测试时间

默认基准测试时间可能不足以稳定测量复杂操作。
可以使用:

go test -bench=. -benchtime=5s

表示每个基准测试运行约5秒。
也可以指定固定次数:

go test -bench=. -benchtime=1000x

表示每个基准测试执行1000次。

十三、重复测试

单次结果容易受到系统负载、CPU 调频和后台进程影响。
建议:

go test -bench=. -count=5

连续执行5次,观察结果波动。
对于需要严谨对比的优化,可以增加重复次数,并使用统计工具分析结果。

十四、使用 benchstat 对比

Go 官方维护的 benchstat 工具可以比较两组基准测试结果。
安装:

go install golang.org/x/perf/cmd/benchstat@latest

保存优化前结果:

go test -run='^$' -bench=. -benchmem -count=10 > old.txt

修改代码后:

go test -run='^$' -bench=. -benchmem -count=10 > new.txt

比较:

benchstat old.txt new.txt

它可以帮助判断性能差异是否具有统计意义。

十五、使用子基准测试

如果需要测试不同输入规模,可以使用 b.Run():

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 函数。
这种方式适合观察算法在不同数据规模下的性能变化。

十六、避免把初始化时间算进去

错误示例:

func BenchmarkSearch(b *testing.B) {
	for i := 0; i < b.N; i++ {
		data := generateData()
		search(data)
	}
}

如果只想测量 search,就不应该把生成测试数据的开销计算进去。
可以使用:

func BenchmarkSearch(b *testing.B) {
	data := generateData()
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		search(data)
	}
}

ResetTimer() 会重置计时和相关统计信息。

十七、StopTimer 与 StartTimer

对于需要在循环中准备数据的测试:

func BenchmarkProcess(b *testing.B) {
	for i := 0; i < b.N; i++ {
		b.StopTimer()
		data := generateData()
		b.StartTimer()
		process(data)
	}
}

这样可以排除准备数据的时间。
但频繁暂停和恢复计时器本身也存在开销,特别是测试操作非常快时,应尽量把准备工作移到循环外。

十八、并行基准测试

Go 支持 RunParallel():

func BenchmarkParallel(b *testing.B) {
	var counter atomic.Int64
	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			counter.Add(1)
		}
	})
}

需要导入 sync/atomic。
这种方式适合测试并发计数器、缓存和线程安全数据结构的吞吐量。

十九、调整并行度

可以使用:

go test -bench=BenchmarkParallel -cpu=1,2,4,8

观察不同 GOMAXPROCS 设置下的性能。
还可以通过:

b.SetParallelism(4)

调整 RunParallel() 的目标并行工作数量比例。
注意,更多 Goroutine 不一定带来更高吞吐量。

二十、Benchmark 与 pprof 结合

如果某个 Benchmark 性能较差,可以直接采集 CPU 和内存数据:

go test -run='^$' -bench=. -cpuprofile=cpu.prof -memprofile=mem.prof

分析 CPU:

go tool pprof cpu.prof

分析内存:

go tool pprof mem.prof

Benchmark 用于衡量性能,pprof 用于定位具体瓶颈,两者配合能够提高优化效率。

二十一、Benchmark 常见误区

第一,测试函数名称没有以 Benchmark 开头。第二,忘记使用 b.N 或 b.Loop()。第三,测试结果被编译器优化影响。第四,将初始化和业务处理混在一起测量。第五,只运行一次就判断性能优劣。第六,测试时后台程序大量占用 CPU。第七,认为 allocs/op 越低就一定越快。第八,使用与真实业务差异很大的测试数据。第九,直接比较不同硬件环境下的结果。

二十二、实际项目优化案例

例如 Go 开发的 Excel 批量合并工具,需要处理大量行数据。
可以分别测试:

方案A:逐行追加Slice
方案B:预分配Slice
方案C:流式处理

通过 Benchmark 对比处理耗时和内存分配,再使用 pprof 分析性能热点。
对于 PDF 转换、图片压缩、数据解析等工具,也可以使用相同方法评估缓冲区复用、并发数量和数据结构优化的效果。

二十三、正确的性能测试流程

确定测试目标
↓
编写Benchmark
↓
使用真实规模的数据
↓
重复测试并保存结果
↓
分析耗时与内存分配
↓
使用pprof定位瓶颈
↓
修改代码
↓
再次测试并对比

优化过程中必须确保业务结果正确,不能为了提高测试分数而改变功能语义。

二十四、总结

Benchmark 是 Go 性能优化的重要基础工具,能够帮助开发者客观比较不同实现的执行效率。
常用命令:

go test -bench=.
go test -bench=. -benchmem
go test -bench=. -benchtime=5s
go test -bench=. -count=5
go test -bench=. -cpu=1,2,4

核心指标:

ns/op:执行耗时
B/op:内存分配字节数
allocs/op:内存分配次数

掌握 Benchmark 后,就可以通过真实数据验证字符串处理、Slice 预分配、Map 查询、并发任务和缓存优化的效果。下一篇将进一步介绍 Go 性能优化的系统方法,从 CPU、内存、GC、并发、网络 I/O 和数据库访问等方面,构建完整的性能分析与优化流程。
下一篇:性能优化——CPU、内存、GC、并发调度、I/O 优化与高性能 Go 程序实战