性能分析与调优
需要预先了解:
1. 性能分析与调优
性能优化的第一条原则是先测量,再优化。凭直觉猜"哪里慢"十有八九猜错,而且容易把代码改得更复杂却没有任何收益。Go 工具链把测量能力做得很完整:testing 做基准、pprof 找热点、trace 看调度。
1.1 方法论
- 建立基线:用
testing.B写基准测试,记录当前的ns/op、B/op、allocs/op; - 找到瓶颈:用 pprof 看 CPU/内存/阻塞的分布,而不是猜;
- 只改一个变量:一次改一处,改完重新测量;
- 验证收益:与基线对比,没有明显提升就回退;
- 防止退化:把关键基准保留在仓库里,配合 CI 定期跑。
优化的收益通常遵循帕累托分布:少数几处热点贡献了大部分开销。先解决它们,再考虑细节。
1.2 采集 profile
文件式采集(runtime/pprof)
在程序里显式启动:
f, _ := os.Create("cpu.prof")
pprof.StartCPUProfile(f)
defer pprof.StopCPUProfile()
// 运行待分析的代码
测试式采集
go test 直接支持:
go test -bench=BenchmarkBuildSlow -cpuprofile=cpu.out -benchtime=1s -run=^$
go test -bench=BenchmarkCollectSlow -memprofile=mem.out -benchtime=1s -run=^$
go test -trace=trace.out -bench=BenchmarkBuildSlow
服务式采集(net/http/pprof)
线上服务最常见的方式——导入这个包就会注册一组 HTTP 端点:
import _ "net/http/pprof"
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
| 端点 | 内容 |
|---|---|
/debug/pprof/profile?seconds=30 |
CPU profile(默认 30 秒) |
/debug/pprof/heap |
堆内存 profile |
/debug/pprof/goroutine |
goroutine profile |
/debug/pprof/block |
阻塞 profile |
/debug/pprof/mutex |
锁竞争 profile |
/debug/pprof/trace |
执行轨迹 |
注意:这些端点会暴露大量内部信息,不要在生产环境对公网开放。用内网地址或加认证。
1.3 go tool pprof
交互式使用:
go tool pprof cpu.out
常用命令:
| 命令 | 作用 |
|---|---|
top |
按开销排序,显示前若干个函数 |
top -cum |
按累积开销排序(包含子调用) |
list F |
显示函数 F 的逐行开销 |
peek F |
显示调用 F 的上下游 |
web |
生成调用图并在浏览器打开(需要 graphviz) |
-http=:8080 |
启动 Web UI,含火焰图 |
火焰图的读法:横轴是累计耗时(不是时间顺序),越宽的框代表占用 CPU 越多;纵轴是调用栈,越深代表调用层级越深。找最宽的那几个框,就是优化目标。
-list 能把开销定位到行:
go tool pprof -list=BuildSlow cpu.out
ROUTINE ======================== note27/perf.BuildSlow in .../perf.go
10ms 770ms (flat, cum) 34.38% of Total
. . 29:func BuildSlow(items []Item) string {
. . 30: out := ""
10ms 10ms 31: for _, it := range items {
. 760ms 32: out += it.Name + "," + strconv.Itoa(it.ID) + ";"
. . 33: }
. . 34: return out
. . 35:}
flat 是函数自身耗时,cum 是包含子调用的累计耗时。这里 760ms 都花在第 32 行的字符串拼接上——目标非常明确。
1.4 四类核心 profile
CPU profile
回答"CPU 时间花在哪"。典型输出:
flat flat% sum% cum cum%
0.36s 16.07% 16.07% 0.36s 16.07% runtime.memmove
0.19s 8.48% 24.55% 0.23s 10.27% runtime.preemptM
0.12s 5.36% 36.61% 0.34s 15.18% runtime.lock2
0.10s 4.46% 41.07% 0.27s 12.05% runtime.scanObject
runtime.memmove、runtime.mallocgc、runtime.gcBgMarkWorker 这类运行时函数占比高,通常说明分配太多,问题在业务代码里,不在运行时。
heap profile
回答"内存花在哪",有两个视角:
go tool pprof -sample_index=alloc_space mem.out # 累计分配
go tool pprof -sample_index=inuse_space mem.out # 当前占用
Type: alloc_space
6.16GB 99.94% 99.94% 6.16GB 99.94% note27/perf.CollectSlow (inline)
Type: inuse_space
2059.04kB 100% 100% 2059.04kB 100% runtime.mallocgc
alloc_space高:分配量大,GC 压力大,看"谁在频繁分配";inuse_space高:对象没被释放,看"谁在长期持有内存"(泄漏)。
goroutine profile
排查泄漏的首选:
pprof.Lookup("goroutine").WriteTo(os.Stdout, 1)
goroutine profile: total 4
3 @ 0x7ff77ad8c82a 0x7ff77ad21e8e ...
# 0x7ff77adf89d8 main.main.func1+0x18 .../main.go:17
3 @ ... 表示有 3 个 goroutine 停在同一个位置,栈信息直接指向泄漏点。线上通过 /debug/pprof/goroutine?debug=1 就能看到。
block 与 mutex profile
这两个默认不开启,需要显式设置采样率:
runtime.SetBlockProfileRate(1) // 采样所有阻塞事件
runtime.SetMutexProfileFraction(1) // 采样所有锁竞争
--- block profile ---
49310681 1 @ ...
# sync.(*WaitGroup).Wait+0x84 sync/waitgroup.go:206
--- mutex profile ---
44612371 2 @ ...
# sync.(*Mutex).Unlock+0x71 sync/mutex.go:65
block profile 记录"goroutine 等了多久"(channel、锁、time.Sleep、网络),mutex profile 记录"锁竞争多严重"。高并发服务里这两个 profile 往往比 CPU profile 更能反映问题。
1.5 go tool trace
trace 记录的是事件流:goroutine 的创建、阻塞、唤醒,GC 的开始结束,系统调用等。它回答的是 CPU profile 回答不了的问题——“为什么 CPU 没跑满但延迟很高”。
go tool trace trace.out # 打开 Web UI
go tool trace -d=footprint trace.out # 只打印统计信息
Event Bytes % Count %
HeapAlloc 1936614 70.12% 322366 64.10%
ProcStart 143009 5.18% 24467 4.86%
GoStart 123061 4.46% 24268 4.83%
GCSweepEnd 91139 3.30% 12111 2.41%
GCMarkAssistBegin 17646 0.64% 5870 1.17%
Web UI 里能看 goroutine 的时间线:哪段时间在运行、哪段在等待、被谁唤醒。GC 和 STW 事件也标在时间轴上,配合 调度器:GMP 模型深入 一起看。
1.6 优化手法清单
下面每一条都有对应的基准数据支撑。
1. 字符串拼接用 Builder
BenchmarkBuildSlow-24 370 1078059 ns/op 6675856 B/op 1900 allocs/op
BenchmarkBuildFast-24 14862 30201 ns/op 23360 B/op 901 allocs/op
35 倍差距、内存从 6.6MB 降到 23KB。循环里的字符串拼接一律用 strings.Builder + Grow(见 切片、map 与字符串的底层实现)。
2. 切片预分配容量
BenchmarkCollectSlow-24 58966 6512 ns/op 35184 B/op 11 allocs/op
BenchmarkCollectFast-24 139482 2944 ns/op 16384 B/op 1 allocs/op
2.2 倍差距,分配次数从 11 降到 1。知道元素个数时用 make([]T, 0, n)。
3. 减少逃逸
BenchmarkReturnPtr-24 44806212 7.685 ns/op 16 B/op 1 allocs/op
BenchmarkReturnValue-24 794514319 0.4424 ns/op 0 B/op 0 allocs/op
小对象返回值而不是返回指针,可以完全留在栈上(见 内存分配与逃逸分析)。用 -gcflags='-m' 验证。
4. 大结构体的传递方式
BenchmarkSumByValue-24 839727 436.5 ns/op 0 B/op 0 allocs/op
BenchmarkSumByPointer-24 912754 399.6 ns/op 0 B/op 0 allocs/op
这里差别只有 8%,因为 range 对切片元素本身就是按需取值,没有整片拷贝。不要凭"结构体大所以传指针一定快"来优化——传指针会让对象逃逸,反而可能更慢。用 benchmark 说话。
5. 复用对象:sync.Pool
频繁创建销毁临时对象(如缓冲区)时,用 sync.Pool 复用(见 sync 包与原子操作)。注意 GC 会清空池,所以它只适合"临时对象回收站",不能当缓存。
6. 避免不必要的 []byte ↔ string 转换
BenchmarkStringToBytes-24 12358393 18.92 ns/op 40 B/op 2 allocs/op
一次转换 2 次分配。在循环里反复转换会迅速放大开销——把转换提到循环外,或用 bytes.Buffer。
7. 调整 GC 参数
GOGC 调大可以降低 GC 频率(用内存换 CPU),调小则相反。容器里用 GOMEMLIMIT 让 GC 贴合内存上限(见 垃圾回收)。但减少对象数量永远比调参数更有效。
8. 减少锁竞争
用 -mutexprofile 找到竞争最严重的锁;把临界区缩到最小;读多写少换 RWMutex;能改成每 goroutine 独立数据就别共享。
1.7 完整案例
从发现到修复的全过程:
1. 建立基线
BenchmarkBuildSlow-24 370 1078059 ns/op 6675856 B/op 1900 allocs/op
2. 采集 CPU profile
go test -bench=BenchmarkBuildSlow -cpuprofile=cpu.out -benchtime=1s -run=^$
go tool pprof -list=BuildSlow cpu.out
定位到第 32 行的字符串拼接占 760ms。
3. 定位原因
out += ... 每次都分配一块新内存并复制旧内容——1900 次分配、6.6MB 内存就是证据。
4. 修改实现
换成 strings.Builder 并预分配容量。
5. 复测
BenchmarkBuildFast-24 14862 30201 ns/op 23360 B/op 901 allocs/op
耗时降到 1/35,内存降到 1/285。这次优化只改了一个函数,收益明确、风险可控。
1.8 runtime 常用调试 API
| 函数 | 作用 |
|---|---|
runtime.NumGoroutine() |
当前 goroutine 数量 |
runtime.GOMAXPROCS(0) |
查询 P 的数量 |
runtime.ReadMemStats(&m) |
读取内存统计 |
runtime.GC() |
手动触发 GC |
runtime.SetBlockProfileRate(n) |
开启阻塞采样 |
runtime.SetMutexProfileFraction(n) |
开启锁竞争采样 |
debug.SetGCPercent(n) |
运行时修改 GOGC 百分比 |
debug.SetMemoryLimit(n) |
运行时修改内存软上限(Go 1.19+) |
runtime/pprof 负责采集,runtime/debug 负责动态调整参数——线上服务可以暴露一个受保护的调试端点,在需要时临时开启采样。
1.9 练习
- 给一段自己写的代码建立基准测试,用
-cpuprofile找出最热的函数,尝试优化后再测一次。 - 用
pprof.Lookup("goroutine").WriteTo观察一个泄漏的 goroutine,根据栈信息定位泄漏点并修复。 - 用
-benchmem对比"在循环内拼接字符串"和"用 Builder 拼接"的allocs/op,解释差异的来源。



