主题
pprof:先选对“在量什么”,再找热点
服务变慢时,先回答一个问题:时间花在执行代码,还是花在等待?CPU profile 只统计前一种。内存上升时,也先分清当前还留着多少对象,以及过去一共分配过多少。选错剖析类型,图可以很漂亮,结论仍然会错。
这篇用一个不到一秒的本地程序采集 profile,解释怎样把采样结果还原成可检验的假设。实测环境是 Go 1.27.2、Linux/amd64。所有请求测试都在 httptest 中运行,没有连接生产服务,也没有向公网暴露端口。
七个入口,各自回答什么
runtime/pprof 负责程序内采集,net/http/pprof 把这些能力放到 HTTP,go tool pprof 负责分析文件。CPU 通过 StartCPUProfile/StopCPUProfile 采集;其他内置 profile 可用 Lookup(name).WriteTo 导出。[1][2]
| 类型 | 主要问题 | 单位与时间范围 | 容易读错的地方 |
|---|---|---|---|
| CPU | 哪些调用栈在消耗 CPU? | 捕获窗口内的 CPU 采样时间 | 等网络、sleep、排队不消耗相应 CPU;多核总 CPU 时间可以超过墙钟时间 |
| heap | 最近一次完成的 GC 所反映的存活对象由哪里分配? | 默认 inuse_space,存活字节估计 | 分配位置不等于持有引用的位置;不等于 RSS |
| allocs | 到目前为止,哪里制造了最多分配? | 默认 alloc_space,进程生命周期累计分配字节 | 已经回收的对象仍在累计量内;不能拿累计数当泄漏量 |
| goroutine | 采样时有哪些 goroutine、停在哪些调用栈? | 某个时刻的 goroutine 数量 | 一个大栈簇可能是正常空闲 worker;需要随负载与时间比较 |
| block | goroutine 在同步操作上等了多久? | 累计阻塞时长,按时间抽样 | 栈指向等待方,如 Mutex.Lock、channel 接收 |
| mutex | 哪些临界区让别人等锁? | 其他 goroutine 的累计等待时长,按事件抽样 | 栈通常归到持锁方结束临界区的位置,如 Unlock |
| threadcreate | OS 线程在哪里创建? | 线程创建栈 | OS 线程不是 goroutine;不能拿它代替 goroutine profile |
表格前六项是排障时最常用的类型,threadcreate 用于补查线程增长。heap 和 allocs 底层描述的是同一组内存采样,默认选中的指标不同。内存 profile 有 inuse_space、inuse_objects、alloc_space、alloc_objects 四种常用 sample index;大对象数量少、细碎对象数量多,按字节和按对象数看会得到不同排序。[1]
正在绘制图示…
查看图源代码
flowchart LR
A[一次对象分配] --> B[按 MemProfileRate 抽样并记录分配栈]
B --> C[alloc_space / alloc_objects 累计]
B --> D[经过 GC 的存活统计]
D --> E[inuse_space / inuse_objects]
C --> F[找分配抖动与 GC 压力]
E --> G[找仍被保留的对象来源]
这里的箭头表达统计关系,不表示 GC 会从累计分配量里扣钱。alloc_space 只会继续累加;inuse_space 才会随对象回收下降。
让“留着的”和“用完的”出现在不同视图
实验先把 64 块 64 KiB 的切片留在全局变量,再制造 128 块同样大小的临时切片。临时变量也故意经过全局赋值,以保证这次实验测的是堆分配;最后清空它,再完成两次 GC。
go
func churn() {
for range 128 {
b := make([]byte, 64<<10)
b[0] = 1
temporary = b
}
temporary = nil
}
func keep() {
for range 64 {
retained = append(retained, make([]byte, 64<<10))
}
}本次的真实输出和 pprof -top 摘录:
text
retained_bytes: 4194304
Type: inuse_space
4MB main.keep
Type: alloc_space
8MB main.churn
4MB main.keep这就能区分两种修法:如果担心 GC 频繁,应检查 churn 为什么不断制造临时对象;如果担心工作结束后还占着内存,应找是谁继续引用 keep 分配的切片。heap 只告诉我们“从这里分配”,不能直接指出引用链上的缓存或全局变量。
程序把 runtime.MemProfileRate 设成 1,尽量让每次堆分配都进入记录。这是为了让小实验容易观察。默认值是 512 KiB 的采样间隔;生产排查要考虑开销,并且程序中应尽早只设置一次,因为分析工具假设采样率稳定。[3] 两次 GC 也是实验控制条件,不能照抄成生产请求路径上的动作。内存 profile 的记录可能滞后于刚发生的释放,runtime.MemProfile 文档说明最多可落后两个 GC 周期。[3]
完整报告还会看到压缩器和 profile 自身的分配。本次 allocs 总量约 13.98 MiB,其中业务演示分配约 12 MiB。采集工具也是程序的一部分,不能为了图整齐把其开销说成不存在。
CPU 的 flat、cum 怎样看
实验用 350 ms 的有界计算循环,采到 340 ms CPU 样本:
text
Duration: 350.04ms, Total samples = 340ms (97.13%)
flat flat% cum cum%
340ms 100% 340ms 100% main.burn
0 0% 340ms 100% main.runflat 是直接落在该函数的样本,cum 包括该函数调用的下层函数。run 的 flat 是零,并不意味着调用 run 没成本:它调用 burn,所以 cum 很高。先用 top 找自身热点,再用 top -cum 找承载这些热点的调用入口,用 list 关联到源码。[4]
bash
go tool pprof ./lab cpu.pb.gz
# 在交互提示符输入:
top
top -cum
list main.burn符号化需要将样本里的地址映射回函数、文件和行号。尽量保留与采样时完全匹配的二进制、源码提交和构建信息;不要拿重编译后的同名文件冒充原二进制。某些 profile 自带符号信息,仍不应因此丢掉构建产物。[4]
火焰图的宽度不是执行时间线
在自己的分析机上运行 go tool pprof -http=127.0.0.1:8080 ./lab cpu.pb.gz,选择 Flame Graph。一个框通常表示某条调用路径上的函数,父框包含子框,宽度对应当前指标的样本权重。同名函数在不同调用路径可能出现多次。横向位置用于排列调用栈,不能解释成“先执行左边,再执行右边”。颜色也不应被当成固定的慢、快或错误等级。[4][5]
读图时先确认当前指标是 CPU 时间、字节还是阻塞时间,再找宽分支。宽的父框不一定自身昂贵,要顺着子框往下看。若问题是“这次请求在哪一刻被唤醒,又隔了多久才开始运行”,需要时间线,转用 trace。本文实际验证了命令行报告,没有声称打开浏览器或截取火焰图。
block 看等的人,mutex 看让人等的临界区
两个 profile 默认不开启,单纯导入 net/http/pprof 不会启用它们。入口分别是:
go
runtime.SetBlockProfileRate(1)
old := runtime.SetMutexProfileFraction(1)
defer runtime.SetBlockProfileRate(0)
defer runtime.SetMutexProfileFraction(old)block 的参数是平均每隔多少阻塞纳秒采一个事件,1 表示记录每个阻塞事件,≤0 关闭。mutex 的参数是事件抽样比例,正数 n 表示平均采 1/n 的竞争事件,0 关闭,负数只查询。[3] 这两个数字不能按同一种“采样频率”理解。
实验安排 4 个 goroutine,每个循环 20 次持锁睡眠 1 ms,故意让锁竞争。真正的业务代码应尽量避免持锁做这类等待。本次结果:
text
block:
250.10ms sync.(*Mutex).Lock
86.72ms sync.(*WaitGroup).Wait
mutex:
250.62ms 99.92% 99.92% 250.62ms 99.92% sync.(*Mutex).Unlock (inline)锁工作阶段只经历了约几十毫秒墙钟时间,多个等待者的等待时间却会累加,因此 250 ms 并不奇怪。block 指向 Lock,mutex 指向 Unlock。两份报告是在提示从不同一端调查同一场竞争,并不是解锁操作本身用了 250 ms。[1]
goroutine 快照则捕到了 4 个等待 channel 的 goroutine。实验关闭 channel 后全部退出,因此这不是泄漏。真实排查要在流量回落后再取一次栈,确认相同调用栈的数量是否仍不断增长。
在线使用:调试端口要有自己的边界
导入 net/http/pprof 会注册到 http.DefaultServeMux。若业务服务器使用默认 mux,就可能一起暴露调试入口。本文的代码显式创建管理 mux,业务 mux 另建,测试里验证公开 mux 访问 heap 返回 404。[2]
go
func adminMux() *http.ServeMux {
m := http.NewServeMux()
m.HandleFunc("GET /debug/pprof/", httppprof.Index)
m.HandleFunc("GET /debug/pprof/profile", httppprof.Profile)
m.HandleFunc("GET /debug/pprof/trace", httppprof.Trace)
return m
}本次测试输出:
text
GET status=200
POST status=405
GET status=404前两项来自管理 mux,最后一项来自公开 mux。Go 1.22 起官方 pprof 路径要求 GET;这里的路由模式也显式限制方法。Index 会分派其下面的命名 profile,而 CPU 与 trace 用独立 handler。[2]
上线时需要补的是部署边界,单个 mux 不等于访问控制:
| 边界 | 为什么需要 | 落地检查 |
|---|---|---|
| 独立监听地址 | 避免业务入口顺手暴露调试 API | 管理 listener 绑定 loopback 或受控管理网络;不要使用 :6060 作为默认公开示例 |
| 网络和身份控制 | 同网段、同主机上的人也未必都有诊断权限 | 防火墙/网络策略,加已批准的认证代理或 mTLS;loopback 本身不是身份认证 |
| 限制并发与捕获长度 | 多次采集、trace、强制 GC 可能干扰服务 | 诊断窗口、超时、并发限额、审计;代理 timeout 需覆盖批准的采样时长 |
| 保护产物 | 栈、标签、路径、命令行可能暴露实现和业务信息 | 不把完整 profile/trace 直接上传公共网站;限制保存、下载和保留期 |
HTTP 参数也影响语义:debug=0 默认二进制,debug>0 文本;goroutine?debug=2 是完整栈文本;heap?gc=1 会先触发 GC;对 heap、allocs、goroutine、block、mutex、threadcreate 指定 seconds=N 返回差分,对 profile 和 trace 则是采集 N 秒。[2] 差分的 heap 可能出现负值,含义是采集两端的存活量差,不能解释成“负分配”。
-base=before.pb.gz after.pb.gz 可做离线差分。比较前必须固定指标、同类负载、相近时间窗与采样配置,保留前后请求量。进程重启会重置累计 profile,跨重启拿累计量直接相减没有意义。百分比还会受分母变化影响:函数占比下降,绝对 CPU 时间仍可能上升。
本次验证与边界
程序生成七份二进制 profile,全部通过当前 go tool pprof -top 解析;管理 mux 三条行为断言通过,go test -race 与 go vet 通过。数字来自这台共享云机器的一次短实验,只证明采集链与演示现象,不是容量估算或生产延迟承诺。
最先值得练习的动作,是对同一份内存采样切换 inuse_space 与 alloc_space,再说明这两种排序为什么不同。能解释清楚这个区别,才知道下一步应该减少分配,还是找还没释放的引用。
依据
- Go 1.27.2 runtime/pprof:各 profile 的范围、block/mutex 栈归因、heap 与 allocs 默认视图。
- Go 1.27.2 net/http/pprof:注册、GET 要求、HTTP 参数与 handler。
- Go 1.27.2 runtime:
MemProfileRate、MemProfile、SetBlockProfileRate、SetMutexProfileFraction。 - pprof 官方操作文档;本次命令还由 Go 1.27.2 随附
go tool pprof -help验证。 - Brendan Gregg:Flame Graphs:原作者对调用栈聚合、宽度与横向排列的说明。
完整复现代码
在一个新目录按以下文件名保存。需要 Go 1.27.2;无第三方依赖。执行 bash run.sh out,所有输出保存在 out。脚本启用的全量采样只发生在这个短命的本地实验进程中,不要直接搬进生产服务。
go.mod
go.mod
module example.com/pproflab
go 1.27.2main.go
代码块 · 144 行 · 点击收起 / 展开
go
package main
import (
"fmt"
"net/http"
httppprof "net/http/pprof"
"os"
"path/filepath"
"runtime"
"runtime/pprof"
"sync"
"time"
)
var retained [][]byte
var temporary []byte
var sink uint64
//go:noinline
func churn() {
for range 128 {
b := make([]byte, 64<<10)
b[0] = 1
temporary = b
}
temporary = nil
}
//go:noinline
func keep() {
for range 64 {
retained = append(retained, make([]byte, 64<<10))
}
}
//go:noinline
func burn(d time.Duration) {
x := uint64(1)
until := time.Now().Add(d)
for time.Now().Before(until) {
for range 10000 {
x = x*1664525 + 1013904223
}
}
sink = x
}
func dump(dir, name string) error {
f, err := os.Create(filepath.Join(dir, name+".pb.gz"))
if err != nil {
return err
}
err = pprof.Lookup(name).WriteTo(f, 0)
closeErr := f.Close()
if err != nil {
return err
}
return closeErr
}
func adminMux() *http.ServeMux {
m := http.NewServeMux()
m.HandleFunc("GET /debug/pprof/", httppprof.Index)
m.HandleFunc("GET /debug/pprof/profile", httppprof.Profile)
m.HandleFunc("GET /debug/pprof/trace", httppprof.Trace)
return m
}
func run(dir string) error {
if err := os.MkdirAll(dir, 0700); err != nil {
return err
}
// Only this short-lived laboratory enables full sampling.
runtime.MemProfileRate = 1
runtime.SetBlockProfileRate(1)
old := runtime.SetMutexProfileFraction(1)
defer runtime.SetBlockProfileRate(0)
defer runtime.SetMutexProfileFraction(old)
f, err := os.Create(filepath.Join(dir, "cpu.pb.gz"))
if err != nil {
return err
}
if err := pprof.StartCPUProfile(f); err != nil {
f.Close()
return err
}
burn(350 * time.Millisecond)
pprof.StopCPUProfile()
if err := f.Close(); err != nil {
return err
}
keep()
churn()
runtime.GC()
runtime.GC()
for _, name := range []string{"heap", "allocs"} {
if err := dump(dir, name); err != nil {
return err
}
}
ready := make(chan struct{}, 4)
release := make(chan struct{})
var waiting sync.WaitGroup
for range 4 {
waiting.Go(func() { ready <- struct{}{}; <-release })
}
for range 4 {
<-ready
}
if err := dump(dir, "goroutine"); err != nil {
close(release)
waiting.Wait()
return err
}
close(release)
waiting.Wait()
var mu sync.Mutex
var wg sync.WaitGroup
for range 4 {
wg.Go(func() {
for range 20 {
mu.Lock()
time.Sleep(time.Millisecond)
mu.Unlock()
}
})
}
wg.Wait()
for _, name := range []string{"block", "mutex", "threadcreate"} {
if err := dump(dir, name); err != nil {
return err
}
}
fmt.Println("retained_bytes:", len(retained)*(64<<10))
fmt.Println("profiles: cpu heap allocs goroutine block mutex threadcreate")
return nil
}
func main() {
dir := "out"
if len(os.Args) > 1 {
dir = os.Args[1]
}
if err := run(dir); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}main_test.go
go
package main
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestEndpointIsolation(t *testing.T) {
admin := adminMux()
public := http.NewServeMux()
for _, tc := range []struct {
h http.Handler
method string
want int
}{
{admin, "GET", 200}, {admin, "POST", 405}, {public, "GET", 404},
} {
w := httptest.NewRecorder()
tc.h.ServeHTTP(w, httptest.NewRequest(tc.method, "/debug/pprof/heap", nil))
if w.Code != tc.want {
t.Fatalf("%s: got %d want %d", tc.method, w.Code, tc.want)
}
t.Logf("%s status=%d", tc.method, w.Code)
}
}run.sh
bash
#!/usr/bin/env bash
set -euo pipefail
export TZ=UTC
out=${1:-out}
mkdir -p "$out"
go version > "$out/environment.txt"
go env GOOS GOARCH CGO_ENABLED >> "$out/environment.txt"
go test -v . > "$out/test.txt"
go test -race . >> "$out/test.txt"
go vet . >> "$out/test.txt"
go build -o "$out/lab" .
"$out/lab" "$out" > "$out/run.txt"
for p in cpu heap allocs goroutine block mutex threadcreate; do
go tool pprof -top -nodecount=8 "$out/lab" "$out/$p.pb.gz" > "$out/$p-top.txt"
done