需要预先了解:


1. 性能分析与调优

性能优化的第一条原则是先测量,再优化。凭直觉猜"哪里慢"十有八九猜错,而且容易把代码改得更复杂却没有任何收益。Go 工具链把测量能力做得很完整:testing 做基准、pprof 找热点、trace 看调度。


1.1 方法论

  1. 建立基线:用 testing.B 写基准测试,记录当前的 ns/op、B/op、allocs/op;
  2. 找到瓶颈:用 pprof 看 CPU/内存/阻塞的分布,而不是猜;
  3. 只改一个变量:一次改一处,改完重新测量;
  4. 验证收益:与基线对比,没有明显提升就回退;
  5. 防止退化:把关键基准保留在仓库里,配合 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 练习

  1. 给一段自己写的代码建立基准测试,用 -cpuprofile 找出最热的函数,尝试优化后再测一次。
  2. 用 pprof.Lookup("goroutine").WriteTo 观察一个泄漏的 goroutine,根据栈信息定位泄漏点并修复。
  3. 用 -benchmem 对比"在循环内拼接字符串"和"用 Builder 拼接"的 allocs/op,解释差异的来源。