Go 基准测试 Benchmark 详解:精准分析性能瓶颈
在 Go 开发中,功能正常只是基础,性能达标才是关键。
尤其是高并发场景下,一个看似简单的函数若存在性能隐患,可能会拖垮整个服务。
而 Benchmark(基准测试)就是定位性能瓶颈的核心工具,它能精准统计函数的执行效率。
做性能优化,如果没搞懂 Benchmark 的细节,测出来的结果忽高忽低,会走不少弯路。
今天就结合工作实战经验,把 Benchmark 讲透,让你少踩坑。
一、为什么要进行基准测试?
Benchmark 是 Go 标准库 testing 包提供的性能测试能力,核心作用是通过反复执行目标函数,统计其平均执行时间、每秒执行次数、内存分配等关键指标。
和普通单元测试(Test 函数)不同,它关注的不是“功能对不对”,而是“性能好不好”。
实际开发中,Benchmark 的使用场景非常多:
-
优化函数后,验证性能是否真的提升(比如把冒泡排序改成快速排序后,量化提升幅度);
-
对比不同实现方案的优劣(比如用切片和数组存数据,哪个读写更快);
-
提前发现性能隐患(比如某个函数每次执行都分配大量内存,高并发下会触发频繁 GC)。
使用 Benchmark 的核心规范(新手必记,错了测试不执行):
-
测试文件必须以 “_test.go” 结尾(和普通单元测试一致);
-
测试函数必须以 “Benchmark” 开头,比如 BenchmarkSort;
-
函数参数固定为 “b *testing.B”,不能多也不能少;
-
执行时需用 “go test -bench=” 命令,默认不执行基准测试。
二、核心用法
(一)写一个简单测试
先以“对比两种字符串拼接方式的性能”为例,写一个基础的 Benchmark 测试。
目标函数是两种拼接方式:用 “+” 拼接和用 strings.Builder 拼接。
首先创建业务文件 “string_join.go”,实现两种拼接逻辑:
// string_join.go
package stringutil
import "strings"
// JoinWithPlus 用 "+" 拼接字符串
func JoinWithPlus(a, b, c string) string {
return a + b + c
}
// JoinWithBuilder 用 strings.Builder 拼接字符串
func JoinWithBuilder(a, b, c string) string {
var builder strings.Builder
builder.WriteString(a)
builder.WriteString(b)
builder.WriteString(c)
return builder.String()
}然后创建测试文件 “string_join_test.go”,编写 Benchmark 测试函数:
// string_join_test.go
package stringutil
import "testing"
// BenchmarkJoinWithPlus 测试 JoinWithPlus 函数的性能
func BenchmarkJoinWithPlus(b *testing.B) {
// 1. 测试前的准备工作:定义输入参数(不会计入测试时间)
a, b, c := "hello", "world", "go"
// 2. 重置计时器:关键!忽略准备工作的时间
b.ResetTimer()
// 3. 循环执行目标函数:b.N 是 testing 包自动计算的迭代次数
for i := 0; i < b.N; i++ {
JoinWithPlus(a, b, c)
}
}
// BenchmarkJoinWithBuilder 测试 JoinWithBuilder 函数的性能
func BenchmarkJoinWithBuilder(b *testing.B) {
a, b, c := "hello", "world", "go"
b.ResetTimer() // 必须加,否则准备时间会影响结果
for i := 0; i < b.N; i++ {
JoinWithBuilder(a, b, c)
}
}这里有个关键细节:b.ResetTimer() 必须加在循环前。
因为测试前的准备工作(比如定义参数)不应该计入函数的执行时间,否则会导致结果不准。
(二)执行 Benchmark 测试
在测试文件所在目录打开终端,执行以下命令:
// 执行所有基准测试,显示内存分配信息
go test -bench=. -benchmem -v命令参数说明:
-
-bench=.: 表示执行所有 Benchmark 测试函数,“.” 是通配符;如果想只执行某个,比如 BenchmarkJoinWithPlus,就写 “-bench=JoinWithPlus”;
-
-benchmem: 关键参数,显示内存分配信息(每次执行分配多少内存、分配多少次);
-
-v: 显示详细日志,包括测试函数名称和执行状态。
执行后输出结果类似这样:
goos: darwin
goarch: arm64
pkg: stringutil
BenchmarkJoinWithPlus-8 500000000 2.430 ns/op 16 B/op 1 allocs/op
BenchmarkJoinWithBuilder-8 1000000000 0.8980 ns/op 0 B/op 0 allocs/op
PASS
ok stringutil 3.678s(三)读懂测试结果
结果中的每个字段都有特殊含义,以 “BenchmarkJoinWithPlus-8” 为例,逐字解读:
-
BenchmarkJoinWithPlus-8:“BenchmarkJoinWithPlus” 是测试函数名;“8” 表示测试时使用了 8 个 CPU 核心(和电脑核心数一致,可通过 -cpu 参数指定核心数,比如 -cpu=4 表示用 4 核);
-
500000000:迭代次数(b.N 的值),testing 包会自动调整这个值,确保测试总时间在 1 秒左右(避免测试时间太短导致结果波动);
-
2.430 ns/op:每次执行函数的平均耗时,“ns/op” 是纳秒/次,这里表示每次拼接耗时约 2.43 纳秒。
-
16 B/op:每次执行函数分配的内存大小,这里是 16 字节。
-
1 allocs/op:每次执行函数的内存分配次数,这里是 1 次。
对比两个函数的结果,很明显:JoinWithBuilder 性能更好,不仅耗时只有 “+” 拼接的 1/3 左右,还完全不分配内存。
这也印证了我们的常识:在字符串拼接场景,strings.Builder 比 “+” 更高效,尤其是拼接次数多的时候。
三、进阶技巧
(一)子基准测试:测试不同参数下的性能
实际开发中,函数性能可能受参数影响很大。比如排序函数,对 10 个元素和 1000 个元素排序的性能肯定不一样。
这时候就需要用子基准测试,通过 b.Run() 方法实现,给同一个函数传不同参数测试。
示例:测试切片排序函数在不同切片长度下的性能,在 “sort_test.go” 中编写代码:
// sort_test.go
package sortutil
import (
"math/rand"
"sort"
"testing"
"time"
)
// 生成指定长度的随机切片
func generateRandomSlice(length int) []int {
rand.Seed(time.Now().UnixNano())
slice := make([]int, length)
for i := 0; i < length; i++ {
slice[i] = rand.Intn(10000)
}
return slice
}
// BenchmarkSortSlice 测试不同长度切片的排序性能(子基准测试)
func BenchmarkSortSlice(b *testing.B) {
// 定义要测试的切片长度列表
lengths := []int{10, 100, 1000, 10000}
for _, len := range lengths {
// 子基准测试:第一个参数是子测试名称,第二个是测试函数
b.Run(fmt.Sprintf("length=%d", len), func(b *testing.B) {
// 准备工作:生成指定长度的随机切片
slice := generateRandomSlice(len)
b.ResetTimer()
// 循环排序:每次排序前要复制切片,避免原切片已排序影响结果
for i := 0; i < b.N; i++ {
temp := make([]int, len)
copy(temp, slice)
sort.Ints(temp)
}
})
}
}执行命令 “go test -bench=SortSlice -benchmem” 后,输出结果会按子测试名称分类:
BenchmarkSortSlice/length=10-8 100000000 11.80 ns/op 48 B/op 1 allocs/op
BenchmarkSortSlice/length=100-8 10000000 119.0 ns/op 384 B/op 1 allocs/op
BenchmarkSortSlice/length=1000-8 1000000 1210 ns/op 3072 B/op 1 allocs/op
BenchmarkSortSlice/length=10000-8 100000 12300 ns/op 32768 B/op 1 allocs/op从结果能清晰看到:切片长度越长,排序耗时越长,内存分配也越多。
这种方式能帮我们快速定位“函数在什么场景下性能不足”。
(二)控制测试时间:避免测试耗时过长
默认情况下,每个 Benchmark 测试会执行到总时间约 1 秒,确保结果稳定。但如果函数执行很慢(比如每次执行要 100 毫秒),1 秒只能执行 10 次,迭代次数太少,结果可能不准。
这时候可以用 -benchtime 参数延长测试时间,比如:
// 让每个测试执行 5 秒(增加迭代次数,结果更稳定)
go test -bench=. -benchmem -benchtime=5s反之,如果想快速验证,也可以缩短测试时间,比如 “-benchtime=100ms”,但不建议太短,否则结果波动大。
(三)结合 pprof:定位具体性能瓶颈
Benchmark 能告诉我们“函数性能不好”,但没法直接说“哪里不好”。
这时候需要结合 Go 自带的 pprof 工具,分析 CPU 占用、内存分配等细节。执行命令时加上 pprof 相关参数即可:
// 生成 CPU 性能分析文件(cpu.pprof)
go test -bench=. -benchmem -cpuprofile=cpu.pprof
// 生成内存性能分析文件(mem.pprof)
go test -bench=. -benchmem -memprofile=mem.pprof生成文件后,用以下命令查看分析结果(需要先安装 graphviz 工具可视化):
// 分析 CPU 性能
go tool pprof cpu.pprof
// 分析内存性能
go tool pprof mem.pprof进入交互模式后,输入 “top” 可查看占用 CPU/内存最高的函数,输入 “web” 可生成可视化图表,清晰看到哪个函数是性能瓶颈。
pprof 相关可以查阅文章:Golang 性能分析神器 pprof 最全图文教程
四、注意事项
(一)性能影响
不同环境下,测试结果性能数据不稳定,因此,为了确保测试结果的准确性,需要尽可能的保证测试时机器环境的一致性稳定性:
-
多次测试时,机器状态保持一致,并且机器需要处于闲置空闲状态。
-
机器需要在测试时关闭节能模式/省点模式
-
虚拟机和云主机 CPU 和内存一般会进行超分配,因此应该避免在虚拟机和云主机进行性能测量
⚠️:如果你确实没办法保证以上条件,那么只是在比对测试的时候,两次运行时机器环境要尽可能保持一致,确保基准测试的准确性
(二)测试参数
// 执行基准测试,-cpu 指定 cpu 核数,-count 执行轮数,-benchmem 输出cpu、内存测试结论, 点 . 表示在执行测试的目录为当前目录
go test -bench='BenchmarkStringBuilderAdd' -cpu=2,4 -count=3 -benchmem .| 执行参数 | 简介 | 备注 |
|---|---|---|
| -bench=‘Fib$’ | 可传入正则,匹配用例 | 如只运行以 Fib 结尾的 benchmark 用例 |
| -cpu=2,4 | 可改变 CPU 核数 | GOMAXPROCS,CPU核数,默认机器的核数 |
| -benchtime=5s -benchtime=50x |
可指定执行时间或具体次数 | benchmark 的默认时间是 1s,决定了b.N的次数,测试时间 -benchtime的值除了是时间外,还可以是具体的次数。 例如,执行 30 次可以用-benchtime=30x |
| -count=3 | 可设置 benchmark 轮数 | 参数可以用来设置 benchmark 的轮数,默认是1轮 |
| -benchmem | 可查看内存分配量和分配次数 | 参数可以度量内存分配的次数,添加此参数后会在结果之后显示 n allocs/op 也就是内存分配了n次,m B/op, 总共分配了 m 字节8003641 B/op == 8 003 641 B=7.63M |
| -cpuprofile=./cpu.prof | 生成CPU性能分析 | 执行 CPU profiling,并把结果保存在 cpu.prof |
| -memprofile=./mem.prof | 生成内存性能分析 | 执行 Mem profiling,并把结果保存在 cpu.prof 文件中 |
(三)执行结果说明
mac@fiveyoboy learn-go % go test -bench='BenchmarkStringBuilderAdd' -cpu=2,4 -count=3 -benchmem .
goos: darwin // 操作系统
goarch: arm64 // cpu 处理器架构(我这里是 mac )
pkg: fiveyoboy/learn-go/ // 测试执行所在的包
cpu: Apple M1 Pro // cpu
// 测试函数-使用CPU核数 执行次数 每次耗时ns(左移9位为秒) 分配内存字节B(左移6位为m) 每次分配内存次数
BenchmarkStringBuilderAdd-2 2170213 546.5 ns/op 4320 B/op 6 allocs/op
BenchmarkStringBuilderAdd-2 2217442 548.2 ns/op 4320 B/op 6 allocs/op
BenchmarkStringBuilderAdd-2 2229522 543.1 ns/op 4320 B/op 6 allocs/op
// 上面使用2核进行测试,测试3次,下面是用4核进行测试,测试 3 次
BenchmarkStringBuilderAdd-4 2068552 563.3 ns/op 4320 B/op 6 allocs/op
BenchmarkStringBuilderAdd-4 2069716 561.8 ns/op 4320 B/op 6 allocs/op
BenchmarkStringBuilderAdd-4 2103303 587.5 ns/op 4320 B/op 6 allocs/op
PASS
ok fiveyoboy/learn-go 9.302s | 结果参数 | 简介 | |
|---|---|---|
| b.N | benchmark的次数b.N,默认是-benchtime=1s内执行的次数,b.N 从 -benchtime=1s开始,如果该用例能够在 1s 内完成,b.N 的值大概以 1, 2, 3, 5, 10, 20, 30, 50, 100 这样的序列递增,越到后面,增加得越快 |
|
| goos | 操作系统 | |
| goarch | cpu处理器 | |
| pkg | 所在的包 | |
| BenchmarkGenerate-2 BenchmarkGenerate-4 BenchmarkGenerate-8 |
【测试函数名】-【cpu核数】BenchmarkGenerate-2, 表示使用2核CPU执行BenchmarkGenerate测试函数 |
|
| ns/op | 每次耗时多少毫秒 | |
| B/op | 每次分配内存多少字节Bytes | |
| allocs/op | 每次分配多少次内存 |
常见问题
Q1. 测试结果波动很大?
这是新手最常遇到的问题,原因主要有 3 个:
-
① 没加 b.ResetTimer():准备工作的时间混入了测试时间,解决办法就是在循环前加 b.ResetTimer();
-
② 测试时电脑有其他占用资源的程序:比如开着视频、游戏,CPU 或内存被占用,导致测试结果不准,解决办法是关闭其他程序,确保测试环境安静;
-
③ 迭代次数太少:测试时间太短,解决办法是用 -benchtime 延长测试时间,增加迭代次数。
Q2. 子基准测试不执行?
核心原因是执行命令时的 -bench 参数没匹配到子测试。
比如子测试名称是 “BenchmarkSortSlice/length=10”,如果只写 “-bench=SortSlice”,会执行所有子测试;如果写 “-bench=SortSlice/length=10”,会只执行该子测试。
如果参数写的是 “-bench=length=10”,则匹配不到,子测试不会执行。
Q3. 内存分配次数为 0,但实际有分配?
可能是 Go 的逃逸分析优化导致的。
比如函数内的变量被分配到栈上,而不是堆上,栈内存的分配和释放不会被 benchmem 统计。
这种情况是正常的,说明 Go 编译器做了优化,不用刻意处理。
Q4. 测试函数和目标函数不在同一个包?
如果目标函数是包内私有函数(首字母小写),测试函数必须和它在同一个包才能访问;如果是公开函数(首字母大写),测试函数可以在不同包,但需要导入目标包。
建议测试函数和目标函数放在同一个包,避免导入路径问题。
总结
Benchmark 作为 Go 性能测试的核心工具,用法其实不难,关键在于掌握细节:写测试函数时加 b.ResetTimer(),执行时用 -benchmem 看内存分配,用子基准测试测不同参数场景,结合 pprof 定位瓶颈。
新手入门的关键是“多练+多观察结果”:先从简单函数的测试写起,对比不同实现的性能差异,再逐步尝试子基准测试和 pprof 分析。
记住,性能测试不是“炫技”,而是“解决实际问题”——比如优化后接口响应时间从 100ms 降到 10ms,这才是 Benchmark 的真正价值。
最后提醒:性能优化要“适度”,不要为了优化而优化。先用 Benchmark 找到真正的瓶颈,再针对性优化,避免把时间浪费在对整体性能影响不大的函数上。
如果有其他 Benchmark 踩坑经历,欢迎在评论区交流~
版权声明
未经授权,禁止转载本文章。
如需转载请保留原文链接并注明出处。即视为默认获得授权。
未保留原文链接未注明出处或删除链接将视为侵权,必追究法律责任!
本文原文链接: https://fiveyoboy.com/articles/go-testing-benchmark/
备用原文链接: https://blog.fiveyoboy.com/articles/go-testing-benchmark/