主题
持续剖析:把一次性能排查,变成能回看时间和版本的证据
接口下午两点变慢,等到有人开始抓 CPU profile,流量已经降了下来。这时得到的火焰图可能完全正常。持续剖析要解决的是这个时间差:平时就按受控的频率留下性能样本,发生问题后,能按服务、版本和时间窗口找回当时的调用栈。
读完这篇,你应该能做三件事:根据症状选对 profile;读懂 Go 的 CPU、内存和锁竞争报告;为线上采集设计版本标记、开销预算和安全边界。最后提供一个完全本地、有时间和内存上限的小实验。它在 Go 1.27.2、Linux/amd64 上实际运行过,没有连接 Pyroscope 或任何线上服务,也不代表已经验证了一套生产持续剖析系统。
先认清:你要解释的是哪一种“慢”
Profile 是按调用栈归集的样本。样本可以是 CPU 时间、分配字节,也可以是同步等待时间。看起来都是一张火焰图,横向宽度的单位却可能不同。先看 profile 类型和样本单位,再看最宽的函数。 Go 各类 profile 的语义以固定版本的 runtime/pprof 文档为准。
| 想回答的问题 | 优先看什么 | 看到的是什么 | 单凭它不能回答什么 |
|---|---|---|---|
| CPU 一直很高,算力花在哪里 | CPU profile | 采样期间进程消耗 CPU 时的调用栈 | 某个请求等数据库花了多久 |
| GC 后仍占用很多 Go 堆内存 | heap 的 inuse_space / inuse_objects | 存活分配的估计字节数 / 对象数 | 整个进程 RSS、所有 native 内存 |
| GC 很忙,谁在不停制造临时对象 | heap 或 allocs 的 alloc_space / alloc_objects | 自进程启动以来的累计分配估计 | 当前还有多少对象活着 |
| goroutine 卡在同步操作上 | block profile | 在 Lock、channel、WaitGroup 等位置等待的累计时间 | 所有网络 I/O 等待的统一账单 |
| 哪段持锁代码让别人等 | mutex profile | 导致竞争的临界区,在解锁一侧归因的等待时间 | 哪个请求最终超时、锁的业务名字 |
| 当前 goroutine 停在哪里 | goroutine profile | 采集当时的栈快照 | 每个状态已经持续多久 |
| 一段时间内怎么调度、唤醒、等待 | Go execution trace | 运行时事件的时间关系 | 自动得到跨服务的完整请求链路 |
最后一行是补充工具,不是 CPU profile 的另一个显示选项。Go execution trace 记录运行时事件;跨服务调用链还要靠分布式 tracing。两者名字都带 trace,观察范围不同。runtime/trace
CPU 时间不等于请求耗时
一个请求先计算 20 毫秒,再等数据库 180 毫秒,总耗时约 200 毫秒。这里的数值是解释口径的假设例子:CPU profile 主要有机会记录那 20 毫秒计算;等待期间没有持续占用 CPU,不能指望它把另外 180 毫秒记成数据库函数的 CPU 消耗。反过来,多个核并行工作时,进程的 CPU 样本总时间也可能超过采集的墙钟时间。Go 诊断指南
所以,“CPU 图里没看到数据库”并不能排除数据库等待。应先用请求延迟、trace、连接池指标缩小方向,再选择 profile。墙钟时间表示从开始到结束过去多久;CPU 时间表示用了多少处理器执行时间。这一层口径不分清,后面的排名就容易看反。
一个一秒钟实验:为什么 Sleep 没有占满 CPU 图
本地程序只做约 650 毫秒 SHA-256 计算,再 Sleep 350 毫秒。下面是程序里的关键部分;启动和停止 profile、关闭文件、错误处理都在文末完整代码中。
go
start := time.Now()
pprof.Do(context.Background(), pprof.Labels("route", "/work", "revision", revision), func(context.Context) {
cpuWork(650 * time.Millisecond)
time.Sleep(350 * time.Millisecond)
})
wall := time.Since(start)本次运行打印:
text
work_wall=1.001s overlap_rejected=true
profiles=cpu,heap,block,mutex network=none再执行脚本中的 go tool pprof -top,报告摘录如下:
text
Type: cpu
Duration: 1s, Total samples = 650ms (64.96%)
flat flat% sum% cum cum%
630ms 96.92% 96.92% 630ms 96.92% crypto/internal/fips140/sha256.blockSHANI墙钟过去约一秒,CPU 样本约 650 毫秒,主要落在散列函数。Sleep 没有被算成 350 毫秒 CPU 时间。这个实验只说明采样口径;具体样本会随调度、机器指令集和并发负载变化,不能据此认定任何线上服务的 CPU 利用率。
flat 是直接落在当前函数的样本;cum 包含经过它、继续进入下游调用的样本。一次样本能同时属于多层调用者的 cum,因此不能把各行 cum 相加。火焰图同样表达调用栈的包含关系,宽度对应所选样本值,不是按时间从左向右播放请求经过的顺序。实际定位时,先找消耗大的调用路径,再看路径上的源码;看到最底层的散列或内存复制函数,还不能直接断定应该替换标准库实现。pprof 阅读说明
同一个进程,CPU 采集只能有一个负责人
输出中的 overlap_rejected=true 来自一个故意尝试失败的操作:
go
if err := pprof.StartCPUProfile(w); err != nil {
return 0, false, err
}
defer pprof.StopCPUProfile()
overlapRejected := pprof.StartCPUProfile(io.Discard) != nil第二次启动被拒绝。StartCPUProfile 在 CPU profiling 已开启时返回错误;StopCPUProfile 等到 profile 写完后才返回。持续采集 SDK、手工抓取和测试工具若同时争用它,就会互相影响。应统一采集入口,排查时调整已有采集器,而不是再挂第二个长期采集器。固定版本 API
内存图的两个视角:现在留下什么,过去分配过什么
实验接着做两批分配:
go
retainMemory() // 分配 16 个 1 MiB 切片,继续引用它们
churnMemory() // 分配 32 个 1 MiB 切片,最终不再引用
runtime.GC()
writeProfile(dir, "heap")这里主动 GC 是为了教学时更容易分清存活与已回收对象。把它原样放到每个请求里,或者在线上高频强制 GC,会改变正在观察的程序。
同一份 heap.pprof,选择不同样本:
bash
go tool pprof -top -sample_index=inuse_space "$tmp/demo" "$tmp/profiles/heap.pprof"
go tool pprof -top -sample_index=alloc_space "$tmp/demo" "$tmp/profiles/heap.pprof"本次报告中的两组主要行:
text
Type: inuse_space
13027.01kB 96.22% 96.22% 13027.01kB 96.22% main.retainMemory
Type: alloc_space
29606.85kB 66.55% 66.55% 29606.85kB 66.55% main.churnMemory
13027.01kB 29.28% 95.83% 13027.01kB 29.28% main.retainMemorychurnMemory 在累计分配中很显眼,却没有成为存活堆的主要来源。它制造了需要回收的对象,不能据此叫它“内存泄漏”。retainMemory 的对象还被引用,出现在存活视角中,但本例保留它们是有意的,也不等于泄漏。怀疑泄漏,需要继续检查同等负载下,GC 后存活量是否持续增长,以及哪些对象本应已经结束生命周期。
为什么 16 MiB 没有显示为精确的 16 MiB?默认内存 profile 是采样估计,不是逐笔记账。Go 1.27.2 的 MemProfileRate 默认值是 512 KiB,目标是平均每分配这些字节取一个分配样本。本次没有修改它,因此小样本误差明显。inuse_objects 与 alloc_objects 看对象数,可帮助区分“大对象少量分配”和“小对象大量分配”;它们也受采样影响。MemProfileRate
还有两个时间陷阱:heap 文档说明,统计通常以最近完成的 GC 为边界;alloc_space 是进程生命周期内累计值。不能拿启动十分钟的新实例和运行三天的旧实例直接比累计分配量。若要观察某个窗口的分配,应由采集器生成正确的增量,或者比较同一进程、相同口径的前后样本;进程重启、采样率变更和窗口缺失都要单独处理。存活堆的前后差值则表示净变化,不代表期间总共分配了多少。
锁等待:谁在等,谁让别人等
下面这段代码故意让两个 goroutine 在同一个锁里睡眠:
go
mu.Lock()
time.Sleep(2 * time.Millisecond)
mu.Unlock()实验为短窗口打开全部事件采样,运行完就关闭 block profile,并恢复原 mutex 采样设置。完整过程只进行固定次数的循环。
go
runtime.SetBlockProfileRate(1)
old := runtime.SetMutexProfileFraction(1)
contend()
runtime.SetBlockProfileRate(0)
runtime.SetMutexProfileFraction(old)两个报告摘录:
text
block:
52.38ms 52.28% 52.28% 52.38ms 52.28% sync.(*WaitGroup).Wait
47.80ms 47.72% 100% 47.80ms 47.72% sync.(*Mutex).Lock (inline)
mutex:
47.76ms 100% 100% 47.76ms 100% sync.(*Mutex).Unlock (inline)block 把等待记在等待者所在的位置,所以看见 Lock 和等待两名工人结束的 WaitGroup.Wait。mutex 把竞争成本归在造成等待的临界区结束处,所以看见 Unlock。这不表示 Unlock 指令本身花了 47.76 毫秒;要顺着它的调用栈检查前面那段持锁工作。本例的问题就在锁内 Sleep。真实程序里可能是锁内网络请求、大量计算,或临界区包含了本可移出去的操作。block 与 mutex 语义
等待是跨 goroutine 累计的,多个等待者同时等待,累计值可以超过窗口的墙钟时间。两个 profile 的采样机制也不同,不应逐毫秒强求数值相等。
| 配置 | 参数单位 | 1 的含义 | 持续采集时要注意什么 |
|---|---|---|---|
SetBlockProfileRate(rate) | 按阻塞时间纳秒进行抽样 | 记录每次阻塞事件 | 5 不是每 5 次抽一次;小值可能很重 |
SetMutexProfileFraction(rate) | 竞争事件抽样比例的分母 | 记录每次竞争事件 | 5 才是平均记录五分之一 |
MemProfileRate | 平均分配字节数 | 记录每次堆分配 | 不要在程序运行中反复调整;工具假定该值稳定 |
这些参数的名称相近,单位却不相同。尤其不要把一段演示里的两个 5 理解成相同开销。block 参数、mutex 参数
从本地文件到线上常驻:需要补齐哪些环节
持续剖析增加的价值主要在样本管理:发生问题时,能找回相关时间段、相同服务和准确版本的样本。只让 pprof 一直运行、不断往本地磁盘写文件,还没有解决检索、丢失、保留和权限问题。
正在绘制图示…
查看图源代码
flowchart LR
A["Go 进程<br/>运行时采样 + 受控标签"] --> B["单一采集负责人<br/>按窗口截取 / 增量处理"]
R["发布信息<br/>版本、构建标识、运行环境"] --> B
B --> C["有界队列<br/>限时重试、超限丢弃并计数"]
C --> D["鉴权与传输加密"]
D --> E["Profile 后端<br/>时间索引、保留、租户隔离"]
E --> F["选择相同口径的窗口<br/>按版本和工作负载比较"]
G["指标 / Trace<br/>症状、流量和请求类型"] --> F
C --> H["采集健康指标<br/>上传失败、丢弃、覆盖空窗"]
图里有两类采集方式。Push 是进程内 SDK 采集后主动发出;Pull 是受控采集器访问进程的诊断端点。Pyroscope 的 Go SDK 使用 runtime/pprof,官方也提供通过 Alloy 使用 Pull 的接入路径。选哪种取决于服务发现、网络策略和进程内允许承担的工作,不能只看哪段示例更短。Pyroscope Go 接入
| 环节 | 应作出的决定 | 不决定会发生什么 |
|---|---|---|
| 覆盖范围 | 所有副本、抽样副本,还是分组轮换 | 只抓一个异常少的副本,误以为服务一直正常 |
| 采集节奏 | 窗口长度、间隔、不同类型是否错开 | 短毛刺落在空窗;多种高开销采集叠加 |
| 归因信息 | 服务、部署环境、构建版本、profile 类型、窗口起止 | 同名函数的不同构建被混在一起 |
| 故障处理 | 队列字节上限、超时、最大重试期、丢弃规则 | 后端不可用拖垮应用,或悄悄丢失关键样本 |
| 保留策略 | 原始样本保留多久,聚合层保留多久 | 保存成本不断增长,或事故发生后才发现样本已删 |
| 采集健康 | 最近成功时间、覆盖率、丢弃量 | “没有热点”其实是根本没收到数据 |
这是上线设计清单,不是某一产品的默认值。具体后端是否支持多级聚合、每租户保留或哪些认证方式,要按选定版本查证。本文没有部署后端,也没有实测断网补传和跨版本查询。
版本归因:先确认样本属于谁
本例构建时用 -ldflags='-X main.revision=demo-v1' 写入演示版本,CPU 样本中添加 route 与 revision 标签。读取标签的实际输出为:
text
revision: Total 650ms of 650ms ( 100%)
650ms ( 100%): demo-v1
route: Total 650ms of 650ms ( 100%)
650ms ( 100%): /work这个 demo-v1 是人工指定的演示值,不是从真实仓库验证出的提交。生产中应由构建流程注入不可变的 release 或 commit,并保留对应二进制的构建标识、源码和需要的符号信息;不能事后拿另一份同名二进制解释机器地址。
pprof.Do 给当前调用及其新建 goroutine 设置标签,返回后恢复原有标签。固定版本文档明确说,当前这些标签用于 CPU 和 goroutine profile。不要以为给 context 添加了标签,heap、block、mutex 就会自动按 HTTP 路由划分。 版本、实例和窗口等属性可以由采集器作为整个 profile 的元数据记录;请求级细分则要确认 profile 类型和采集器确实支持。Labels
标签也有代价。route=/orders/{id} 的可能值可控,route=/orders/982371 会随请求不断增多。用户 ID、邮箱、完整 URL、SQL 参数既可能让标签组合爆炸,也可能把敏感数据送到后端。先列允许的标签,再考虑追加,通常比事后清理更可靠。
看到新版本更热,为什么还不能立刻判它回退
比较前先固定样本口径:同一种 profile、相同单位、相近窗口长度、相同采样配置。然后看流量和工作量。新版本 CPU 总量翻倍,可能只是接收了两倍请求;函数占比上升,也可能只是其他函数变快后,分母变小。只比百分比会漏掉这个变化。
可把比较拆成三层:
- 指标确认症状:CPU、p99、错误率是否变差,是否正好遇到限流、冷启动或容量变化。
- Profile 找方向:同类请求下,每次请求或每单位工作对应的 CPU、分配、竞争是否增加;样本不足时先补数据。
- 控制变量验证:灰度对照、相同输入的回放或可回退的实验,确认改动与退化之间的关系。
第二步提供线索,第三步才让归因更有说服力。版本、时间点与热点同时出现,只证明它们相关;请求构成、CPU 配额、依赖延迟和 Go 工具链版本都可能一起改变。这些比较步骤是工程建议,不是 profile 自动给出的因果结论。
常驻的前提:预算够、收得住、关得掉
Go 官方建议在生产开启前测量开销,并提醒不同诊断工具可能互相干扰。精细内存采样可能影响 CPU profile;强制 GC 和高密度锁采样也会改变原来的运行状态。诊断开销
因此,不能把某个产品声称的低开销数字直接当成自己的预算。较稳妥的验证方式是在代表性负载下做“关闭、开启”的对照,保持构建、请求构成和资源配额一致,重复运行,记录 CPU/请求、p99、RSS、GC、上传字节以及队列丢弃。race 检测也会改变开销;本文的 race 运行只检查并发错误,普通运行才用于展示 profile。
本地实验的 SetBlockProfileRate(1) 和 SetMutexProfileFraction(1) 为了让短窗口容易看出问题。它们不是线上默认建议。常驻先从实际需要的少量信号开始,根据成本调节覆盖副本和采集窗口,保留关闭入口;临时提高精度则应设置结束时间。
安全上,诊断端口应与业务入口分离,限制网络访问并做身份验证。net/http/pprof 的默认注册发生在 DefaultServeMux,还包含可返回命令行信息的端点;业务 HTTP 服务若恰好使用这个 mux,可能把本来只想在内部用的能力一起暴露出去。Pull 采集还要限制抓取并发和时长,防止诊断请求变成资源消耗入口。HTTP pprof
Profile 通常记录栈、符号和样本,并不等于完整堆转储;但源码路径、函数名、自定义标签与其他诊断输出仍可能泄露信息。上传前确认允许传哪些字段,存储端配置最小权限、租户隔离和保留期。本例只处理合成数据,文件权限为 0600;运行脚本在退出时删除原始 profile 和二进制,只留下文字验证记录。
验证范围与依据
本次在 Go 1.27.2、Linux/amd64、CGO_ENABLED=1、GOMAXPROCS=2 下执行了完整脚本。普通测试、race 测试和 go vet 均通过;实测了 CPU 与墙钟的差异、同进程重复启动 CPU 采样被拒绝、标签、存活与累计分配视角、block 与 mutex 栈归因,以及禁止覆盖已有 profile 的失败退出。测试还覆盖了不合法采集窗口、不存在的 profile 名称和写入失败。
这些结果不证明生产常驻开销、多实例覆盖、后端保留策略或跨版本退化归因。没有运行真实负载、Pyroscope 服务、HTTP 诊断端口,也没有把任何生产 profile 发到外部。本文没有给出经过基准验证的采样比例或百分比开销。
资料核对日期为 2026-10-13。Go 行为同时核对了本地官方 Go 1.27.2 的 go doc;带版本的包链接可复查该版本。产品网页为当日官方文档,未来可能变化。
- runtime/pprof@go1.27.2:profile 类型、采样索引、CPU 生命周期、标签与栈归因。
- runtime@go1.27.2:
MemProfileRate、SetBlockProfileRate、SetMutexProfileFraction。 - net/http/pprof@go1.27.2:HTTP handler 与诊断入口。
- runtime/trace@go1.27.2:运行时事件记录的范围。
- Go Diagnostics:CPU profile 的口径、生产开销和采集相互干扰。本文不依赖该页面较旧的跨服务传播说明。
- google/pprof 使用说明:报告和图形阅读;具体命令还以本次
go tool pprof -help核对。 - Pyroscope Go SDK 官方文档:Go runtime 接入、Push/Pull 选择、类型与标签;本文未运行其 SDK。
完整实验:代码、测试与运行脚本
下面四个文件放在同一个目录。只依赖标准库。需要 Go 1.27.2;Linux 上运行 race 需要可用的 C 工具链。执行 bash run.sh 即可;若只想验证代码,可先运行 go test ./...、go test -race ./... 和 go vet ./...。
程序输出目录必须是你控制的私有目录。脚本自动创建临时目录并清理。最后一次执行故意使用已存在的文件,预期退出码为 1;这是验证保护行为,不是脚本失败。时间、采样数、函数排序和临时路径会随运行变化,不应写成精确值断言。
go.mod
go
module example.com/observability/profiling
go 1.27.2main.go
代码块 · 166 行 · 点击收起 / 展开
go
package main
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"runtime/pprof"
"sync"
"time"
)
var revision = "local-demo"
var checksum [32]byte
var retained [][]byte
var transient []byte
func cpuWork(d time.Duration) {
buf := make([]byte, 4096)
deadline := time.Now().Add(d)
for time.Now().Before(deadline) {
checksum = sha256.Sum256(buf)
buf[0] = checksum[0]
}
}
func captureCPU(w io.Writer, busy, wait time.Duration) (time.Duration, bool, error) {
if busy <= 0 || busy > 2*time.Second || wait < 0 || wait > time.Second {
return 0, false, errors.New("busy must be (0,2s], wait must be [0,1s]")
}
if err := pprof.StartCPUProfile(w); err != nil {
return 0, false, err
}
defer pprof.StopCPUProfile()
// A negative fixture: a second owner cannot start CPU profiling.
overlapRejected := pprof.StartCPUProfile(io.Discard) != nil
start := time.Now()
pprof.Do(context.Background(), pprof.Labels("route", "/work", "revision", revision), func(context.Context) {
cpuWork(busy)
time.Sleep(wait)
})
return time.Since(start), overlapRejected, nil
}
//go:noinline
func retainMemory() {
for range 16 {
retained = append(retained, make([]byte, 1<<20))
}
}
//go:noinline
func churnMemory() {
for range 32 {
transient = make([]byte, 1<<20)
transient[0] = 1
}
transient = nil
}
func contend() {
var mu sync.Mutex
var wg sync.WaitGroup
start := make(chan struct{})
for range 2 {
wg.Add(1)
go func() {
defer wg.Done()
<-start
for range 12 {
mu.Lock()
time.Sleep(2 * time.Millisecond) // Deliberately bad: sleep while holding the lock.
mu.Unlock()
}
}()
}
close(start)
wg.Wait()
}
func writeProfile(dir, name string) error {
p := pprof.Lookup(name)
if p == nil {
return fmt.Errorf("unknown profile: %s", name)
}
f, err := os.OpenFile(filepath.Join(dir, name+".pprof"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if err != nil {
return err
}
return errors.Join(p.WriteTo(f, 0), f.Close())
}
func run(dir string) error {
if dir == "" {
return errors.New("provide an empty private directory with -out")
}
if err := os.MkdirAll(dir, 0700); err != nil {
return err
}
f, err := os.OpenFile(filepath.Join(dir, "cpu.pprof"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if err != nil {
return err
}
start := time.Now().UTC()
wall, overlap, err := captureCPU(f, 650*time.Millisecond, 350*time.Millisecond)
err = errors.Join(err, f.Close()) // captureCPU has already called StopCPUProfile.
if err != nil {
return err
}
end := time.Now().UTC()
fmt.Printf("work_wall=%s overlap_rejected=%v\n", wall.Round(time.Millisecond), overlap)
retainMemory()
churnMemory()
runtime.GC()
if err := writeProfile(dir, "heap"); err != nil {
return err
}
runtime.KeepAlive(retained)
// Full event sampling is for this short synthetic experiment only.
runtime.SetBlockProfileRate(1)
old := runtime.SetMutexProfileFraction(1)
contend()
runtime.SetBlockProfileRate(0)
runtime.SetMutexProfileFraction(old)
for _, name := range []string{"block", "mutex"} {
if err := writeProfile(dir, name); err != nil {
return err
}
}
meta := struct {
Service, Revision, GoVersion, OS, Arch string
CPUWindowStart, CPUWindowEnd time.Time
GOMAXPROCS int
}{"synthetic-profiler", revision, runtime.Version(), runtime.GOOS, runtime.GOARCH, start, end, runtime.GOMAXPROCS(0)}
data, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return err
}
mf, err := os.OpenFile(filepath.Join(dir, "metadata.json"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if err != nil {
return err
}
_, err = mf.Write(append(data, '\n'))
if err := errors.Join(err, mf.Close()); err != nil {
return err
}
fmt.Println("profiles=cpu,heap,block,mutex network=none")
return nil
}
func main() {
out := flag.String("out", "", "private directory for synthetic profiles")
flag.Parse()
if err := run(*out); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}main_test.go
代码块 · 60 行 · 点击收起 / 展开
go
package main
import (
"bytes"
"context"
"errors"
"io"
"runtime/pprof"
"strings"
"testing"
"time"
)
func TestCPUAndOverlap(t *testing.T) {
var b bytes.Buffer
wall, rejected, err := captureCPU(&b, 10*time.Millisecond, 5*time.Millisecond)
if err != nil || !rejected || wall < 15*time.Millisecond || b.Len() == 0 {
t.Fatalf("wall=%v rejected=%v bytes=%d err=%v", wall, rejected, b.Len(), err)
}
}
func TestRejectUnboundedWindow(t *testing.T) {
for _, busy := range []time.Duration{0, -1, 3 * time.Second} {
if _, _, err := captureCPU(io.Discard, busy, 0); err == nil {
t.Fatalf("accepted busy=%s", busy)
}
}
}
func TestLabelsRestored(t *testing.T) {
parent := pprof.WithLabels(context.Background(), pprof.Labels("route", "/parent"))
pprof.Do(parent, pprof.Labels("route", "/child"), func(ctx context.Context) {
if value, _ := pprof.Label(ctx, "route"); value != "/child" {
t.Fatal(value)
}
})
if value, _ := pprof.Label(parent, "route"); value != "/parent" {
t.Fatal(value)
}
}
func TestUnknownProfile(t *testing.T) {
err := writeProfile(t.TempDir(), "not-a-profile")
if err == nil || !strings.Contains(err.Error(), "unknown profile") {
t.Fatal(err)
}
}
var errDisk = errors.New("synthetic disk failure")
type failedWriter struct{}
func (failedWriter) Write([]byte) (int, error) { return 0, errDisk }
func TestSnapshotWriteFailure(t *testing.T) {
err := pprof.Lookup("goroutine").WriteTo(failedWriter{}, 0)
if !errors.Is(err, errDisk) {
t.Fatalf("got %v", err)
}
}run.sh
代码块 · 35 行 · 点击收起 / 展开
bash
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
export GOMAXPROCS=2
export TZ=UTC
go version
go env GOOS GOARCH CGO_ENABLED
go test -count=1 -v ./...
go test -race -count=1 ./...
go vet ./...
go build -trimpath -ldflags='-X main.revision=demo-v1' -o "$tmp/demo" .
"$tmp/demo" -out "$tmp/profiles"
cat "$tmp/profiles/metadata.json"
echo '--- CPU ---'
go tool pprof -top -nodecount=7 "$tmp/demo" "$tmp/profiles/cpu.pprof"
echo '--- labels ---'
go tool pprof -tags "$tmp/demo" "$tmp/profiles/cpu.pprof"
echo '--- live heap ---'
go tool pprof -top -sample_index=inuse_space -nodecount=5 "$tmp/demo" "$tmp/profiles/heap.pprof"
echo '--- cumulative allocation ---'
go tool pprof -top -sample_index=alloc_space -nodecount=5 "$tmp/demo" "$tmp/profiles/heap.pprof"
echo '--- block ---'
go tool pprof -top -nodecount=5 "$tmp/demo" "$tmp/profiles/block.pprof"
echo '--- mutex ---'
go tool pprof -top -nodecount=5 "$tmp/demo" "$tmp/profiles/mutex.pprof"
echo '--- overwrite negative fixture ---'
set +e
"$tmp/demo" -out "$tmp/profiles"
code=$?
set -e
printf 'second_run_exit=%s\n' "$code"
test "$code" -eq 1
echo 'raw profiles and binary are removed by EXIT trap'