Appearance
基准测试
写完单元测试之后,开发者常常会问:「这段代码跑得有多快?哪种实现更好?为什么这一段比那一段慢?」要回答这些问题,就需要基准测试(Benchmark)。Go 工具链原生支持基准测试,无需引入任何第三方框架。本篇将系统讲解基准测试的核心机制、b.N 自适应迭代、内存分配测量、并行基准、子基准、对比工具 benchstat,以及一个字符串拼接方案的性能对比实战。
一、基准测试基础:BenchmarkXxx
基准测试函数的命名约定是 BenchmarkXxx(b *testing.B),与单元测试的 TestXxx 平行。go test 默认不运行基准测试,必须加 -bench 参数才会触发。
来看一个最小示例。被测代码:
go
package strutil
// SumTo 计算 1+2+...+n。
func SumTo(n int) int {
sum := 0
for i := 1; i <= n; i++ {
sum += i
}
return sum
}基准测试:
go
package strutil
import "testing"
func BenchmarkSumTo(b *testing.B) {
for i := 0; i < b.N; i++ {
SumTo(1000)
}
}运行:
bash
go test -bench=.输出(具体数字取决于机器):
text
goos: windows
goarch: amd64
pkg: example.com/strutil
cpu: Intel(R) Core(TM) i7-9700K CPU @ 3.60GHz
BenchmarkSumTo-8 1234567 970.3 ns/op
PASS
ok example.com/strutil 1.234s关键点:
b.N是核心:基准测试函数里必须有一个for i := 0; i < b.N; i++循环,把被测代码放在里面。- 不要在循环外做耗时操作:循环外的工作不会算入单次时间,但不影响 N 的判断——其实可能会影响,所以 setup 应该放在循环外,并配合
b.ResetTimer。 BenchmarkXxx-8:-8表示 GOMAXPROCS=8,用于标识并行基准的并行度。
二、b.N 和自适应迭代
Go 的基准测试框架会自动调整 b.N,让整个基准测试运行约 1 秒(默认)。它的做法是:
- 第一次跑
b.N=1,测量耗时。 - 根据耗时估计「跑多少次能让总时长接近 1 秒」。
- 用新的 N 再跑。
- 重复直到稳定,最终报告结果。
这意味着你不能假设 b.N 是某个固定值——它可能是 1,也可能是 10 亿次。你的代码必须能处理任何 N。
一个常见错误:
go
// ❌ 错误:依赖 N 等于某个具体值
func BenchmarkBad(b *testing.B) {
data := make([]int, b.N) // 这部分是对的
for i := 0; i < b.N; i++ {
data[i] = i // 但不应该在循环内修改 data
}
// 在循环外测量 data 大小会出错
}正确做法:把 setup 放在循环外,并调用 b.ResetTimer() 重置计时器。
三、b.ResetTimer、b.StopTimer、b.StartTimer
默认情况下,基准测试从函数开始执行就计时。如果函数开头有一段 setup(如分配大数组、读文件),这部分耗时会被算进结果,导致结果不准。三个 API 帮你精细控制计时:
| API | 作用 |
|---|---|
b.ResetTimer() | 重置计时器,丢弃之前已耗费的时间 |
b.StopTimer() | 暂停计时器,做一些不想被计入耗时的操作 |
b.StartTimer() | 恢复计时器 |
go
package strutil
import "testing"
func BenchmarkSumTo_Large(b *testing.B) {
// 假设 setup 较耗时
b.StopTimer()
target := 1000000
b.StartTimer()
// 计时从这里开始
for i := 0; i < b.N; i++ {
SumTo(target)
}
}
// 推荐写法:用 ResetTimer 替代 Stop/Start
func BenchmarkSumTo_Reset(b *testing.B) {
target := 1000000
b.ResetTimer()
for i := 0; i < b.N; i++ {
SumTo(target)
}
}
// 更复杂的场景:每次循环都需要 setup,但 setup 不能被计入
func BenchmarkSumTo_PerIter(b *testing.B) {
for i := 0; i < b.N; i++ {
b.StopTimer()
target := 1000 // 模拟每次循环的 setup
b.StartTimer()
SumTo(target)
}
}
StopTimer/StartTimer会有额外开销:每次调用都触发 timer 状态切换,在 N 很大时会显著影响结果。仅在必要时使用,能避免就避免。
四、基准测试结果解读:ns/op、B/op、allocs/op
加上 -benchmem 参数,可以看到内存分配情况:
bash
go test -bench=. -benchmem输出:
text
BenchmarkSumTo-8 1234567 970.3 ns/op 0 B/op 0 allocs/op每列含义:
| 列 | 含义 |
|---|---|
BenchmarkSumTo-8 | 基准测试名 + GOMAXPROCS |
1234567 | b.N 的最终值(迭代次数) |
970.3 ns/op | 每次迭代平均耗时(纳秒) |
0 B/op | 每次迭代平均分配的字节数 |
0 allocs/op | 每次迭代平均分配的对象数 |
经验法则:
- ns/op 越小越好:直接反映性能。
- B/op 越小越好:分配越少,GC 压力越小。
- allocs/op 越小越好:分配次数直接影响 GC 频率,通常比 B/op 影响更大。
- 「0 allocs/op」是黄金目标:很多热点代码可以做到零分配。
来对比两个例子:
go
package strutil
import (
"fmt"
"strings"
)
// ConcatFmt 用 fmt.Sprintf 拼接字符串。
func ConcatFmt(parts ...string) string {
return fmt.Sprintf("%s-%s-%s", parts[0], parts[1], parts[2])
}
// ConcatBuilder 用 strings.Builder 拼接。
func ConcatBuilder(parts ...string) string {
var b strings.Builder
b.WriteString(parts[0])
b.WriteByte('-')
b.WriteString(parts[1])
b.WriteByte('-')
b.WriteString(parts[2])
return b.String()
}
// ConcatPlus 用 + 拼接。
func ConcatPlus(parts []string) string {
return parts[0] + "-" + parts[1] + "-" + parts[2]
}基准测试:
go
package strutil
import "testing"
func BenchmarkConcatFmt(b *testing.B) {
parts := []string{"hello", "world", "foo"}
for i := 0; i < b.N; i++ {
_ = ConcatFmt(parts...)
}
}
func BenchmarkConcatBuilder(b *testing.B) {
parts := []string{"hello", "world", "foo"}
for i := 0; i < b.N; i++ {
_ = ConcatBuilder(parts...)
}
}
func BenchmarkConcatPlus(b *testing.B) {
parts := []string{"hello", "world", "foo"}
for i := 0; i < b.N; i++ {
_ = ConcatPlus(parts)
}
}运行 go test -bench=. -benchmem:
text
BenchmarkConcatFmt-8 654321 1820.5 ns/op 80 B/op 3 allocs/op
BenchmarkConcatBuilder-8 1234567 920.4 ns/op 32 B/op 1 allocs/op
BenchmarkConcatPlus-8 2345678 480.2 ns/op 32 B/op 1 allocs/op可以清楚看到:
ConcatFmt最慢且分配最多——fmt.Sprintf内部反射 + 多次分配。ConcatPlus最快——3 个字符串拼接被编译器优化为一次分配。ConcatBuilder中等——Builder 比 fmt 快,但比 + 慢。
注意:
+拼接在已知数量的小字符串上最快。但如果循环里反复s += x,每次都会重新分配,那就成了 O(n²) 灾难。Builder 才是循环拼接的正确选择。
五、内存分配优化验证
基准测试是验证「优化是否生效」的关键手段。来看一个 map 分配的优化案例:
go
package strutil
import "strings"
// SplitToLower 把字符串按空格分割并转小写。
func SplitToLower(s string) []string {
parts := strings.Split(s, " ")
for i, p := range parts {
parts[i] = strings.ToLower(p)
}
return parts
}
// SplitToLowerPrealloc 预分配结果切片容量。
func SplitToLowerPrealloc(s string) []string {
n := strings.Count(s, " ") + 1
parts := make([]string, 0, n)
start := 0
for i := 0; i < len(s); i++ {
if s[i] == ' ' {
parts = append(parts, strings.ToLower(s[start:i]))
start = i + 1
}
}
parts = append(parts, strings.ToLower(s[start:]))
return parts
}基准测试:
go
package strutil
import "testing"
func BenchmarkSplitToLower(b *testing.B) {
s := "Hello World Foo Bar Baz"
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = SplitToLower(s)
}
}
func BenchmarkSplitToLowerPrealloc(b *testing.B) {
s := "Hello World Foo Bar Baz"
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = SplitToLowerPrealloc(s)
}
}b.ReportAllocs() 显式开启内存分配统计(无需 -benchmem 参数)。运行:
bash
go test -bench=Split -count=3输出(典型情况):
text
BenchmarkSplitToLower-8 567890 2100 ns/op 144 B/op 3 allocs/op
BenchmarkSplitToLower-8 567890 2110 ns/op 144 B/op 3 allocs/op
BenchmarkSplitToLower-8 567890 2095 ns/op 144 B/op 3 allocs/op
BenchmarkSplitToLowerPrealloc-8 890123 1340 ns/op 80 B/op 1 allocs/op
BenchmarkSplitToLowerPrealloc-8 890123 1350 ns/op 80 B/op 1 allocs/op
BenchmarkSplitToLowerPrealloc-8 890123 1335 ns/op 80 B/op 1 allocs/op预分配把分配次数从 3 降到 1,速度提升约 36%。这就是优化的「证据」。
六、并行基准测试:b.RunParallel
对于线程安全的代码,可以用 b.RunParallel 测量并行性能。它会启动多个 goroutine 同时跑被测代码,每个 goroutine 独立累加自己的计数。
go
package strutil
import (
"strings"
"sync/atomic"
)
// Counter 是一个线程安全的计数器。
type Counter struct {
count int64
}
func (c *Counter) Inc() {
atomic.AddInt64(&c.count, 1)
}
func (c *Counter) Get() int64 {
return atomic.LoadInt64(&c.count)
}
// ToUpper 是纯函数,并行安全。
func ToUpper(s string) string {
return strings.ToUpper(s)
}基准测试:
go
package strutil
import (
"strings"
"testing"
)
func BenchmarkCounterSerial(b *testing.B) {
c := &Counter{}
for i := 0; i < b.N; i++ {
c.Inc()
}
}
func BenchmarkCounterParallel(b *testing.B) {
c := &Counter{}
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
c.Inc()
}
})
}
func BenchmarkToUpperParallel(b *testing.B) {
s := "Hello World"
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_ = strings.ToUpper(s)
}
})
}b.RunParallel 的签名:
go
func (b *B) RunParallel(body func(*PB))*PB 有一个 Next() bool 方法,返回 true 表示还有任务,返回 false 表示结束。每个 goroutine 独立调用 pb.Next(),框架保证总调用次数恰好等于 b.N。
并行基准的输出会带上 -8(GOMAXPROCS):
text
BenchmarkCounterSerial-8 100000000 11.5 ns/op
BenchmarkCounterParallel-8 200000000 6.0 ns/op
BenchmarkToUpperParallel-8 500000000 2.4 ns/op并行基准的意义:不只是测多核性能,更能验证「这段代码是否真的线程安全」。如果被测代码有数据竞争,
go test -race -bench=.会立刻报错。
七、子基准测试:b.Run
类似子测试 t.Run,基准测试也支持 b.Run(name, func)。它常用于:
- 在不同输入规模下测量(n=10、100、1000、10000)。
- 在不同实现之间对比(保持测试结构相同)。
- 测量不同参数下的性能曲线。
go
package strutil
import (
"strings"
"testing"
)
func BenchmarkSumTo_Scalable(b *testing.B) {
for _, n := range []int{10, 100, 1000, 10000, 100000} {
b.Run("n="+itoa(n), func(b *testing.B) {
for i := 0; i < b.N; i++ {
SumTo(n)
}
})
}
}
func BenchmarkToUpper_Sizes(b *testing.B) {
small := "hello"
medium := strings.Repeat("hello", 100)
large := strings.Repeat("hello", 10000)
b.Run("small", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = strings.ToUpper(small)
}
})
b.Run("medium", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = strings.ToUpper(medium)
}
})
b.Run("large", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = strings.ToUpper(large)
}
})
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var sign string
if n < 0 {
sign = "-"
n = -n
}
digits := []byte{}
for n > 0 {
digits = append([]byte{byte('0' + n%10)}, digits...)
n /= 10
}
return sign + string(digits)
}运行 go test -bench=Scalable:
text
BenchmarkSumTo_Scalable/n=10-8 100000000 11.2 ns/op
BenchmarkSumTo_Scalable/n=100-8 20000000 52.1 ns/op
BenchmarkSumTo_Scalable/n=1000-8 2000000 510 ns/op
BenchmarkSumTo_Scalable/n=10000-8 200000 5120 ns/op
BenchmarkSumTo_Scalable/n=100000-8 20000 51200 ns/op可以清楚看到耗时与 n 成线性关系——SumTo 是 O(n)。
子基准也可以与 -run 配合选择:
bash
go test -bench='Scalable/n=1000$' .只跑 n=1000 的那个子基准。
八、对比不同实现性能
下面用一个完整的「字符串拼接」对比案例,展示如何用基准测试比较多个实现。
go
package strutil
import (
"fmt"
"strings"
)
// 实现 1:fmt.Sprintf
func JoinFmt(parts []string, sep string) string {
switch len(parts) {
case 0:
return ""
case 1:
return parts[0]
default:
return fmt.Sprintf("%s", strings.Join(parts, sep))
}
}
// 实现 2:strings.Join
func JoinStd(parts []string, sep string) string {
return strings.Join(parts, sep)
}
// 实现 3:手写循环 + Builder
func JoinBuilder(parts []string, sep string) string {
if len(parts) == 0 {
return ""
}
var b strings.Builder
b.WriteString(parts[0])
for _, p := range parts[1:] {
b.WriteString(sep)
b.WriteString(p)
}
return b.String()
}
// 实现 4:手写循环 + 直接拼接(每次重新分配)
func JoinConcat(parts []string, sep string) string {
if len(parts) == 0 {
return ""
}
s := parts[0]
for _, p := range parts[1:] {
s += sep + p
}
return s
}基准测试:
go
package strutil
import (
"strings"
"testing"
)
func BenchmarkJoin(b *testing.B) {
cases := []struct {
name string
parts []string
}{
{"3 parts", []string{"a", "b", "c"}},
{"10 parts", strings.Split("a,b,c,d,e,f,g,h,i,j", ",")},
{"100 parts", func() []string {
s := make([]string, 100)
for i := range s {
s[i] = "x"
}
return s
}()},
}
implementations := []struct {
name string
fn func([]string, string) string
}{
{"Fmt", JoinFmt},
{"Std", JoinStd},
{"Builder", JoinBuilder},
{"Concat", JoinConcat},
}
for _, c := range cases {
for _, impl := range implementations {
b.Run(c.name+"/"+impl.name, func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = impl.fn(c.parts, "-")
}
})
}
}
}运行 go test -bench=Join -benchmem,输出大致如下:
text
BenchmarkJoin/3_parts/Fmt-8 2000000 600 ns/op 80 B/op 3 allocs/op
BenchmarkJoin/3_parts/Std-8 10000000 120 ns/op 16 B/op 1 allocs/op
BenchmarkJoin/3_parts/Builder-8 5000000 200 ns/op 24 B/op 2 allocs/op
BenchmarkJoin/3_parts/Concat-8 5000000 180 ns/op 32 B/op 2 allocs/op
BenchmarkJoin/10_parts/Fmt-8 1000000 1500 ns/op 144 B/op 3 allocs/op
BenchmarkJoin/10_parts/Std-8 5000000 220 ns/op 48 B/op 1 allocs/op
BenchmarkJoin/10_parts/Builder-8 3000000 400 ns/op 80 B/op 2 allocs/op
BenchmarkJoin/10_parts/Concat-8 2000000 600 ns/op 160 B/op 6 allocs/op
BenchmarkJoin/100_parts/Fmt-8 200000 7000 ns/op 928 B/op 3 allocs/op
BenchmarkJoin/100_parts/Std-8 2000000 700 ns/op 256 B/op 1 allocs/op
BenchmarkJoin/100_parts/Builder-8 1000000 1100 ns/op 832 B/op 2 allocs/op
BenchmarkJoin/100_parts/Concat-8 100000 12000 ns/op 6400 B/op 42 allocs/op可以看到:
strings.Join始终最快:标准库会预算最终长度,一次性分配。Concat在大切片上灾难性慢:每次+=都重新分配,O(n²) 复杂度。Builder比Join慢:因为 Builder 不知道 sep 长度,多次扩容。fmt.Sprintf永远最慢:反射开销 + 多次分配。
这就是基准测试的威力:用数据说话,避免「凭感觉优化」。
九、go test -bench 参数详解
go test 与基准测试相关的参数:
| 参数 | 作用 |
|---|---|
-bench regexp | 运行名字匹配正则的基准测试 |
-bench . | 运行所有基准测试(. 是匹配任意字符的正则) |
-benchmem | 报告每次迭代的内存分配 |
-benchtime duration | 每个基准测试运行多久(默认 1s,可设 5s、100x 等) |
-count N | 每个基准测试跑 N 次(用于稳定性测试,默认 1) |
-cpu 1,2,4,8 | 在不同 GOMAXPROCS 下跑 |
-run '^$' | 跳过单元测试(-run 默认会跑匹配的 Test,传 ^$ 不匹配) |
-benchtime 100x | 每个基准测试跑恰好 100 次(x 表示次数) |
-cpuprofile file | 生成 CPU profile |
-memprofile file | 生成内存 profile |
-trace file | 生成执行 trace |
-short | 跳过耗时基准 |
常用组合:
bash
# 跑所有基准测试,显示内存分配
go test -bench=. -benchmem
# 只跑基准,跳过单元测试(关键:-run '^$')
go test -bench=. -run='^$' -benchmem
# 每个基准跑 5 秒,跑 3 次取平均
go test -bench=. -benchtime=5s -count=3 -benchmem
# 跑指定基准
go test -bench='BenchmarkJoin$' -benchmem
# 在 1、4、8 个 CPU 下跑
go test -bench=. -cpu=1,4,8 -benchmem
# 固定 1000 次迭代(用于短测试)
go test -bench=. -benchtime=1000x
# 生成 CPU profile,用 go tool pprof 分析
go test -bench=. -cpuprofile=cpu.prof
go tool pprof cpu.prof十、go benchstat 对比工具
单次基准测试的结果波动较大,要严谨对比两个实现,需要 benchstat 工具:
bash
go install golang.org/x/perf/cmd/benchstat@latest工作流程:
bash
# 第 1 步:跑旧实现的基准,记录到 old.txt
git stash # 暂存新代码
go test -bench=. -benchmem -count=10 > old.txt
git stash pop # 恢复新代码
# 第 2 步:跑新实现
go test -bench=. -benchmem -count=10 > new.txt
# 第 3 步:对比
benchstat old.txt new.txtbenchstat 会输出对比表,自动标注差异是否显著:
text
│ old.txt │ new.txt │
│ sec/op | allocs/op | sec/op │ allocs/op │
Join/3_parts/Std-8 120.5n ± 2% 1.0 ± 0% 118.2n ± 1% -1.9% (p=0.001)
Join/100_parts/Std-8 705.0n ± 3% 1.0 ± 0% 520.0n ± 2% -26.2% (p=0.000)
Join/100_parts/Builder-8 1100.0n ± 4% 2.0 ± 0% 950.0n ± 2% -13.6% (p=0.000)± X% 表示方差,(p=0.001) 是显著性 p-value。p < 0.05 才算差异显著,否则视为噪声。
不要用单次基准下结论。CPU 频率波动、后台进程、温度都会影响结果。
-count=10+benchstat是最低标准。
十一、完整示例:字符串拼接方案性能对比
把前面散落的示例整合成一个完整可运行的项目。
目录结构:
text
strutil/
├── go.mod
├── strutil.go
└── strutil_test.gogo.mod:
text
module example.com/strutil
go 1.21strutil.go:
go
// Package strutil 提供字符串处理工具。
package strutil
import (
"fmt"
"strings"
)
// SumTo 计算 1+2+...+n。
func SumTo(n int) int {
sum := 0
for i := 1; i <= n; i++ {
sum += i
}
return sum
}
// JoinFmt 用 fmt.Sprintf 拼接(性能较差)。
func JoinFmt(parts []string, sep string) string {
if len(parts) == 0 {
return ""
}
return fmt.Sprintf("%s", strings.Join(parts, sep))
}
// JoinStd 用标准库 strings.Join(推荐)。
func JoinStd(parts []string, sep string) string {
return strings.Join(parts, sep)
}
// JoinBuilder 用 strings.Builder 手写循环。
func JoinBuilder(parts []string, sep string) string {
if len(parts) == 0 {
return ""
}
var b strings.Builder
b.WriteString(parts[0])
for _, p := range parts[1:] {
b.WriteString(sep)
b.WriteString(p)
}
return b.String()
}
// JoinConcat 用 + 直接拼接(每次重新分配,O(n²))。
func JoinConcat(parts []string, sep string) string {
if len(parts) == 0 {
return ""
}
s := parts[0]
for _, p := range parts[1:] {
s += sep + p
}
return s
}
// JoinPrealloc 用 Builder + 预分配容量(最佳)。
func JoinPrealloc(parts []string, sep string) string {
if len(parts) == 0 {
return ""
}
total := 0
for _, p := range parts {
total += len(p)
}
total += len(sep) * (len(parts) - 1)
var b strings.Builder
b.Grow(total)
b.WriteString(parts[0])
for _, p := range parts[1:] {
b.WriteString(sep)
b.WriteString(p)
}
return b.String()
}strutil_test.go:
go
package strutil
import (
"strings"
"testing"
)
// 基准测试主入口:生成多种规模 × 多种实现的对比矩阵。
func BenchmarkJoin(b *testing.B) {
cases := []struct {
name string
parts []string
}{
{"3", []string{"a", "b", "c"}},
{"10", strings.Split("a,b,c,d,e,f,g,h,i,j", ",")},
{"100", makeStrings(100)},
{"1000", makeStrings(1000)},
}
implementations := []struct {
name string
fn func([]string, string) string
}{
{"Fmt", JoinFmt},
{"Std", JoinStd},
{"Builder", JoinBuilder},
{"Concat", JoinConcat},
{"Prealloc", JoinPrealloc},
}
for _, c := range cases {
for _, impl := range implementations {
b.Run(c.name+"/"+impl.name, func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = impl.fn(c.parts, "-")
}
})
}
}
}
func makeStrings(n int) []string {
s := make([]string, n)
for i := range s {
s[i] = "x"
}
return s
}
// 简单基准:测 SumTo 的可扩展性
func BenchmarkSumTo_Scalable(b *testing.B) {
for _, n := range []int{100, 1000, 10000, 100000} {
b.Run("n="+itoa(n), func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = SumTo(n)
}
})
}
}
// 并行基准示例:测 strings.ToUpper 在并发下的性能
func BenchmarkToUpper_Parallel(b *testing.B) {
s := strings.Repeat("Hello World ", 100)
b.ReportAllocs()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_ = strings.ToUpper(s)
}
})
}
// 子基准示例:测不同字符串长度下 ToUpper 的性能曲线
func BenchmarkToUpper_Sizes(b *testing.B) {
sizes := []int{1, 10, 100, 1000, 10000}
for _, size := range sizes {
s := strings.Repeat("a", size)
b.Run("size="+itoa(size), func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = strings.ToUpper(s)
}
})
}
}
// 演示 ResetTimer 的使用
func BenchmarkSetup(b *testing.B) {
// 假设这里有一个耗时的 setup
target := make([]int, 10000)
for i := range target {
target[i] = i
}
b.ResetTimer()
// 计时只覆盖这里
for i := 0; i < b.N; i++ {
_ = SumTo(target[len(target)-1])
}
}
// 演示 StopTimer/StartTimer 的使用(每次循环都 setup)
func BenchmarkPerIterSetup(b *testing.B) {
for i := 0; i < b.N; i++ {
b.StopTimer()
// 每次循环都做 setup(不计入耗时)
n := i % 100
b.StartTimer()
_ = SumTo(n)
}
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var sign string
if n < 0 {
sign = "-"
n = -n
}
digits := []byte{}
for n > 0 {
digits = append([]byte{byte('0' + n%10)}, digits...)
n /= 10
}
return sign + string(digits)
}运行全部基准:
bash
go test -bench=. -benchmem -run='^$' -count=3使用 benchstat 对比「优化前」与「优化后」:
bash
# 假设我们把 JoinStd 改进成了 JoinPrealloc,想看提升
git stash
go test -bench='Join.*Std$' -benchmem -run='^$' -count=10 > before.txt
git stash pop
go test -bench='Join.*Prealloc$' -benchmem -run='^$' -count=10 > after.txt
# 注意:benchstat 比较时要求测试名相同,所以可能需要 sed 替换名字
sed 's/Std$/X/' before.txt > before_fixed.txt
sed 's/Prealloc$/X/' after.txt > after_fixed.txt
benchstat before_fixed.txt after_fixed.txt十二、基准测试常见陷阱
1. 编译器优化掉了被测代码
go
// ❌ 错误:编译器可能直接消除调用,因为没有副作用
func BenchmarkBad(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = strings.ToUpper("hello")
}
}修复:用一个全局变量接收结果,阻止编译器内联:
go
var result string
func BenchmarkGood(b *testing.B) {
var r string
for i := 0; i < b.N; i++ {
r = strings.ToUpper("hello")
}
result = r
}2. 循环外初始化但循环内依赖
go
// ❌ 错误:data 在循环外只创建一次,第一次迭代后就被消费了
func BenchmarkBad(b *testing.B) {
data := makeData()
for i := 0; i < b.N; i++ {
consume(data) // 第二次开始 data 已被修改
}
}修复:每次循环都重新构造,或使用 b.StopTimer/StartTimer。
3. N 太小时分配误差大
text
BenchmarkX-8 1 1000000 ns/op 1000 B/op 5 allocs/opN=1 时只有一次测量,统计意义不大。可以加 -benchtime=10s 让它跑更久。
4. 后台进程干扰
跑基准时关闭浏览器、IDE、其他 CPU 密集任务。-count=10 + benchstat 能过滤掉一些噪声。
5. 不同硬件对比
「我机器上 100ns/op,你机器上 500ns/op,所以你的代码慢」——这是错的。基准测试结果只在同一台机器、同一时刻可对比。跨机器对比要除以基准频率或用相对值。
6. 过早优化
「不要过早优化」是老生常谈,但也是真知。先写正确的代码,跑通测试,再用基准测试找瓶颈,最后才优化。优化后必须有基准测试证明提升,否则就是「凭感觉」。
7. 没有清理 cache
bash
go test -bench=. -count=10Go 会缓存测试结果,可能导致重复跑得到完全一样的数字。加上 -count=N 配合 benchstat 是安全做法,或显式 -clean-cache:
bash
go clean -testcache
go test -bench=. -benchmem十三、小结
本篇系统讲解了 Go 基准测试,核心要点:
BenchmarkXxx(b *testing.B):基准测试函数,必须有for i := 0; i < b.N; i++循环。b.N自适应:框架自动调整 N,让测试跑约 1 秒,不要假设 N 是固定值。b.ResetTimer:丢弃 setup 时间,最常用。b.StopTimer/b.StartTimer:精细控制计时,但有额外开销,慎用。-benchmem:报告B/op(字节数)与allocs/op(对象数),优化内存的关键指标。b.RunParallel:并行基准,测多核性能 + 验证线程安全。b.Run:子基准,多规模对比,性能曲线。-bench参数:-benchtime、-count、-cpu、-cpuprofile、-memprofile。benchstat:对比两次基准结果,自动标注显著性。- 陷阱:编译器优化、N 太小、跨机器对比、过早优化。
下一篇我们将进入 Fuzz Testing——Go 1.18+ 引入的原生模糊测试能力,它能自动生成各种输入,发现单元测试难以覆盖的边界 bug。
基准测试不是炫技。它能给你两个真实价值:证明优化有效与定位性能瓶颈。把这两件事做扎实,你的代码就会既有速度,又有底气。