Skip to content

持续剖析:把一次性能排查,变成能回看时间和版本的证据 ​

接口下午两点变慢,等到有人开始抓 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.retainMemory

churnMemory 在累计分配中很显眼,却没有成为存活堆的主要来源。它制造了需要回收的对象,不能据此叫它“内存泄漏”。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/>上传失败、丢弃、覆盖空窗"]
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
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 总量翻倍,可能只是接收了两倍请求;函数占比上升,也可能只是其他函数变快后,分母变小。只比百分比会漏掉这个变化。

可把比较拆成三层:

  1. 指标确认症状:CPU、p99、错误率是否变差,是否正好遇到限流、冷启动或容量变化。
  2. Profile 找方向:同类请求下,每次请求或每单位工作对应的 CPU、分配、竞争是否增加;样本不足时先补数据。
  3. 控制变量验证:灰度对照、相同输入的回放或可回退的实验,确认改动与退化之间的关系。

第二步提供线索,第三步才让归因更有说服力。版本、时间点与热点同时出现,只证明它们相关;请求构成、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;带版本的包链接可复查该版本。产品网页为当日官方文档,未来可能变化。

完整实验:代码、测试与运行脚本 ​

下面四个文件放在同一个目录。只依赖标准库。需要 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.2

main.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'

保留各书来源与许可 · 示例运行条件以正文为准