Go 从 0 到精通 · 第 32 课:Benchmark 与性能测试

学习定位:这是整套 Go 教程的第 32 课,也是阶段五(并发与工程阶段)的第八课。
前置要求:已经完成第 31 课,掌握了 Go 的 testing 包、单元测试和表驱动测试。
本课目标:掌握 Go 基准测试的写法,理解 b.N 的工作机制,学会用 go test -bench 运行基准测试,能对比不同实现的性能差异,了解基准测试的常见误区。


1. 本课你要解决的核心问题

上一课你学会了测试——确认代码"对不对"。这一课解决另一个问题:代码"快不快"。

你写了两种字符串拼接方式,哪个更快?你把 map 换成了 slice,性能提升了多少?你优化了一个函数,到底快了还是慢了?

靠猜不行,得用数据说话。

Go 内置了基准测试(Benchmark)功能。跟单元测试一样,不需要装任何第三方工具,写一个函数、跑一条命令,就能得到精确的性能数据。

这一课讲清楚以下问题:

  • 基准测试函数怎么写
  • b.N 是什么,Go 怎么自动决定跑多少次
  • 怎么运行基准测试、怎么看结果
  • 怎么对比两种实现的性能差异
  • 怎么测内存分配
  • 基准测试有哪些常见的坑

2. 最简单的基准测试

2.1 被测函数

沿用上一课的 Reverse 函数:

1
2
3
4
5
6
7
8
9
10
// stringutil/stringutil.go
package stringutil

func Reverse(s string) string {
runes := []rune(s)
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
runes[i], runes[j] = runes[j], runes[i]
}
return string(runes)
}

2.2 写基准测试

stringutil_test.go 中加一个函数:

1
2
3
4
5
6
7
8
9
package stringutil

import "testing"

func BenchmarkReverse(b *testing.B) {
for i := 0; i < b.N; i++ {
Reverse("hello, world")
}
}

就这么简单。跟单元测试对比:

单元测试 基准测试
函数前缀 Test Benchmark
参数类型 *testing.T *testing.B
核心逻辑 断言结果对不对 循环 b.N 次测性能

2.3 运行基准测试

1
go test -bench .

输出:

1
2
3
4
5
6
7
goos: windows
goarch: amd64
pkg: stringutil
cpu: 12th Gen Intel(R) Core(TM) i7-12700H
BenchmarkReverse-20 8234567 145.2 ns/op
PASS
ok stringutil 1.345s

2.4 怎么读结果

1
2
3
4
5
6
BenchmarkReverse-20    8234567    145.2 ns/op
│ │ │ │
│ │ │ └── 每次操作耗时 145.2 纳秒
│ │ └── 总共运行了 8234567
│ └── 使用了 20 个 CPU 核心
└── 函数名

核心指标是 ns/op——每次操作耗时多少纳秒。这个数字越小,性能越好。


3. 理解 b.N

3.1 b.N 是什么

b.N 是 Go 自动决定的循环次数。你不需要手动设置。

Go 的做法是:先用一个小的 N 跑一遍(比如 1 次),看耗时够不够。不够就加大 N(10、100、1000…),直到总耗时达到一个稳定的基准(默认约 1 秒)。

1
2
3
4
5
6
7
8
9
10
func BenchmarkReverse(b *testing.B) {
// Go 自动调整 b.N
// 第一轮 b.N 可能是 1
// 第二轮可能是 100
// 第三轮可能是 10000
// ...直到总耗时足够稳定
for i := 0; i < b.N; i++ {
Reverse("hello, world")
}
}

3.2 为什么要这样

如果固定跑 100 次,对于一个耗时 1 微秒的函数来说太少了,误差很大。对于一个耗时 1 秒的函数来说太多了,要等好几分钟。

Go 自动调整 N,确保:

  • 耗时短的函数多跑几轮,减小误差
  • 耗时长的函数少跑几轮,节省时间

3.3 绝对不要这样写

1
2
3
4
5
6
// 大错特错!
func BenchmarkReverse(b *testing.B) {
for i := 0; i < 10000; i++ { // 不要写固定数字!
Reverse("hello, world")
}
}

如果你用固定数字而不是 b.N,Go 会认为"这个函数只需要跑一次",然后给你一个没有意义的结果。


4. 运行基准测试的常用命令

4.1 基本命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 运行当前包的所有基准测试(不运行单元测试)
go test -bench .

# 运行所有包的基准测试
go test -bench . ./...

# 运行指定名称的基准测试(支持正则)
go test -bench BenchmarkReverse
go test -bench "Benchmark(Reverse|Concat)"

# 同时显示内存分配信息
go test -bench . -benchmem

# 指定每个基准测试运行的时间(默认 1 秒)
go test -bench . -benchtime 3s

# 指定每个基准测试运行固定次数
go test -bench . -benchtime 100x

# 多次运行以获得更稳定的结果
go test -bench . -count 5

# 跳过单元测试(只跑基准测试)
go test -bench . -run ^$

4.2 最常用的组合

1
2
3
4
5
6
7
8
# 开发时对比性能:显示内存 + 跑 3 次取平均
go test -bench . -benchmem -count 3

# 只跑某个基准测试,排除单元测试干扰
go test -bench BenchmarkReverse -run ^$ -benchmem

# 跑久一点,结果更稳定
go test -bench . -benchtime 5s -benchmem

4.3 关于 -run ^$

go test -bench . 默认会同时运行单元测试。如果你只想跑基准测试,加 -run ^$(匹配一个不存在的测试名),这样单元测试全部被跳过。


5. 对比不同实现的性能

基准测试最有价值的场景就是对比

5.1 字符串拼接的三种方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package stringutil

import (
"fmt"
"strings"
)

// 方式 1:用 + 号拼接
func ConcatPlus(strs []string) string {
result := ""
for _, s := range strs {
result += s
}
return result
}

// 方式 2:用 fmt.Sprintf
func ConcatSprintf(strs []string) string {
result := ""
for _, s := range strs {
result = fmt.Sprintf("%s%s", result, s)
}
return result
}

// 方式 3:用 strings.Builder
func ConcatBuilder(strs []string) string {
var builder strings.Builder
for _, s := range strs {
builder.WriteString(s)
}
return builder.String()
}

// 方式 4:用 strings.Join
func ConcatJoin(strs []string) string {
return strings.Join(strs, "")
}

5.2 写基准测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
package stringutil

import (
"strings"
"testing"
)

// 准备测试数据
func makeStrings(n int) []string {
strs := make([]string, n)
for i := range strs {
strs[i] = "hello"
}
return strs
}

func BenchmarkConcatPlus(b *testing.B) {
strs := makeStrings(100)
b.ResetTimer() // 重置计时器,排除准备数据的时间

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

func BenchmarkConcatSprintf(b *testing.B) {
strs := makeStrings(100)
b.ResetTimer()

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

func BenchmarkConcatBuilder(b *testing.B) {
strs := makeStrings(100)
b.ResetTimer()

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

func BenchmarkConcatJoin(b *testing.B) {
strs := makeStrings(100)
b.ResetTimer()

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

5.3 运行并对比

1
go test -bench BenchmarkConcat -benchmem -run ^$

输出(示意):

1
2
3
4
BenchmarkConcatPlus-20       18534    64521 ns/op    53336 B/op    99 allocs/op
BenchmarkConcatSprintf-20 8245 145234 ns/op 106824 B/op 298 allocs/op
BenchmarkConcatBuilder-20 325678 3682 ns/op 2544 B/op 8 allocs/op
BenchmarkConcatJoin-20 412345 2916 ns/op 512 B/op 1 allocs/op

5.4 怎么读对比结果

1
2
3
4
5
                            次数      耗时         内存        分配次数
ConcatPlus 18534 64521 ns/op 53336 B/op 99 allocs/op
ConcatSprintf 8245 145234 ns/op 106824 B/op 298 allocs/op
ConcatBuilder 325678 3682 ns/op 2544 B/op 8 allocs/op
ConcatJoin 412345 2916 ns/op 512 B/op 1 allocs/op
指标 含义
ns/op 每次操作耗时(纳秒)。越小越快
B/op 每次操作分配的内存(字节)。越小越省内存
allocs/op 每次操作的内存分配次数。越少越好

结论一目了然:

  • strings.Join 最快,内存分配最少
  • strings.Builder 紧随其后
  • + 号拼接慢了 20 倍,内存分配多了 100 倍
  • fmt.Sprintf 最慢

这就是基准测试的价值——用数据说话,不靠猜测。


6. b.ResetTimer、b.StopTimer、b.StartTimer

6.1 b.ResetTimer()

用于排除准备工作的耗时。放在准备代码之后、循环之前:

1
2
3
4
5
6
7
8
9
10
func BenchmarkProcess(b *testing.B) {
// 准备工作:加载数据、初始化等
data := loadLargeFile() // 可能耗时 2 秒

b.ResetTimer() // 从这里开始计时

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

不加 b.ResetTimer(),2 秒的准备时间会被算进基准测试结果,导致数据不准。

6.2 b.StopTimer() 和 b.StartTimer()

用于在循环内部排除非测试代码的耗时

1
2
3
4
5
6
7
8
9
10
11
12
13
func BenchmarkInsert(b *testing.B) {
for i := 0; i < b.N; i++ {
b.StopTimer()
db := setupTestDB() // 每次循环都需要准备,但不想计入耗时
b.StartTimer()

db.Insert("key", "value") // 只测这一行的性能

b.StopTimer()
db.Cleanup() // 清理也不计入
b.StartTimer()
}
}

注意StopTimer/StartTimer 本身有开销。如果被测代码执行很快(几十纳秒),频繁调用它们会导致结果不准确。能用 b.ResetTimer() 解决的就别用 StopTimer/StartTimer

6.3 选择建议

场景 用什么
循环前有一次性准备工作 b.ResetTimer()
每次循环内部有准备/清理工作 b.StopTimer() + b.StartTimer()
没有额外准备工作 什么都不用加

7. 内存分配基准测试

7.1 用 -benchmem 看内存

1
go test -bench . -benchmem

输出中会多两列:

1
2
3
4
BenchmarkReverse-20    8234567    145.2 ns/op    48 B/op    2 allocs/op
│ │
│ └── 每次操作分配了 2 次内存
└── 每次操作分配了 48 字节

7.2 用 b.ReportAllocs()

也可以在代码中强制报告内存,不需要加 -benchmem 参数:

1
2
3
4
5
6
7
func BenchmarkReverse(b *testing.B) {
b.ReportAllocs() // 强制报告内存分配

for i := 0; i < b.N; i++ {
Reverse("hello, world")
}
}

7.3 为什么要关注内存分配

Go 的垃圾回收器(GC)需要处理所有分配的内存。分配越多:

  • GC 压力越大
  • 程序可能出现短暂停顿
  • 总体性能下降

很多性能优化的核心就是减少内存分配

7.4 实例:减少内存分配的优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 优化前:每次调用都分配新的 []rune
func ReverseV1(s string) string {
runes := []rune(s)
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
runes[i], runes[j] = runes[j], runes[i]
}
return string(runes)
}

// 优化后:纯 ASCII 字符串走快速路径,避免 []rune 转换
func ReverseV2(s string) string {
// 快速路径:纯 ASCII
isASCII := true
for i := 0; i < len(s); i++ {
if s[i] > 127 {
isASCII = false
break
}
}

if isASCII {
b := make([]byte, len(s))
for i, j := 0, len(s)-1; i <= j; i, j = i+1, j-1 {
b[i], b[j] = s[j], s[i]
}
return string(b)
}

// 慢速路径:有非 ASCII 字符
runes := []rune(s)
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
runes[i], runes[j] = runes[j], runes[i]
}
return string(runes)
}

基准测试对比:

1
2
3
4
5
6
7
8
9
10
11
12
13
func BenchmarkReverseV1(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
ReverseV1("hello, world!")
}
}

func BenchmarkReverseV2(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
ReverseV2("hello, world!")
}
}

结果(示意):

1
2
BenchmarkReverseV1-20    5432198    221.3 ns/op    80 B/op    2 allocs/op
BenchmarkReverseV2-20 9876543 121.5 ns/op 32 B/op 1 allocs/op

V2 对纯 ASCII 字符串快了近一倍,内存分配也少了一半。


8. 子基准测试

8.1 用 b.Run 测试不同规模

跟单元测试的 t.Run 一样,基准测试也支持 b.Run 创建子基准测试。最常见的用法是测试不同输入规模下的性能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func BenchmarkReverse(b *testing.B) {
sizes := []struct {
name string
input string
}{
{"短字符串", "hello"},
{"中等字符串", "hello, world! this is a benchmark test"},
{"长字符串", strings.Repeat("abcdefghij", 100)},
}

for _, s := range sizes {
b.Run(s.name, func(b *testing.B) {
for i := 0; i < b.N; i++ {
Reverse(s.input)
}
})
}
}

输出:

1
2
3
BenchmarkReverse/短字符串-20      12345678    97.3 ns/op
BenchmarkReverse/中等字符串-20 4567890 263.1 ns/op
BenchmarkReverse/长字符串-20 123456 9712.0 ns/op

一眼看出性能跟输入长度的关系。

8.2 用 b.Run 对比多种实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func BenchmarkConcat(b *testing.B) {
strs := makeStrings(100)

b.Run("Plus", func(b *testing.B) {
for i := 0; i < b.N; i++ {
ConcatPlus(strs)
}
})

b.Run("Builder", func(b *testing.B) {
for i := 0; i < b.N; i++ {
ConcatBuilder(strs)
}
})

b.Run("Join", func(b *testing.B) {
for i := 0; i < b.N; i++ {
ConcatJoin(strs)
}
})
}

输出:

1
2
3
BenchmarkConcat/Plus-20        18534    64521 ns/op
BenchmarkConcat/Builder-20 325678 3682 ns/op
BenchmarkConcat/Join-20 412345 2916 ns/op

8.3 综合:不同实现 × 不同规模

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func BenchmarkConcatMatrix(b *testing.B) {
sizes := []int{10, 100, 1000}

for _, size := range sizes {
strs := makeStrings(size)

b.Run(fmt.Sprintf("Plus/%d", size), func(b *testing.B) {
for i := 0; i < b.N; i++ {
ConcatPlus(strs)
}
})

b.Run(fmt.Sprintf("Builder/%d", size), func(b *testing.B) {
for i := 0; i < b.N; i++ {
ConcatBuilder(strs)
}
})

b.Run(fmt.Sprintf("Join/%d", size), func(b *testing.B) {
for i := 0; i < b.N; i++ {
ConcatJoin(strs)
}
})
}
}

输出(示意):

1
2
3
4
5
6
7
8
9
BenchmarkConcatMatrix/Plus/10-20       567890      2105 ns/op
BenchmarkConcatMatrix/Builder/10-20 2345678 511 ns/op
BenchmarkConcatMatrix/Join/10-20 3456789 345 ns/op
BenchmarkConcatMatrix/Plus/100-20 18534 64521 ns/op
BenchmarkConcatMatrix/Builder/100-20 325678 3682 ns/op
BenchmarkConcatMatrix/Join/100-20 412345 2916 ns/op
BenchmarkConcatMatrix/Plus/1000-20 1234 9654321 ns/op
BenchmarkConcatMatrix/Builder/1000-20 45678 26345 ns/op
BenchmarkConcatMatrix/Join/1000-20 56789 21234 ns/op

可以看到:+ 号拼接在数据量增大时性能急剧下降(O(n²)),而 BuilderJoin 基本线性增长。


9. 完整实战:map vs slice 查找性能

9.1 两种查找方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package lookup

// 在 slice 中线性查找
func SliceLookup(data []int, target int) bool {
for _, v := range data {
if v == target {
return true
}
}
return false
}

// 在 map 中直接查找
func MapLookup(data map[int]bool, target int) bool {
return data[target]
}

9.2 基准测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package lookup

import (
"testing"
)

// 准备 slice 数据
func makeSlice(n int) []int {
s := make([]int, n)
for i := range s {
s[i] = i
}
return s
}

// 准备 map 数据
func makeMap(n int) map[int]bool {
m := make(map[int]bool, n)
for i := 0; i < n; i++ {
m[i] = true
}
return m
}

func BenchmarkLookup(b *testing.B) {
sizes := []int{10, 100, 1000, 10000}

for _, size := range sizes {
sliceData := makeSlice(size)
mapData := makeMap(size)
target := size - 1 // 查找最后一个元素(最坏情况)

b.Run(fmt.Sprintf("Slice/%d", size), func(b *testing.B) {
for i := 0; i < b.N; i++ {
SliceLookup(sliceData, target)
}
})

b.Run(fmt.Sprintf("Map/%d", size), func(b *testing.B) {
for i := 0; i < b.N; i++ {
MapLookup(mapData, target)
}
})
}
}

9.3 预期结果分析

1
2
3
4
5
6
7
8
BenchmarkLookup/Slice/10-20       50000000     24 ns/op
BenchmarkLookup/Map/10-20 30000000 40 ns/op
BenchmarkLookup/Slice/100-20 5000000 288 ns/op
BenchmarkLookup/Map/100-20 25000000 45 ns/op
BenchmarkLookup/Slice/1000-20 500000 2876 ns/op
BenchmarkLookup/Map/1000-20 20000000 51 ns/op
BenchmarkLookup/Slice/10000-20 50000 28934 ns/op
BenchmarkLookup/Map/10000-20 18000000 58 ns/op

结论:

  • 数据量小(10 个以下):slice 比 map 快(没有 hash 计算的开销)
  • 数据量大(100+):map 远快于 slice(O(1) vs O(n))
  • 拐点大约在 20~30 个元素

这种数据对日常决策很有用:小集合用 slice,大集合用 map。


10. 常见坑总结

10.1 编译器优化掉了你的代码

这是基准测试中最常见也最隐蔽的坑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 坏:结果没有被使用,编译器可能直接优化掉整个函数调用
func BenchmarkReverse(b *testing.B) {
for i := 0; i < b.N; i++ {
Reverse("hello") // 返回值被丢弃,编译器可能跳过调用
}
}

// 好:用一个包级变量"消费"结果
var result string

func BenchmarkReverse(b *testing.B) {
var r string
for i := 0; i < b.N; i++ {
r = Reverse("hello")
}
result = r // 防止编译器优化
}

Go 编译器很聪明。如果它发现一个函数的返回值没被使用,在某些情况下会优化掉整个调用。把结果赋给包级变量可以阻止这种优化。

实际中,Go 目前的编译器在大多数情况下不会优化掉有副作用的函数调用。但养成这个习惯是好的,尤其是测试纯计算函数时。

10.2 循环内部做了太多不相关的事

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 坏:每次循环都重新准备数据
func BenchmarkProcess(b *testing.B) {
for i := 0; i < b.N; i++ {
data := loadData() // 这个耗时被算进去了!
Process(data)
}
}

// 好:数据准备放循环外面
func BenchmarkProcess(b *testing.B) {
data := loadData()
b.ResetTimer()

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

10.3 忘了 b.ResetTimer()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 坏:初始化耗时被计入
func BenchmarkDB(b *testing.B) {
db := connectDatabase() // 可能耗时 500ms
defer db.Close()
// 没有 ResetTimer,500ms 被算进结果

for i := 0; i < b.N; i++ {
db.Query("SELECT 1")
}
}

// 好
func BenchmarkDB(b *testing.B) {
db := connectDatabase()
defer db.Close()
b.ResetTimer() // 从这里开始计时

for i := 0; i < b.N; i++ {
db.Query("SELECT 1")
}
}

10.4 用固定数字代替 b.N

1
2
3
4
5
6
// 大错:不用 b.N
func BenchmarkReverse(b *testing.B) {
for i := 0; i < 10000; i++ {
Reverse("hello")
}
}

上面已经说过,不再重复。记住:循环条件永远是 i < b.N

10.5 基准测试结果不稳定

同一个基准测试,每次跑出来的结果可能不一样。这是正常的。减小波动的方法:

1
2
3
4
5
6
7
# 方法 1:多次运行取平均
go test -bench . -count 5

# 方法 2:增加运行时间
go test -bench . -benchtime 5s

# 方法 3:关闭不必要的后台程序,减少系统干扰

不要在做其他事情的电脑上跑基准测试。浏览器、音乐播放器、IDE 都会影响结果。

10.6 只看 ns/op,忽略 allocs/op

1
go test -bench . -benchmem

有时候两种实现的 ns/op 差不多,但 allocs/op 差很多。内存分配多的在高并发场景下会因 GC 压力变慢。永远加 -benchmem


11. 实用技巧

11.1 benchstat 工具

Go 官方提供了 benchstat 工具,用于统计分析基准测试结果:

1
2
3
4
5
6
7
8
9
10
11
# 安装
go install golang.org/x/perf/cmd/benchstat@latest

# 使用:先把结果保存到文件
go test -bench . -benchmem -count 5 > old.txt

# 优化代码后再跑一次
go test -bench . -benchmem -count 5 > new.txt

# 对比
benchstat old.txt new.txt

输出(示意):

1
2
3
4
5
6
7
8
name          old time/op    new time/op    delta
Reverse-20 145ns ± 2% 122ns ± 1% -15.86% (p=0.008 n=5+5)

name old alloc/op new alloc/op delta
Reverse-20 48.0B ± 0% 32.0B ± 0% -33.33% (p=0.008 n=5+5)

name old allocs/op new allocs/op delta
Reverse-20 2.00 ± 0% 1.00 ± 0% -50.00% (p=0.008 n=5+5)

benchstat 会计算标准差、p 值,告诉你性能变化是否统计显著±2% 是波动范围,p=0.008 说明变化是真实的(不是噪音)。

11.2 b.ReportMetric() 自定义指标

1
2
3
4
5
6
7
8
9
10
11
12
13
func BenchmarkSort(b *testing.B) {
data := makeRandomSlice(10000)
b.ResetTimer()

for i := 0; i < b.N; i++ {
sorted := make([]int, len(data))
copy(sorted, data)
sort.Ints(sorted)
}

// 报告自定义指标
b.ReportMetric(float64(len(data)), "items/op")
}

输出会多一列 10000 items/op,让你知道每次操作处理了多少数据。

11.3 testing.Short() 跳过慢的基准测试

1
2
3
4
5
6
func BenchmarkExpensive(b *testing.B) {
if testing.Short() {
b.Skip("跳过耗时基准测试")
}
// ...
}
1
go test -bench . -short  # 跳过标记了 Short 的基准测试

12. 本课练习

练习 1:切片追加的性能

对比以下三种构建切片的方式:

  1. 直接 append(不预分配容量)
  2. make([]int, 0, n) 预分配容量后 append
  3. make([]int, n) 后用索引赋值

用基准测试对比它们在 n=100、n=1000、n=10000 时的性能差异。


练习 2:map 初始化的影响

对比以下两种 map 使用方式:

  1. make(map[string]int) 不指定容量
  2. make(map[string]int, n) 预分配容量

向 map 中插入 1000 个键值对,用基准测试对比性能和内存分配。


练习 3:字符串查找

对比以下几种判断字符串是否包含子串的方式:

  1. strings.Contains
  2. strings.Index >= 0
  3. 手写循环匹配

在短字符串(20 字符)和长字符串(10000 字符)上分别测试。


练习 4:JSON 序列化

创建一个包含 10 个字段的结构体,用基准测试对比:

  1. json.Marshal
  2. 手动拼接 JSON 字符串

测量性能差异和内存分配差异。


练习 5:完整的优化流程

  1. 写一个函数:接收一个字符串切片,返回所有字符串的去重结果
  2. 先写最简单的实现(两层循环)
  3. 再写优化实现(用 map 去重)
  4. 用基准测试对比两种实现
  5. benchstat 生成对比报告

13. 自测题

13.1 概念题

  1. 基准测试函数的命名规则和参数类型是什么?
  2. b.N 是什么?为什么不能用固定数字代替它?
  3. b.ResetTimer() 的作用是什么?什么时候需要用?
  4. -benchmem 参数输出的三列数据分别是什么意思?
  5. 为什么说基准测试中编译器优化是一个坑?怎么避免?
  6. b.Run 创建子基准测试有什么好处?
  7. 什么时候应该用 b.StopTimer/b.StartTimer?有什么注意事项?
  8. 为什么基准测试结果每次都不完全一样?怎么让结果更可靠?

13.2 代码阅读题

以下基准测试有两个问题,找出来:

1
2
3
4
5
6
7
8
func BenchmarkProcess(b *testing.B) {
data := loadLargeDataset() // 耗时 3 秒

for i := 0; i < 10000; i++ {
result := Process(data)
fmt.Println(result)
}
}
点击查看答案

问题 1:用了固定数字 10000 而不是 b.N。循环条件应该是 i < b.N,否则 Go 无法正确测量每次操作的耗时。

问题 2:没有用 b.ResetTimer()loadLargeDataset() 耗时 3 秒,这个时间会被计入基准测试结果,导致 ns/op 严重偏高。

还有一个额外问题:fmt.Println(result) 会做 I/O 操作,这个耗时也被算进去了。如果目的是防止编译器优化,应该赋给包级变量,而不是打印。

修复后:

1
2
3
4
5
6
7
8
9
10
11
12
var benchResult int

func BenchmarkProcess(b *testing.B) {
data := loadLargeDataset()
b.ResetTimer()

var r int
for i := 0; i < b.N; i++ {
r = Process(data)
}
benchResult = r
}

14. 本课总结

这一课你学到了 Go 基准测试的核心内容。

知识点 要点
基本写法 BenchmarkXxx(b *testing.B),循环 b.N
运行命令 go test -bench . -benchmem
核心指标 ns/op(耗时)、B/op(内存)、allocs/op(分配次数)
计时控制 b.ResetTimer()b.StopTimer()b.StartTimer()
子基准测试 b.Run 对比不同规模、不同实现
分析工具 benchstat 统计对比、-count 多次运行

最重要的三件事:

  1. 循环条件永远是 i < b.N——Go 自动调整 N 来获得稳定结果
  2. 永远加 -benchmem——内存分配往往比执行时间更能说明问题
  3. 优化前先跑基准测试,优化后再跑一次对比——用数据驱动决策,不靠猜测

15. 下一课预告

到这里你已经会写测试、会跑基准测试了。下一步该学怎么组织一个完整的 Go 项目。

下一课:项目结构与工程组织

会重点讲:

  • Go 项目的常见目录结构
  • 包怎么拆分才合理
  • 配置、日志和错误包装的标准做法
  • internal 目录的用途
  • 从零搭建一个结构清晰的小型工程项目

学完下一课,你就不再是"能写 Go 代码",而是"能组织 Go 项目"了。