pprof — 性能剖析与诊断
runtime/pprof 把 Go 运行时的剖析数据写成 pprof 可视化工具能识别的格式,是排查 CPU 热点、内存分配、goroutine 泄漏、锁竞争时最顺手的工具——全部内置于标准库,不需要任何第三方依赖。
本文覆盖:profile 的获取入口(HTTP 端点 / go test / 手工采集)、Go 1.27 正式化的 goroutineleak、Go 1.25 引入的 FlightRecorder(生产常驻 trace 环形缓冲)、pprof.Do/Labels 标签机制,以及 go tool pprof 速查与陷阱汇总。
基线:go1.27.0 windows/amd64(GOROOT
G:\Need\Go\golang)。文中"实测"均在本机验证;平台差异会明确标注。
1. Go 内置了哪些 profile
runtime/pprof 包文档的 Profile 类型注释列出了全部预定义 profile。实测 pprof.Profiles() 返回(按名称排序):
allocs block goroutine goroutineleak heap mutex threadcreate| Profile | 记录内容(包文档措辞) | 获取前提 |
|---|---|---|
goroutine |
当前所有 goroutine 的栈 | 始终可用 |
goroutineleak |
所有"泄漏的" goroutine 的栈(Go 1.27 正式,见第 3 节) | 始终可用 |
allocs |
对历史上所有内存分配的采样 | 始终可用 |
heap |
对当前存活对象的内存分配采样 | 始终可用 |
threadcreate |
导致创建新 OS 线程的栈 | 始终可用 |
block |
导致在同步原语上阻塞的栈 | 默认关闭,需 runtime.SetBlockProfileRate |
mutex |
持有争用互斥锁者的栈 | 默认关闭,需 runtime.SetMutexProfileFraction |
注意
net/http/pprof的包文档示例特意写了 “after callingruntime.SetBlockProfileRatein your program”——block 和 mutex 这两个 profile 不开采就是全零,这是新手的头号困惑来源。
几个容易被误解的语义,均来自 runtime/pprof 包文档原文:
- CPU profile 不是
Profile。包文档:“The CPU profile is not available as a Profile. It has a special API, theStartCPUProfileandStopCPUProfilefunctions, because it streams output to a writer during profiling."——CPU profile 边跑边流式写出,其余 profile 是"拍快照”。 - heap 不是"此刻"的堆。包文档:“The heap profile reports statistics as of the most recently completed garbage collection; it elides more recent allocation to avoid skewing the profile away from live data and toward garbage."——它反映的是上一次 GC 结束时的状态。分析时
-inuse_space(当前存活,默认)看泄漏、-alloc_space(历史累计)看分配热点。 - allocs 与 heap 数据相同,只是 pprof 默认展示索引不同(
-alloc_spacevs-inuse_space)。 - mutex profile 的栈归属反直觉:报告的是持锁者 Unlock 处的栈而非等锁者;持锁 1s、5 个 goroutine 等满 1s 记 5s 争用。
2. 三种获取入口
2.1 net/http/pprof:HTTP 端点(生产服务首选)
package main
import (
"log"
"net/http"
_ "net/http/pprof" // 仅 import,副作用注册 /debug/pprof/ 下所有 handler
)
func main() {
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
// ... 业务代码 ...
}导入后自动提供第 1 节全部 profile,外加包文档列出的 Cmdline、Profile(CPU)、Symbol、Trace 四个端点。常用 URL:
http://localhost:6060/debug/pprof/profile?seconds=30 # 30 秒 CPU profile
http://localhost:6060/debug/pprof/heap?debug=1 # 堆快照(文本)
http://localhost:6060/debug/pprof/goroutine?debug=1 # goroutine 汇总栈
http://localhost:6060/debug/pprof/goroutine?debug=2 # 逐个 goroutine 栈(panic 风格)
http://localhost:6060/debug/pprof/goroutineleak?debug=1 # 泄漏 goroutine(1.27)
http://localhost:6060/debug/pprof/block # 阻塞事件(需开启采集)
http://localhost:6060/debug/pprof/mutex # 锁争用(需开启采集)包文档给出的查询参数:debug=N(N=0 二进制 protobuf,N>0 文本);gc=N(仅 heap,N>0 先跑一次 GC 再采样);seconds=N 对 allocs/block/goroutine/heap/mutex/threadcreate 返回增量(delta)profile,对 profile(CPU)与 trace 表示采集时长。Go 1.22 起所有端点必须用 GET。实测(本机起服务后 curl):
$ curl http://127.0.0.1:16061/debug/pprof/goroutineleak?debug=1
goroutineleak profile: total 0注意 这些端点会暴露完整调用栈和内存线索,绝不对公网暴露。放独立内部端口、加防火墙或反向代理鉴权,是上线检查项。
2.2 go test 基准:-cpuprofile / -memprofile
runtime/pprof 包文档原文:基准的剖析支持"built into go test",直接:
go test -cpuprofile cpu.prof -memprofile mem.prof -bench .以一段真实基准为例(实测通过):
package probe
import "testing"
func BenchmarkAppend(b *testing.B) {
s := make([]byte, 0, 64)
for i := 0; i < b.N; i++ {
s = append(s, byte(i))
if len(s) == 64 {
s = s[:0]
}
}
}实测输出(i7-10750H,windows/amd64):
BenchmarkAppend-12 1000000000 0.9484 ns/op
$ go tool pprof -top cpu.prof # Duration: 1.05s, Total samples = 1.03s (98.07%)
1.02s 99.03% 100% probe.BenchmarkAppend-memprofile 写出的文件与 pprof.Lookup("allocs") 同源(包文档:“Lookup(“allocs”) creates a profile similar to go test -memprofile")。基准的更多参数(-benchtime、-count)见 ./testing.md。
2.3 runtime/pprof.StartCPUProfile:手工采集
非测试的独立程序按包文档推荐的方式接入 flag 手工采集,以下完整程序可直接运行:
package main
import (
"flag"
"log"
"os"
"runtime/pprof"
)
var cpuprofile = flag.String("cpuprofile", "", "write cpu profile to file")
func main() {
flag.Parse()
if *cpuprofile != "" {
f, err := os.Create(*cpuprofile)
if err != nil {
log.Fatal("could not create CPU profile: ", err)
}
defer f.Close()
if err := pprof.StartCPUProfile(f); err != nil {
log.Fatal("could not start CPU profile: ", err)
}
defer pprof.StopCPUProfile() // 必须执行:Stop 之前数据尚未写完
}
// ... 程序主体 ... 堆快照按包文档示例:先 runtime.GC(),
// 再 pprof.Lookup("allocs").WriteTo(f, 0)(与 -memprofile 同风格;
// 换成 Lookup("heap") 则默认按 inuse_space 展示)
}三个关键点:已有采集进行中时 StartCPUProfile 返回错误(包文档:“If another goroutine has started CPU profiling, the function returns an error”);StopCPUProfile 阻塞等待当前采样写完,退出前必须执行,os.Exit 会跳过 defer;堆快照前建议先 runtime.GC(),否则读到的是旧一轮 GC 的视图(呼应第 1 节)。
3. goroutineleak:让 GC 帮你找泄漏的 goroutine(Go 1.27 正式)
设计意图与版本脉络
goroutine 泄漏是长跑服务最常见的慢性病:翻 goroutine profile 时,成千上万条栈里"正常的等待"和"永远醒不过来的等待"混在一起,人工甄别很痛苦。Go 1.26 以实验形式(GOEXPERIMENT=goroutineleakprofile)引入泄漏剖析,Go 1.27 转正式。1.27 release notes 原文:
“A new profile type that reports leaked goroutines, previously available as an experiment in Go 1.26, is now generally available. The new profile type, named
goroutineleak, is supported in theruntime/pprofpackage. It is also available as thenet/http/pprofendpoint/debug/pprof/goroutineleak.”
notes 同时说明 “The goroutineleakprofile GOEXPERIMENT setting is now deleted”——1.27 起无需任何构建开关。
检测原理:可达性,不是快照差分
1.27 notes 对"泄漏"的定义与检测机制:
“A ’leaked’ goroutine is a goroutine blocked on some concurrency primitive (channels,
sync.Mutex,sync.Cond, etc) that cannot possibly become unblocked. The runtime detects leaked goroutines using the garbage collector: if a goroutine G is blocked on concurrency primitive P, and P is unreachable from any runnable goroutine or any goroutine that those could unblock, then P cannot be unblocked, so goroutine G can never wake up.”
也就是说,它不是对比前后两次 goroutine 快照做差分,而是借 GC 的可达性分析判断"阻塞点是否还有被唤醒的理论可能”:被阻塞的 goroutine 自身不在"可运行集合"里,它独占持有的 channel/锁自然也不可达。
notes 同时标注能力边界(原文摘录):“it is impossible to detect permanently blocked goroutines in all cases, this approach detects a large class of such leaks”;对经全局变量或可运行 goroutine 局部变量可达的阻塞原语,可能漏报。
1.26 notes 给出的真实案例
notes 示例来自一次 early-return 后没有排空 channel 的并行聚合:
// result 是携带 workResult 与 error 的结构体(notes 原文示例,定义略)
func processWorkItems(ws []workItem) ([]workResult, error) {
ch := make(chan result)
for _, w := range ws {
go func() {
res, err := processWorkItem(w)
ch <- result{res, err} // 若无人再收,这些 goroutine 永远阻塞
}()
}
for range ws {
r := <-ch
if r.err != nil {
return nil, r.err // 提前返回:剩余 worker 全部泄漏
}
}
return nil, nil
}ch 只被这些 goroutine 自己引用,提前返回后不再可达,所有卡在发送上的 worker 都会被 goroutineleak profile 点名。
用法与实测
用法即 pprof.Lookup("goroutineleak").WriteTo(w, debug)(完整示例见第 5 节)。实测(构造上述模式:一个 goroutine 在 pprof.Do 内启动并阻塞在自建 channel;另一个阻塞在 main 仍持有的 channel 上,非泄漏):
Profiles(): allocs block goroutine goroutineleak heap mutex threadcreate
Lookup==nil? false
goroutineleak profile: total 1
# labels: {"handler":"/orders", "tenant":"acme"}
# 0x... main.blockedLeak+0x24 E:/tmp/probe-sec06/pprof/1/main.go:16被 main 持有 channel 的 goroutine 没有出现在泄漏列表里——与"可达即未泄漏"的原理一致。判定依赖最近一轮 GC 的可达性分析,且实测 Count() 要等 WriteTo 生成快照才会重算泄漏集(见第 7 节陷阱 13)。线上直接用 go tool pprof http://.../debug/pprof/goroutineleak,文本视图适合快速肉眼看。
4. FlightRecorder:生产常驻的 trace 环形缓冲(Go 1.25)
设计意图
执行 trace 信息量极大,但持续写盘又重又占空间——1.25 release notes 的结论是它 “generally impractical for debugging rare events”(体积与写盘开销让全量 trace 基本不适合排查罕见事件)。
runtime/trace.FlightRecorder 解决的是"罕见事件发生之前的现场已经没了”:像飞机黑匣子一样,运行期间把 trace 持续录进内存环形缓冲,只保留最近一小段,出事时落盘快照。notes 原文:“The new runtime/trace.FlightRecorder API provides a lightweight way to capture a runtime execution trace by continuously recording the trace into an in-memory ring buffer. When a significant event occurs, a program can call FlightRecorder.WriteTo to snapshot the last few seconds of the trace to a file.”
API 与关键约束
API 面(go doc runtime/trace.FlightRecorder 实测):NewFlightRecorder(FlightRecorderConfig) *FlightRecorder、Start() error、WriteTo(w io.Writer) (int64, error)、Stop()、Enabled() bool。注意快照方法叫 WriteTo,不是 WriteTrace——标准库里不存在后者。
配置两个字段(包文档措辞):MinAge time.Duration 是窗口内事件保留时间下限,运行时"strive to promptly discard events older than the minimum age"但不保证严格裁剪,为 0 时取实现定义值、“can be assumed to be on the order of seconds”;MaxBytes uint64 是窗口字节上限,“This setting takes precedence over MinAge”,且对实际写出大小与内存开销"does not make any guarantees … Treat it as a hint"。
约束(均见包文档):同时只允许一个 flight recorder 活跃,但 flight recording 可与 trace.Start 的消费者并存——实测 FR 活跃期间 trace.Start 依然成功返回。WriteTo 同一时刻只允许一个 goroutine 执行;Stop 会等并发的 WriteTo 完成。实测 fr.Stop() 后再 WriteTo 报 “cannot snapshot a disabled flight recorder”。
完整可运行示例(实测通过)
package main
import (
"fmt"
"os"
"runtime/trace"
"time"
)
func main() {
fr := trace.NewFlightRecorder(trace.FlightRecorderConfig{
MinAge: 2 * time.Second, // 尽力保留最近 2 秒
MaxBytes: 1 << 20, // 窗口上限 ~1 MiB(提示值)
})
if err := fr.Start(); err != nil {
fmt.Println("Start err:", err)
os.Exit(1)
}
defer fr.Stop()
for i := 0; i < 50; i++ { // 业务跑约 1 秒,维持 trace 数据流
time.Sleep(20 * time.Millisecond)
}
f, _ := os.Create("trace.out") // 出事路径(告警/慢请求/panic 恢复)里调用
n, err := fr.WriteTo(f)
fmt.Printf("WriteTo: n=%d err=%v\n", n, err)
f.Close()
// 之后用 go tool trace trace.out 分析
}实测输出(go1.27.0 windows/amd64):WriteTo: n=12365 err=<nil>——一秒多的业务就录下 12KB 可回放 trace。
与 trace.Start 的取舍
常驻 trace.Start 到磁盘:全量、可完整回放,代价是持续 IO 和大文件,适合短时复现问题;FlightRecorder:常驻内存窗口、开销低,但只有"最近几秒",适合偶发慢请求、罕见调度毛刺——触发瞬间 WriteTo 落盘的就是事发现场。两者不互斥(实测可并存):先常驻 FR,深挖时再临时起 trace.Start。FR 的窗口受 MaxBytes 提示约束,别指望它替代全量 trace 做长时段分析。
5. pprof.Do 与 pprof.Labels:给 goroutine 打标签
API
pprof.Labels("k1", "v1", "k2", "v2") // 构造 LabelSet(偶数个参数)
pprof.WithLabels(ctx, labels) // 把标签放进 context(不改变当前 goroutine)
pprof.Do(ctx, labels, func(ctx) { ... }) // 动态作用域:f 执行期间给当前 goroutine 设标签
pprof.SetGoroutineLabels(ctx) // 底层 API,把当前 goroutine 标签设为 ctx 里的
pprof.ForLabels(ctx, func(k, v string) bool) // 从 ctx 读标签Labels 包文档原文:“A label overwrites a prior label with the same key. Currently only the CPU and goroutine profiles utilize any labels information."——只有 CPU 和 goroutine(含 goroutineleak)两类 profile 带标签,别指望 block/mutex 里出现它。Do 的语义:f 执行期间挂上标签(同 key 覆盖、按参数顺序叠加),f 返回时恢复原标签集。
标签被子 goroutine 继承(实测修正)
SetGoroutineLabels 包文档原文:”A new goroutine inherits the labels of the goroutine that created it." Do 的文档同样写 “Goroutines spawned while executing f will inherit the augmented label-set”。实测:在 Do 里 go 出来的 goroutine,在 goroutineleak 文本视图里直接带着标签行:
# labels: {"handler":"/orders", "tenant":"acme"}所以"标签不会被子 goroutine 继承"是过时/错误的说法——1.27 的行为是创建时刻继承。但有两个真实限制:
- 继承只发生在创建那一刻。常驻 worker pool 在打标签代码之前就建好了,它的 goroutine 永远不带标签;要让 worker 带上当前请求的标签,需把带标签的 ctx 传进去,并在 worker 函数内部显式调用
pprof.SetGoroutineLabels(ctx)。 - 标签是创建时的快照,之后父 goroutine 改标签,子 goroutine 不会跟着变。
小例子(实测输出见注释)
package main
import (
"bytes"
"context"
"fmt"
"runtime/pprof"
"time"
)
func handle(orderID string) {
pprof.Do(context.Background(), pprof.Labels("handler", "/orders"),
func(ctx context.Context) {
go slowAudit(orderID) // 子 goroutine 继承 handler=/orders
time.Sleep(time.Millisecond)
})
}
func slowAudit(orderID string) {
c := make(chan struct{})
<-c // 永远阻塞,制造一个可被 goroutineleak 点名的泄漏
}
func main() {
handle("A123")
time.Sleep(100 * time.Millisecond)
gl := pprof.Lookup("goroutineleak")
fmt.Println(gl.Count()) // 实测输出 0:WriteTo 之前不重算泄漏集
var buf bytes.Buffer
gl.WriteTo(&buf, 1) // 生成快照同时重算
fmt.Println(gl.Count()) // 实测输出 1
fmt.Print(buf.String())
}在分析工具里看标签
goroutine/goroutineleak的debug=1文本视图:标签行形如# labels: {"handler":"/orders"}(实测格式)。go tool pprof:标签作为 tag 呈现,用-tagfocus过滤。实测go tool pprof -h该项帮助为 “Restricts to samples with tags in range or matched by regexp”,如go tool pprof -tagfocus='handler=/orders' cpu.pprof(正则匹配 tag 字符串)。- Go 1.27 起 panic traceback 也带标签。1.27 notes 原文:“Tracebacks for modules with
godirectives configuring Go 1.27 or later will now includeruntime/pprofgoroutine labels in the header line.” 可用GODEBUG=tracebacklabels=0关闭(该 GODEBUG 1.26 加入)。notes 同时提醒:标签里可能含敏感信息时要关掉。
6. go tool pprof 速查
采集侧命令行形态(flag 均经 go tool pprof -h 实测存在):
go tool pprof cpu.pprof # 本地文件
go tool pprof -seconds 30 http://host:6060/debug/pprof/profile # 远程采 30s
go tool pprof http://host:6060/debug/pprof/heap # 直接分析线上堆
go tool pprof -base old.pprof new.pprof # 差分,定位增长
go tool pprof -inuse_space heap.pprof # 也可 -alloc_space
go tool pprof -tagfocus='handler=/orders' cpu.pprof # 按标签过滤交互界面高频命令:top [-N](热点列表)、peek <函数>(某函数的调用上下层)、list <函数>(源码行级耗时)、disasm(汇编级)、tags(标签统计)、web/svg/pdf(调用图,图渲染依赖系统 graphviz,没有就改用 -http)、tree、help。
-http 打开浏览器交互界面:Go 1.26 起默认进入火焰图视图,1.26 notes 原文:“The pprof tool web UI, enabled with the -http flag, now defaults to the flame graph view. The previous graph view is available in the ‘View -> Graph’ menu, or via /ui/graph."——1.26+ 看火焰图不需要额外参数,也不依赖 graphviz。执行 trace 走 go tool trace trace.out,与 pprof 是两个工具。
heap 类 profile 做 -base 差分看的是"两次 GC 之间存了什么新东西”;看"分配了多少"要加 -alloc_space(实测 go tool pprof -top -sample_index=alloc_space mem.prof 可用)。
7. 陷阱汇总
| # | 陷阱 | 后果 / 纠正 |
|---|---|---|
| 1 | StartCPUProfile 后忘记 StopCPUProfile(或 os.Exit 跳过 defer) |
CPU profile 尾部缺失甚至不可解析;Stop 负责冲刷收尾,退出前必须执行 |
| 2 | 以为堆 profile 是"此刻实时"数据 | 它是上一次 GC 结束时的快照(包文档原文);写快照前先 runtime.GC() 或带 ?gc=1 |
| 3 | allocs 与 heap 混为一谈 | 数据同源、默认索引不同:pprof 对 allocs 默认 -alloc_space、对 heap 默认 -inuse_space,看错视角结论相反 |
| 4 | block/mutex profile 拿到全零 | 默认关闭,需 runtime.SetBlockProfileRate/SetMutexProfileFraction;设 1 为全采、开销大,线上按采样设较大值 |
| 5 | 把 mutex profile 的栈当"等锁者" | 包文档明确栈对应持锁者临界区结束(Unlock)处,等锁方不出现在该 profile |
| 6 | goroutineleak 查不出已知泄漏 | 判定依赖 GC 可达性:阻塞原语经全局变量或可运行 goroutine 的栈可达时不报告(1.27 notes 承认的漏报边界) |
| 7 | 期待 block/mutex/heap profile 里有标签 | 包文档:标签目前只有 CPU 与 goroutine profile 使用 |
| 8 | 常驻 worker pool 看不到请求标签 | 继承发生在创建时刻;预先创建的 goroutine 不追溯,需在 worker 内 SetGoroutineLabels(ctx) |
| 9 | 以为存在 FlightRecorder.WriteTrace |
真实方法是 WriteTo;MaxBytes 只是 hint,不保证窗口实际大小 |
| 10 | 期望 FlightRecorder 替代全量 trace | 它只保留最近数秒窗口(notes 措辞 “the last few seconds”),长时段回放用 trace.Start |
| 11 | pprof HTTP 端点暴露公网 | 完整调用栈+内存线索外泄;只走内部端口/鉴权 |
| 12 | 用 POST 请求 /debug/pprof/* |
Go 1.22 起包文档要求全部端点仅接受 GET,脚本里注意 |
| 13 | 拿 goroutineleak 的 Count() 当实时值 |
实测多次显式 runtime.GC() 后 Count() 仍为 0,调用 WriteTo 生成快照后才重算为 1;监控场景要定期拉一次 profile |
实战建议
- 常驻服务默认只挂一个内网
net/http/pprof端口即可覆盖绝大多数性能排查;CPU 问题用go tool pprof -http :8080 http://.../debug/pprof/profile?seconds=30直接开火焰图(1.26+ 默认视图)。 - goroutine 数量缓慢爬升时,先 GET
/debug/pprof/goroutineleak?debug=1,再回到代码找"阻塞点不可达"的 early-return——比在几万条 goroutine 里人肉对比快一个数量级。 - 偶发毛刺/罕见慢请求,在 main 里常驻 FlightRecorder,在告警回调或慢请求阈值触发处
WriteTo落盘,事后go tool trace回看现场。 - Web 服务按端点/租户打
pprof.Do标签,火焰图里用-tagfocus只看某个 handler 的开销;Go 1.27 起 panic traceback 直接带标签,事故日志可读性更好。 - 内存议题分两问:谁分配多(
-alloc_space,优化分配)与谁没释放(-inuse_space+ 两次差分,修泄漏),一次 profile 回答一个问题。 - 运行时参数(GOGC/GOMEMLIMIT/GOMAXPROCS)与 GC 行为调优见同目录 ./runtime-debug.md;并发原语用法见 ../02-并发与同步/sync.md。