Go 基准测试
用 testing.B 与 go test -bench 建立可重复的性能基线,正确使用 b.N、计时复位、分配统计与子基准对比。
[!info] 关联笔记
Go 基准测试
这个概念为什么会出现
优化若没有可重复数字,只剩故事:“感觉快了”“这台机器上好像好点”。Go 把基准测试放进与单元测试同一套 go test 工具链:
- 同一仓库、同一包、同一 CI 可跑
- 自动选择迭代次数
b.N以降低计时噪声 - 可报告每次操作的分配次数与字节
Benchmark 回答的是“这条路径有多贵”,不是“业务是否正确”。正确性仍归 Test/Fuzz/-race。
[!abstract] 一句话理解 Go benchmark 用
BenchmarkXxx(b *testing.B)在受控循环里重复目标代码,由工具调整b.N并报告每操作耗时与可选分配指标,用来建基线与对比实现。
最小可运行示例
先把示例放进业务场景,再看代码:
场景:对比「拼装物流面单备注」的两种字符串实现
仓储系统导出面单时,要把「收件人 + 电话尾号」拼成备注字段,高峰期每秒成千上万次。
产品怀疑 + 拼接分配多,想和 strings.Builder 比一下——这是典型的已知热点路径建基线,不是先上 pprof 全站扫。
用 BenchmarkXxx + -benchmem 看 ns/op 与 allocs/op,再决定要不要改实现。
// label.go
package shipping
// JoinLabelNote 拼装面单备注:姓名 + 分隔符 + 手机尾号。
// 业务意图:导出面单时的高频纯字符串路径。
func JoinLabelNote(name, mobileTail string) string {
return name + "/" + mobileTail
}
// label_bench_test.go
package shipping
import (
"strings"
"testing"
)
// BenchmarkJoinLabelPlus 测当前线上写法:多次 +。
// 教学点:循环必须跑 b.N 次;用 _ 吃掉结果防被优化掉。
func BenchmarkJoinLabelPlus(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = JoinLabelNote("Ada", "1234")
}
}
// BenchmarkJoinLabelBuilder 测备选:Builder 写两段再取出。
// 业务意图:同输入下对比分配,而不是「感觉 Builder 更快」。
func BenchmarkJoinLabelBuilder(b *testing.B) {
for i := 0; i < b.N; i++ {
var sb strings.Builder
sb.WriteString("Ada")
sb.WriteString("/")
sb.WriteString("1234")
_ = sb.String()
}
}
建议运行:
go test -bench=. -benchmem .
go test -bench=BenchmarkJoinLabelPlus -count=5 .
期望输出形态(数字因机器而异):
BenchmarkJoinLabelPlus-8 20000000 55.0 ns/op 8 B/op 1 allocs/op
BenchmarkJoinLabelBuilder-8 15000000 80.0 ns/op 24 B/op 2 allocs/op
结合场景再看三个关注点
-
循环次数交给
b.N
面单路径要让工具自动加长迭代,自己写死1000000会噪声大、不可比。 -
-benchmem解释「贵在分配还是 CPU」
物流备注这种短串,allocs/op 往往比口头「换 Builder」更有说服力。 -
Benchmark 不证明正确
拼装对不对仍归Test;这里只回答「这条导出路径有多贵」。
核心概念与准确模型
约定
func BenchmarkName(b *testing.B) {
// 可选:昂贵 setup
b.ResetTimer() // setup 不计入
for i := 0; i < b.N; i++ {
// 被测代码
}
}
| API | 作用 |
|---|---|
b.N | 框架选定的迭代次数 |
b.ResetTimer | 丢弃之前计时 |
b.StopTimer / StartTimer | 暂停/恢复计时 |
b.ReportAllocs | 强制报告分配(或用 -benchmem) |
b.SetBytes(n) | 报告 MB/s 吞吐 |
b.Run(name, ...) | 子基准 |
b.RunParallel | 并行基准(多 goroutine 分摊 b.N 语义见文档) |
测什么
适合:
- 热点纯函数、编解码、序列化、正则、模板
- 两种算法/数据结构对比
- 分配是否回退(regression)
不适合单独承担:
- 跨机器绝对 SLA 承诺(噪声大)
- 未固定输入分布的“全系统性能”
- 正确性(应用 Test)
子基准与参数矩阵
func BenchmarkMarshal(b *testing.B) {
for _, n := range []int{64, 4096, 1 << 20} {
b.Run(fmt.Sprintf("size=%d", n), func(b *testing.B) {
data := make([]byte, n)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = sink(data)
}
})
}
}
与 pprof 的分工
| 手段 | 问题 |
|---|---|
| benchmark | 已知路径的成本与对比 |
| pprof CPU | 谁在吃 CPU |
| pprof heap | 谁在分配/持有 |
| trace | 延迟与调度时间线 |
常见流程:benchmark 发现回归 → pprof 定位热点 → 改代码 → benchmark 确认。
规范 vs 实现
- 约定与 API:
testing包与go test文档定义如何写、如何跑。 - 计时分辨率、调度、GC 介入、编译器优化:属实现与运行环境,同一基准在不同 GOMAXPROCS/机器上数字不同。
- 不要把某次 ns/op 写进语言规范式的“真理”。
边界情况与反直觉行为
1. 编译器把工作优化没了
只调用无副作用且结果未使用的纯函数,可能被优化。使用包级 var sink int 或返回值赋给 sink。
2. setup 污染计时
在循环外构造巨大输入却忘了 ResetTimer,数字虚高。
3. 第一次迭代的惰性初始化
缓存、sync.Once、正则 MustCompile 放错位置会扭曲结果。明确“是否包含初始化成本”。
4. GC 噪声
短基准易受 GC 影响。-count=5 看分布;必要时 b.ReportMetric 或拉长运行 -benchtime.
5. 并行基准误解
RunParallel 测的是并行扩展性,不是简单“更快”。共享资源锁竞争可能更慢。
常见误区
[!warning] 常见误区:用 time.Now 自己循环当基准 错误:手写计时、固定 1000 次。
正确:用testing.B与足够benchtime。
[!warning] 常见误区:一次数字定胜负 错误:跑一次 A 比 B 快 2% 就合并。
正确:-count多次,看噪声;大改进才有决策意义。
[!warning] 常见误区:优化未证明的热点 错误:先微优化再测。
正确:先 profile/bench 证明路径昂贵。
[!warning] 常见误区:benchmark 替代正确性测试 错误:只 bench 不 test。
正确:先对后快。
工程实践
- 固定输入:同一数据集;记录 Go 版本与关键 flag。
- 对比同机前后:PR 上贴 benchstat 风格对比(工具可外置)。
- 防回归:对核心热点保留 benchmark,CI 可选跑(耗时权衡)。
- 报告分配:内存优化优先看
allocs/op。 - 真实一点的负载:仅微基准不够时,补集成压测(另一层)。
- 文档:注释写明测的是哪条路径、是否含分配。
go test -bench=BenchmarkConcat -benchmem -count=5 | tee old.txt
# 改代码后
go test -bench=BenchmarkConcat -benchmem -count=5 | tee new.txt
可验证实验
实验 1:+ 与 Builder
对长字符串重复拼接对比 + 与 strings.Builder 的 allocs/op。
实验 2:ResetTimer
在 reset 前后插入 time.Sleep,观察 ns/op 变化。
实验 3:SetBytes
对拷贝 n 字节的函数 b.SetBytes(n),观察 MB/s。
实验 4:子基准尺寸矩阵
不同 n 下算法交叉点(小 n 与大 n 胜者可能不同)。
本节总结
- 本质:受控重复测量 + 工具选
b.N。 - 关键指标:ns/op、B/op、allocs/op、可选吞吐。
- 最易错:测错东西、优化被优化掉、单次噪声决策。
- 下一步:pprof 解释“为什么慢”;GC 理解分配压力。
自测题
概念题
- 为什么循环条件必须是
i < b.N? b.ResetTimer解决什么问题?-benchmem多了哪些列?
代码推理题
func BenchmarkSum(b *testing.B) {
for i := 0; i < b.N; i++ {
sum(1000)
}
}
若 sum 结果未使用,可能发生什么?如何避免?
工程思考题
CI 每次 PR 全量 -bench=. -count=10 是否合适?如何折中?
参考答案
展开
- 框架通过调整 N 达到稳定计时;手写次数失去该机制。
- 排除 setup 耗时。
- 每操作字节与分配次数。
代码题:可能被优化掉;用 sink 保存结果。
工程题:通常只对热点包/变更相关 bench 或 nightly 跑;PR 用短 benchtime 冒烟。
延伸阅读与资料来源
| 资料 | 类型 | 支撑 |
|---|---|---|
| Package testing — B | 标准库 | Benchmark API |
| go test — bench flags | 命令 | -bench/-benchmem/-benchtime |
| Using Subtests and Sub-benchmarks | 博客 | 子基准 |
| Profiling Go Programs | 博客 | 与 pprof 衔接 |