Skip to content
Go
pprof — 性能剖析与诊断

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 calling runtime.SetBlockProfileRate in your program”——block 和 mutex 这两个 profile 不开采就是全零,这是新手的头号困惑来源。

几个容易被误解的语义,均来自 runtime/pprof 包文档原文:

  • CPU profile 不是 Profile。包文档:“The CPU profile is not available as a Profile. It has a special API, the StartCPUProfile and StopCPUProfile functions, 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_space vs -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 the runtime/pprof package. It is also available as the net/http/pprof endpoint /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 的行为是创建时刻继承。但有两个真实限制:

  1. 继承只发生在创建那一刻。常驻 worker pool 在打标签代码之前就建好了,它的 goroutine 永远不带标签;要让 worker 带上当前请求的标签,需把带标签的 ctx 传进去,并在 worker 函数内部显式调用 pprof.SetGoroutineLabels(ctx)。
  2. 标签是创建时的快照,之后父 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 go directives configuring Go 1.27 or later will now include runtime/pprof goroutine 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。