Skip to content

基准测试

写完单元测试之后,开发者常常会问:「这段代码跑得有多快?哪种实现更好?为什么这一段比那一段慢?」要回答这些问题,就需要基准测试(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

关键点:

  1. b.N 是核心:基准测试函数里必须有一个 for i := 0; i < b.N; i++ 循环,把被测代码放在里面。
  2. 不要在循环外做耗时操作:循环外的工作不会算入单次时间,但不影响 N 的判断——其实可能会影响,所以 setup 应该放在循环外,并配合 b.ResetTimer
  3. BenchmarkXxx-8-8 表示 GOMAXPROCS=8,用于标识并行基准的并行度。

二、b.N 和自适应迭代

Go 的基准测试框架会自动调整 b.N,让整个基准测试运行约 1 秒(默认)。它的做法是:

  1. 第一次跑 b.N=1,测量耗时。
  2. 根据耗时估计「跑多少次能让总时长接近 1 秒」。
  3. 用新的 N 再跑。
  4. 重复直到稳定,最终报告结果。

这意味着你不能假设 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
1234567b.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²) 复杂度。
  • BuilderJoin:因为 Builder 不知道 sep 长度,多次扩容。
  • fmt.Sprintf 永远最慢:反射开销 + 多次分配。

这就是基准测试的威力:用数据说话,避免「凭感觉优化」。

九、go test -bench 参数详解

go test 与基准测试相关的参数:

参数作用
-bench regexp运行名字匹配正则的基准测试
-bench .运行所有基准测试(. 是匹配任意字符的正则)
-benchmem报告每次迭代的内存分配
-benchtime duration每个基准测试运行多久(默认 1s,可设 5s100x 等)
-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.txt

benchstat 会输出对比表,自动标注差异是否显著:

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.go

go.mod

text
module example.com/strutil

go 1.21

strutil.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/op

N=1 时只有一次测量,统计意义不大。可以加 -benchtime=10s 让它跑更久。

4. 后台进程干扰

跑基准时关闭浏览器、IDE、其他 CPU 密集任务。-count=10 + benchstat 能过滤掉一些噪声。

5. 不同硬件对比

「我机器上 100ns/op,你机器上 500ns/op,所以你的代码慢」——这是错的。基准测试结果只在同一台机器、同一时刻可对比。跨机器对比要除以基准频率或用相对值。

6. 过早优化

「不要过早优化」是老生常谈,但也是真知。先写正确的代码,跑通测试,再用基准测试找瓶颈,最后才优化。优化后必须有基准测试证明提升,否则就是「凭感觉」。

7. 没有清理 cache

bash
go test -bench=. -count=10

Go 会缓存测试结果,可能导致重复跑得到完全一样的数字。加上 -count=N 配合 benchstat 是安全做法,或显式 -clean-cache

bash
go clean -testcache
go test -bench=. -benchmem

十三、小结

本篇系统讲解了 Go 基准测试,核心要点:

  1. BenchmarkXxx(b *testing.B):基准测试函数,必须有 for i := 0; i < b.N; i++ 循环。
  2. b.N 自适应:框架自动调整 N,让测试跑约 1 秒,不要假设 N 是固定值。
  3. b.ResetTimer:丢弃 setup 时间,最常用。
  4. b.StopTimer/b.StartTimer:精细控制计时,但有额外开销,慎用。
  5. -benchmem:报告 B/op(字节数)与 allocs/op(对象数),优化内存的关键指标。
  6. b.RunParallel:并行基准,测多核性能 + 验证线程安全。
  7. b.Run:子基准,多规模对比,性能曲线。
  8. -bench 参数-benchtime-count-cpu-cpuprofile-memprofile
  9. benchstat:对比两次基准结果,自动标注显著性。
  10. 陷阱:编译器优化、N 太小、跨机器对比、过早优化。

下一篇我们将进入 Fuzz Testing——Go 1.18+ 引入的原生模糊测试能力,它能自动生成各种输入,发现单元测试难以覆盖的边界 bug。


基准测试不是炫技。它能给你两个真实价值:证明优化有效定位性能瓶颈。把这两件事做扎实,你的代码就会既有速度,又有底气。