Linux 基础体系 · 第 79/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。

Linux perf 与火焰图:采样、调用栈、符号、CPU 和 Off-CPU

perf 是 Linux 内核性能事件接口的用户态工具。它可以从硬件 PMU、内核 tracepoint、软件计数器和动态探针中采集事件,再通过采样记录“某个时刻正在发生什么”。火焰图则是一种把大量调用栈样本按层级聚合、按宽度可视化的图形。

二者经常一起使用,但需要先区分三个问题:

  1. CPU 时间花在哪里:线程被 CPU 执行时,采样它的调用栈。
  2. 线程为什么没有运行:线程处于阻塞、睡眠、锁等待或调度延迟时,记录它从运行态离开的原因和等待时长。
  3. 采样中的地址如何变成函数名:依赖可执行文件、共享库、内核和调试符号。

CPU 火焰图主要回答第一个问题;Off-CPU 分析主要回答第二个问题。它们使用不同的事件和解释方式,不能把一张 CPU 火焰图当成完整的线程耗时图。


一、从一次采样开始:perf 实际记录了什么

1.1 采样对象不是“函数执行时间”

假设一个线程在时间区间 TT 内运行,perf 以频率 ff 进行采样。理想情况下,采样点近似均匀分布在该线程实际占用 CPU 的时间上。

对函数 gg,设它的包含调用栈区域为:只要采样时调用栈中出现 gg,就算一次命中。若总样本数为 NN,命中 gg 的样本数为 ngn_g,则 CPU 时间占比可估计为:

p^g=ngN\hat{p}_g = \frac{n_g}{N}

如果线程在这段时间中实际运行了 RR 秒,则:

t^g=R×p^g\hat{t}_g = R \times \hat{p}_g

这里的 tgt_g 是函数 gg包含时间,也叫 inclusive time:包括 gg 自己执行的时间以及它调用的子函数执行时间。

而函数自身的排他时间(exclusive time)近似为:

p^gexclusive=调用栈顶端恰好处于 g 的样本数N\hat{p}^{exclusive}_g = \frac{\text{调用栈顶端恰好处于 }g\text{ 的样本数}}{N}

火焰图通常使用“调用栈折叠后的样本数量”表示宽度。因此:

  • 一层函数的宽度表示包含它的样本数;
  • 最顶部的函数通常代表采样发生时真正运行的叶子位置;
  • 父函数比子函数宽,是因为父函数包含子调用的时间。

采样不是逐条指令计时,也不是由 perf 记录每次函数进入和退出。它通过统计估计热点,因此结果会有随机误差和系统性偏差。

1.2 采样频率、采样周期和误差

命令:

perf record -F 99 -p "$PID" -g -- sleep 30

其中:

  • -F 99 请求大约每秒 99 次采样;
  • -p "$PID" 监控指定进程的线程;
  • -g 记录调用栈;
  • sleep 30 是采集持续时间,不是被监控进程;
  • 结果通常写入当前目录的 perf.data

如果样本近似独立,某个比例估计的标准误差近似为:

SE(p^)p(1p)NSE(\hat{p}) \approx \sqrt{\frac{p(1-p)}{N}}

例如真实热点占比 p=0.25p=0.25,采集到 N=9,000N=9{,}000 个样本,则:

SE0.25×0.7590000.0046SE \approx \sqrt{\frac{0.25 \times 0.75}{9000}} \approx 0.0046

约为 0.46 个百分点。样本越多,随机误差通常越小。

但实际系统并不满足完全均匀、独立采样:

  • 周期性定时器可能和程序周期发生相位锁定;
  • 某些短函数在采样间隔内执行完毕,可能完全不被观察到;
  • 采样发生在中断、抢占或内核路径中时,调用栈上下文会不同;
  • 高负载时 perf 缓冲区可能丢样本;
  • 频率模式会受到内核调节,实际采样率不一定严格等于请求值。

因此,99 Hz 不是“精确测量每个函数 1% 时间”的保证,而是一个开销和统计精度之间的取舍。

1.3 频率模式与周期模式

perf record 可以按频率或事件周期采样:

perf record -F 99 -p "$PID" -- sleep 30

表示请求每秒约 99 次,内核会根据调度和事件计数调整周期。

也可以指定硬件事件和周期:

perf record -e cycles -c 1000000 -p "$PID" -- sleep 30

这里每累计约 1,000,000 个 CPU cycle 触发一次采样。cycles 常对应硬件 PMU 的 CPU 周期事件,但具体可用名称和实现取决于处理器与内核。可查看:

perf list

周期模式更直接地按某个事件加权。例如按 instructions 采样,结果更接近“指令退休位置”的分布;按 cache-misses 采样,则是在问“哪些调用栈更容易产生缓存未命中”。

不同事件不是同一个问题:

perf stat -e cycles,instructions,branches,branch-misses,cache-misses \
  -- ./app

典型解释:

  • cycles:消耗的 CPU 周期;
  • instructions:退休的机器指令数;
  • branches:分支指令数;
  • branch-misses:分支预测失败;
  • cache-misses:由 PMU 定义的缓存未命中事件。

硬件事件的精确定义、可计数数量和是否支持精确采样(PEBS、IBS 等)依赖 CPU 型号。不能把不同架构上的同名事件当成完全等价的指标。


二、调用栈:一个样本如何变成一条路径

2.1 调用栈的含义

如果采样时程序处于如下路径:

main
└── serve
    └── parse_request
        └── malloc

一次样本记录的是这条调用关系,而不是只有 malloc 一个函数。把许多样本按调用路径聚合后,才能知道:

  • malloc 是由哪个业务路径调用的;
  • 同一个库函数是否被多个上层路径使用;
  • 热点属于通用函数本身,还是某个调用者传入了特殊数据;
  • 内核时间是由系统调用、网络协议栈还是文件系统触发的。

调用栈通常有两部分:

用户态栈:应用函数 -> 共享库 -> libc
内核态栈:系统调用入口 -> 内核子系统 -> 驱动

一次样本可能只包含用户态栈,也可能同时包含用户态和内核态栈,取决于事件、权限、采集选项和调用栈展开方式。

2.2 三种常见调用栈展开方式

Frame pointer:fp

perf record --call-graph fp -p "$PID" -- sleep 30

这种方式依赖编译器在函数栈帧中保留 frame pointer。典型编译参数是:

-fno-omit-frame-pointer

优点是开销较低、展开较快。缺点是如果程序使用 -fomit-frame-pointer,或者部分函数通过优化破坏了传统栈帧,调用栈可能截断或错误。

现代编译器常在优化构建中省略 frame pointer,因此不能假设所有发行版二进制都可用 fp

DWARF:dwarf

perf record --call-graph dwarf,16384 -p "$PID" -- sleep 30

DWARF 展开通常会在采样时保存一段用户栈,再使用调试展开信息恢复调用关系。16384 是每次采样保存的用户栈字节数示例,不同程序需要不同大小。

优点:

  • 不要求 frame pointer;
  • 对复杂编译优化、内联和非传统栈帧通常更可靠。

代价:

  • 采样记录更大;
  • 采集和解析开销通常更高;
  • 栈过深或保存空间太小会出现截断。

Last Branch Record:lbr

部分 x86 处理器支持 LBR:

perf record --call-graph lbr -p "$PID" -- sleep 30

LBR 记录最近的分支跳转历史,适合特定硬件和内核条件下的用户态调用链或控制流分析。它不是所有 CPU 都支持,也不能把它当作跨平台的通用栈展开方法。使用前应检查:

perf list | grep -i branch

一般选择顺序是:

  1. 已知程序保留 frame pointer,使用 fp
  2. 不能确认时,使用 dwarf
  3. 有明确硬件和分析需求时,再考虑 lbr

2.3 调用栈失败时的表现

调用栈问题常见表现包括:

unknown
[unknown]

或者火焰图中大量栈只剩下:

main
libc.so.6

这可能由以下原因造成:

  • 没有调试展开信息;
  • frame pointer 被省略;
  • 采样时用户栈或内核栈无法访问;
  • 进程在采样期间退出;
  • 栈深超过保存空间;
  • JIT 代码没有向 perf 提供映射;
  • 容器内进程的文件系统视图与采集端不一致。

验证调用栈不能只看火焰图,应先检查原始报告:

perf report --stdio

再查看采样记录的元信息:

perf script --header

如果报告中已经是 [unknown],后续再运行火焰图脚本不会自动修复符号问题。


三、符号:从指令地址恢复函数和源码位置

3.1 符号解析的链路

CPU 采样首先得到地址,例如:

0x00007f2a1c123456

要显示为函数名,工具需要知道:

  1. 这个地址属于哪个进程映射;
  2. 映射对应哪个 ELF 文件或内核模块;
  3. 地址相对于该文件的偏移;
  4. ELF 符号表或 DWARF 信息如何将偏移映射到函数;
  5. 如果需要源码行,还要有行号调试信息。

因此,“能展开调用栈”和“能显示函数名”是相关但不同的问题:

  • 栈展开解决“调用者是谁”;
  • 符号解析解决“这个地址对应什么函数”。

一个被 strip 的 ELF 文件可能仍然保留足够的动态符号用于显示部分函数,但通常会缺少完整函数名、内联信息和源码行。

3.2 用户态二进制和共享库

推荐为待分析程序保留独立调试信息:

gcc -O2 -g -fno-omit-frame-pointer demo.c -o demo

其中:

  • -g 生成 DWARF 调试信息;
  • -fno-omit-frame-pointerfp 调用栈更可靠;
  • -O2 保留接近生产环境的优化行为。

生产环境通常不会把完整调试信息放进主二进制,而是将其拆分到 debug 包。不同发行版的包名不同,但思路相同:目标机器或分析机器必须能够找到与运行中 ELF 完全匹配的调试文件。

“同名文件”不等于“同一个二进制”。构建 ID、校验和、发行版本和编译参数都可能不同。二进制更新后,如果只保留旧的 perf.data 而删除旧 ELF,历史地址可能无法正确解析。

可以使用:

readelf -n ./demo | grep -A1 'Build ID'
file ./demo

查看构建 ID 和文件基本信息。perf 通常使用 build-id 缓存和 ELF 信息关联采样地址,因此保留采集时对应的构建产物非常重要。

3.3 内核符号、模块和内核地址隐藏

分析内核路径通常需要:

  • /proc/kallsyms 中的内核符号;
  • 与运行内核匹配的 vmlinux
  • 内核模块的符号;
  • 必要时的内核调试信息。

发行版可能将 vmlinux 和 debug 信息放在单独的 debug 包中。若启用了内核地址隐藏,普通用户可能只能看到受限信息。

检查:

uname -r
cat /proc/sys/kernel/kptr_restrict
ls -l /proc/kallsyms

内核符号缺失时,报告可能显示:

[unknown] 

或者只有十六进制地址。不要通过随意修改生产内核安全参数来“修复”报告。更稳妥的方式是使用经过授权的 debug 包、受控分析节点和最小权限。

3.4 容器和命名空间中的符号

容器中的进程可能使用宿主机采集,但其可执行文件和共享库路径只存在于容器文件系统。此时需要:

  • 在采集时保留容器中的 ELF 文件;
  • 在分析机上建立相同路径或使用对应的符号查找方式;
  • 记录镜像 digest,而不是只记录镜像 tag;
  • 对 JIT 程序保留 JIT dump 或运行时生成的符号映射。

容器隔离不会改变 CPU 上执行的指令,但会改变用户态文件路径、PID 视图和权限边界。


四、一个可运行的 CPU 火焰图流程

下面先构造一个包含递归计算和字符串处理的示例程序。

// demo.c
#include <stdio.h>
#include <stdint.h>
#include <unistd.h>

__attribute__((noinline))
static uint64_t fib(unsigned n)
{
    if (n < 2)
        return n;
    return fib(n - 1) + fib(n - 2);
}

__attribute__((noinline))
static uint64_t work(void)
{
    uint64_t total = 0;

    for (unsigned i = 0; i < 1000; i++)
        total += fib(24);

    return total;
}

int main(void)
{
    for (;;) {
        volatile uint64_t result = work();
        (void)result;
        usleep(1000);
    }
}

编译:

gcc -O2 -g -fno-omit-frame-pointer demo.c -o demo
./demo &
PID=$!

这里使用 noinline 是为了减少编译器把 fibwork 内联掉的可能性;volatile 防止结果被过度优化。不同编译器版本和优化设置仍可能影响最终代码,因此应以 perf report 中的实际符号为准。

采集 CPU 样本:

perf record -F 99 --call-graph fp -p "$PID" -- sleep 20

如果程序或共享库没有可靠 frame pointer,可以改用:

perf record -F 99 --call-graph dwarf,16384 -p "$PID" -- sleep 20

先查看文本报告:

perf report --stdio

可能看到类似结构:

  70.00%  demo  demo  [.] fib
  28.00%  demo  demo  [.] work
   2.00%  libc  libc  [.] __libc_usleep

这里的百分比是样本比例,不是某个函数被精确计时后的结果。fib 宽,说明采样时多数调用栈都处于 fib 或其递归子调用中;work 的包含比例会覆盖 fib 的样本,因此父子比例不能简单相加。

4.1 生成折叠栈和 SVG

常用火焰图工具链由三步构成:

perf.data
   │
   └── perf script
          │
          └── stackcollapse-perf.pl
                    │
                    └── flamegraph.pl
                              │
                              └── flame.svg

取得 FlameGraph 脚本后:

git clone https://github.com/brendangregg/FlameGraph.git

生成折叠栈:

perf script | ./FlameGraph/stackcollapse-perf.pl > cpu.folded

生成 SVG:

./FlameGraph/flamegraph.pl \
  --title "CPU profile" \
  --countname samples \
  cpu.folded > cpu.svg

折叠栈中的一行通常类似:

main;work;fib;fib;fib  1532

分号左侧是从根到叶子的调用路径,右侧是该路径的样本数。火焰图脚本会把公共前缀和后缀聚合成矩形。

查看 SVG:

python3 -m http.server 8000 --directory .

然后在浏览器打开 http://127.0.0.1:8000/cpu.svg。如果在远程服务器上分析,应通过受控的端口转发或下载文件,不要直接暴露调试服务到公网。

停止示例程序:

kill "$PID"
wait "$PID" 2>/dev/null || true

4.2 火焰图的坐标和宽度

传统 CPU 火焰图中:

  • 横轴通常不是时间轴;
  • 左右位置主要是聚合布局,不表示调用先后;
  • 纵轴表示调用深度;
  • 矩形宽度表示样本数;
  • 鼠标悬停通常可以查看函数名和样本比例。

因此,不能说“图中最右边的函数最后执行”,也不能用横轴距离推断两个函数之间的时间间隔。它是聚合统计图,不是时序跟踪图。


五、CPU 火焰图到底回答什么

5.1 On-CPU 的定义

On-CPU 指线程正在某个逻辑 CPU 上执行指令的时间。对一个用户线程而言,这段时间可能执行:

  • 用户态应用代码;
  • libc 或其他共享库;
  • 系统调用进入的内核代码;
  • 内核中的文件系统、网络协议栈或驱动代码;
  • 中断或异常相关路径。

如果采样事件是 cycles,火焰图近似表示“CPU 周期在哪里消耗”;如果是 instructions,则更接近“退休指令在哪里出现”。

perf record -p PID 通常按目标进程的线程采样。若目标进程在等待锁、睡眠或等待 I/O,线程不在 CPU 上执行,因此这段等待时间不会自然出现在 CPU 火焰图中。

这解释了一个常见现象:

CPU 火焰图看起来很窄,但请求延迟很高。

程序可能不是 CPU 计算慢,而是在等待数据库、磁盘、网络、锁或调度。

5.2 进程、线程和 CPU 范围

常用目标范围:

# 监控一个进程及其线程
perf record -p "$PID" -g -- sleep 20

# 监控指定线程
perf record -t "$TID" -g -- sleep 20

# 监控指定 CPU
perf record -C 2 -g -- sleep 20

# 系统范围
sudo perf record -a -g -- sleep 20

区别很重要:

  • -p 关注进程的线程;
  • -t 关注单个线程;
  • -C 关注某些逻辑 CPU 上发生的事件,不限进程;
  • -a 关注系统范围,可能包含其他租户、内核线程和中断。

多线程程序中,如果只监控主线程,可能错过真正消耗 CPU 的工作线程。相反,系统范围采集得到的“热点”可能来自其他进程,分析时应检查 perf report 的进程名、线程名和 CPU 信息。

5.3 每个 CPU 的火焰图与聚合火焰图

在多核系统中,同一进程可能被调度到不同逻辑 CPU。系统范围报告通常可以按:

  • 进程;
  • 线程;
  • CPU;
  • 用户态/内核态;
  • 事件类型;

进行筛选。高性能服务出现单核热点时,聚合所有 CPU 可能掩盖问题,例如:

  • 一个线程被绑定到 CPU 2;
  • CPU 2 上有软中断或 IRQ 干扰;
  • NUMA 远端内存访问只影响部分 CPU;
  • cgroup 配额使某些 CPU 上的调度行为不同。

可以先使用:

perf stat -a -e cycles,instructions,context-switches,cpu-migrations \
  -- sleep 10

观察全局行为,再用 perf report 或拆分数据分析具体线程和 CPU。


六、硬件事件、PMU 与“热点”的不同含义

6.1 cycles 热点不等于业务复杂度

一个函数在 cycles 火焰图中很宽,可能因为:

  • 执行了大量指令;
  • 频繁发生缓存未命中;
  • 分支预测失败;
  • 等待执行端口或内存;
  • CPU 降频;
  • 被硬件资源争用。

因此,CPU 周期热点通常需要和计数器一起解释:

perf stat -e \
  cycles,instructions,branches,branch-misses,cache-references,cache-misses \
  -p "$PID" -- sleep 10

常用派生指标:

IPC=instructionscyclesIPC = \frac{\text{instructions}}{\text{cycles}}

IPC 低可能表示内存停顿、分支问题、执行端口受限或其他微架构瓶颈,但仅凭 IPC 不能确定具体原因。

例如:

  • cycles 高、instructions 高、IPC 中等:可能确实执行了很多计算;
  • cycles 高、instructions 不高、缓存未命中高:可能是内存停顿;
  • 分支未命中比例高:可能是分支不可预测;
  • 事件值异常低:可能是 PMU 不支持该事件、被虚拟化、被 multiplex,或权限受限。

6.2 事件复用和计数缩放

现代 CPU 可同时计数的硬件事件数量有限。指定太多事件时,内核会 multiplex,在不同事件之间轮换。perf stat 通常会显示类似:

<not counted>

或带有缩放百分比。采样事件也可能受复用影响。

比较两个版本时,必须确认:

  • 使用了相同 CPU 和内核;
  • 事件实际运行时间接近 100%;
  • 没有因频繁复用导致样本不足;
  • 进程负载和输入数据一致。

硬件事件名称来自架构实现,不应把跨机器的原始数值直接比较为绝对性能结论。


七、Off-CPU:线程不运行时发生了什么

7.1 Off-CPU 的定义

Off-CPU 是线程没有占用 CPU 执行的时间。它可能处于:

  • sleep 或定时等待;
  • 等待互斥锁、读写锁、futex;
  • 等待文件 I/O;
  • 等待 socket 数据或发送缓冲区;
  • 等待条件变量;
  • 等待其他线程;
  • 被调度器延迟运行;
  • 因 cgroup CPU 配额、优先级或 CPU 竞争而未及时运行。

Off-CPU 时间不是单一原因,也不是“CPU 空闲时间”。当线程等待锁时,CPU 可能正在执行另一个线程;当系统整体过载时,线程可能在运行队列中等待调度。

7.2 为什么 CPU 火焰图看不到等待

设一个请求生命周期如下:

t0       t1                 t2       t3
│--------│------------------│--------│
  CPU       等待锁/IO          CPU

CPU 火焰图只在 [t0,t1][t0,t1][t2,t3][t2,t3] 期间采样线程调用栈,而 [t1,t2][t1,t2] 期间线程没有在 CPU 上运行。因此它可能显示:

handler -> parse -> compute

但不会自动显示中间等待了数据库或锁。

更完整的请求延迟分解是:

Trequest=TonCPU+Tblocked+Trunnable+Tremote+TotherT_{request} = T_{onCPU} + T_{blocked} + T_{runnable} + T_{remote} + T_{other}

其中:

  • TonCPUT_{onCPU}:线程实际执行;
  • TblockedT_{blocked}:睡眠、锁等待、I/O 等阻塞;
  • TrunnableT_{runnable}:已可运行但尚未获得 CPU;
  • TremoteT_{remote}:等待远程服务;
  • TotherT_{other}:用户态计时和观测边界之外的部分。

CPU 火焰图主要估计第一项。Off-CPU 分析主要观察第二项,有些调度分析还能观察第三项。远程服务耗时则需要网络或分布式链路证据,不能仅凭本机 Off-CPU 栈判断。


八、从调度事件理解 Off-CPU 采集

线程状态可以抽象为:

stateDiagram-v2
    [*] --> Running
    Running --> Sleeping: 阻塞系统调用/等待锁
    Running --> Runnable: 被抢占或时间片结束
    Runnable --> Running: 调度器选择该线程
    Sleeping --> Runnable: I/O完成/锁释放/超时
    Sleeping --> [*]: 线程退出
    Running --> [*]: 线程退出

关键路径是:

  1. 线程正在运行;
  2. 内核记录它离开 CPU 的原因;
  3. 线程处于 Sleeping 或 Runnable;
  4. 某个事件使它唤醒或获得调度;
  5. 记录等待开始和结束;
  6. 用等待期间关联的调用栈聚合。

perf 可以直接观察调度事件。例如:

sudo perf sched record -p "$PID" -- sleep 20
sudo perf sched timehist

perf sched 适合查看调度延迟、运行队列等待和线程切换。不同内核和 perf 版本的输出列略有差异,但通常包含线程、运行时间、等待时间、调度延迟等信息。

也可以检查 tracepoint 是否存在:

perf list | grep -E 'sched:(sched_switch|sched_wakeup)'

原始调度事件:

sudo perf record -e sched:sched_switch,sched:sched_wakeup \
  -p "$PID" -- sleep 20

perf script

但仅有 sched_switch 事件并不能自动产生漂亮的 Off-CPU 火焰图。还需要:

  • 找到目标线程离开 CPU 的时间;
  • 找到它重新变为可运行或重新运行的时间;
  • 将两者配对;
  • 保存阻塞前的调用栈;
  • 处理线程退出、重复唤醒、迁移和丢事件;
  • 计算持续时间并生成折叠栈。

这也是 Off-CPU 工具通常使用 eBPF、内核 tracepoint 或专门的跟踪逻辑的原因。


九、使用 eBPF 工具生成 Off-CPU 数据

现代发行版常提供 BCC 或 bpftrace,但工具名和包名存在差异。例如 Debian/Ubuntu 系列可能使用 bcc-toolsbpfcc-tools,RPM 系列可能使用不同的子包。应先确认:

command -v offcputime
command -v offcputime-bpfcc
offcputime --help 2>/dev/null || offcputime-bpfcc --help

某些 BCC 版本可以这样输出目标进程的折叠栈:

sudo offcputime -p "$PID" -f 30 > offcpu.folded

也有发行版命令名不同:

sudo offcputime-bpfcc -p "$PID" -f 30 > offcpu.folded

这里的 -p 指定进程,-f 在常见 BCC 实现中表示 folded 输出并带持续时间窗口;具体参数必须以本机 --help 为准,因为 BCC 工具选项随版本和打包方式可能不同。

若输出是可被 FlameGraph 处理的折叠格式,可以生成图:

./FlameGraph/flamegraph.pl \
  --title "Off-CPU profile" \
  --countname "milliseconds" \
  --color=io \
  offcpu.folded > offcpu.svg

Off-CPU 折叠栈的值通常是毫秒或微秒,而 CPU 火焰图的值通常是样本数。不要把两个 SVG 的宽度直接比较为同一物理量。

9.1 Off-CPU 图中的栈应该如何解释

假设 Off-CPU 图出现:

main;server_loop;pthread_mutex_lock  12500

它表示:从这条栈离开 CPU 后,关联到的等待时间累计较多。它不一定意味着 pthread_mutex_lock 函数本身执行很久,而是该调用路径导致线程进入等待。

如果出现:

main;handle_request;read  18000

可能表示线程在 read 相关路径等待数据,但还需要结合:

  • 文件描述符类型;
  • 读的是磁盘、管道还是 socket;
  • I/O 完成时间;
  • 设备延迟;
  • 对端是否发送数据。

调用名只能提供线索,不能单独证明根因。

9.2 Off-CPU 的配对和误差

Off-CPU 工具必须处理以下边界:

  • 线程离开 CPU 后退出,可能没有对应唤醒事件;
  • 线程在等待期间被多次唤醒和重新阻塞;
  • 线程从 Sleeping 变为 Runnable 后又等待一段调度时间;
  • 同一个 TID 被回收后重新使用;
  • 线程在 CPU 之间迁移;
  • 采集缓冲区丢事件;
  • 只过滤 PID 时,需要确认工具按进程、线程还是 cgroup 过滤;
  • 用户栈展开失败时,等待原因仍可能被计数,但栈显示不完整。

因此,Off-CPU 工具的输出应和 perf sched timehist、应用延迟指标、锁指标或 I/O 指标交叉验证。


十、CPU 火焰图和 Off-CPU 火焰图的对照

假设一个请求耗时 100 ms:

阶段 时间
解析请求 2 ms
计算 8 ms
等待锁 40 ms
等待数据库响应 45 ms
线程重新运行和发送响应 5 ms

CPU 火焰图可能主要显示:

handle_request;compute       8%
handle_request;send_response 5%
handle_request;parse         2%

Off-CPU 图可能显示:

handle_request;mutex_lock    40 ms
handle_request;recv          45 ms

如果只看 CPU 火焰图,可能错误地优化 parse 的 2 ms;如果只看 Off-CPU 图,又可能把远程数据库延迟误认为本地 recv 函数慢。

合理的证据链是:

  1. 应用指标确认延迟升高;
  2. CPU 火焰图确认是否存在本地计算热点;
  3. Off-CPU 分析确认本地等待类型;
  4. 锁、磁盘、网络或数据库指标验证等待对象;
  5. 修改后分别重新测量 On-CPU 和 Off-CPU。

十一、采样调用栈的生产风险

11.1 权限限制

perf 受内核权限策略控制。常见参数:

cat /proc/sys/kernel/perf_event_paranoid

不同发行版默认值不同。限制较严格时,普通用户可能无法:

  • 访问系统范围事件;
  • 访问其他用户进程;
  • 使用内核态采样;
  • 使用某些硬件 PMU 能力。

现代内核还可能使用 CAP_PERFMON 等能力进行细粒度授权。不要默认使用:

sudo sysctl -w kernel.perf_event_paranoid=-1

因为这会扩大整个系统的性能观测权限。生产环境更适合:

  • 只授权给指定运维组;
  • 只采集指定 PID、TID 或 cgroup;
  • 只在短时间窗口运行;
  • 采集完成后恢复临时配置;
  • 保存审计记录。

11.2 采集开销和数据量

采样开销来自:

  • 触发事件;
  • 保存寄存器;
  • 保存调用栈;
  • 写入 perf ring buffer;
  • 用户态读取和后处理;
  • DWARF 栈展开。

过高频率和过深调用栈会增加开销。生产采集应观察:

perf record -F 99 --call-graph dwarf,16384 -p "$PID" -- sleep 20

完成后检查:

perf report --stdio --percent-limit 0.5

如果担心丢样本,可检查 perf script 或报告中的丢失提示,并减少频率、缩短窗口或增大缓冲区。不能因为“采样工具通常开销很小”就忽略低延迟服务、实时线程和高频系统调用场景。

11.3 数据泄露

调用栈和参数周边信息可能暴露:

  • 服务内部函数名;
  • 文件路径;
  • 用户态库版本;
  • 内核模块;
  • JIT 代码映射;
  • 线程名和租户信息。

perf.data 本身也可能包含进程、线程、映射和命令行元数据。生产数据上传到分析环境前,应限制访问并评估是否需要脱敏。


十二、常见误区与反例

12.1 “火焰图最宽的函数就是最慢的函数”

反例:

request
├── parse        1 ms
└── database     99 ms,其中本地线程只运行了 2 ms

如果数据库调用期间线程阻塞,CPU 火焰图可能几乎看不到 99 ms。火焰图最宽的本地函数可能只是 parse,但优化它只能减少 1 ms。

正确做法是分别测量:

  • CPU 执行时间;
  • 本地阻塞时间;
  • 远程服务时间;
  • 调度等待时间。

12.2 “父函数和子函数宽度相加就是总时间”

假设 100 个样本中:

  • A 栈中出现 100 次;
  • BA 调用的子函数,出现 70 次。

图中 A 宽度为 100,B 宽度为 70。若把两者相加,会得到 170 个样本,但实际只有 100 次采样。父子区间存在包含关系,不能相加。

12.3 “采样频率设为 1000 Hz 就一定更准确”

更高频率通常增加样本数,但也增加开销和数据量,并不消除:

  • 周期相位偏差;
  • 调用栈缺失;
  • JIT 符号问题;
  • 事件复用;
  • 负载变化;
  • 统计口径错误。

先保证调用栈和符号正确,再根据目标热点大小和观测窗口调整频率。

12.4 “看到 malloc 宽,就说明 malloc 实现效率差”

malloc 可能只是所有对象分配路径的汇合点。需要继续查看它的上层调用者,并结合:

perf record -e cycles --call-graph dwarf -p "$PID" -- sleep 20
perf report --stdio

如果要分析分配次数和大小,CPU 采样通常不够,还需要应用分配器统计、内存分析器或 eBPF/uprobes 等其他证据。

12.5 “Off-CPU 栈显示 futex,所以锁一定有问题”

futex 是用户态同步原语进入内核等待的通用路径。它可能对应:

  • 互斥锁竞争;
  • 条件变量;
  • 线程池任务等待;
  • 其他用户态同步实现。

需要结合锁持有者、锁等待时间、线程状态和应用代码定位真正的共享资源。futex 只是内核等待机制,不是具体业务锁名。

12.6 “没有符号时把地址强行映射成函数名即可”

如果二进制版本不匹配,地址映射可能生成看似合理但实际错误的函数名。尤其在:

  • 二进制重新链接;
  • 共享库升级;
  • ASLR;
  • JIT 代码变化;
  • 容器镜像变化;

之后,地址只有在对应映射和 build-id 匹配时才有意义。


十三、一个系统化的诊断流程

步骤一:先确认问题属于哪类时间

使用应用指标和基础统计确认:

pidstat -p "$PID" -u -w -d 1

关注:

  • %CPU:是否确实消耗 CPU;
  • 上下文切换;
  • I/O 等待线索;
  • 线程级别差异。

如果 %CPU 高,先做 CPU 采样;如果 %CPU 低但延迟高,优先做 Off-CPU、I/O、网络和远程依赖分析。

步骤二:先用短窗口确认权限和栈

perf record -F 49 --call-graph fp -p "$PID" -- sleep 5
perf report --stdio

先不用高频率和很深的 DWARF 栈。确认:

  • 命令有权限;
  • perf.data 成功生成;
  • 函数名可解析;
  • 调用栈没有大面积截断;
  • 采集对象确实是目标线程。

步骤三:根据栈质量选择展开方式

如果 fp 栈明显截断:

perf record -F 99 --call-graph dwarf,16384 -p "$PID" -- sleep 20

如果 DWARF 数据量过大,可以降低频率、缩短窗口或减少栈大小。不要为了生成完整 SVG 而无限增大采集参数。

步骤四:关联硬件或软件事件

CPU 计算热点:

perf record -e cycles --call-graph dwarf -p "$PID" -- sleep 20

系统调用和调度线索:

sudo perf sched record -p "$PID" -- sleep 20
sudo perf sched timehist

计数器概览:

perf stat -p "$PID" \
  -e cycles,instructions,context-switches,cpu-migrations \
  -- sleep 10

每个命令都在回答不同问题,不应把 perf stat 的聚合计数直接当成调用栈热点。

步骤五:生成两类图并验证

CPU 图:

perf script |
  ./FlameGraph/stackcollapse-perf.pl |
  ./FlameGraph/flamegraph.pl --title "On-CPU" > oncpu.svg

Off-CPU 图则使用支持调度状态关联的工具,并确认其输出单位、过滤范围和栈来源。生成图后,至少抽查:

  • 进程和线程是否正确;
  • 时间单位是否正确;
  • 是否出现大量 [unknown]
  • 是否有缓冲区丢失;
  • 采集窗口内负载是否稳定。

十四、把 perf 与 eBPF 证据链连接起来

perf 擅长基于 PMU 和内核事件进行低侵入采样;eBPF 可以在 tracepoint、kprobe、uprobe、USDT 等位置记录更具体的上下文。二者可以互补:

应用延迟
   │
   ├── perf record:On-CPU 调用栈
   ├── perf sched:调度和运行队列
   ├── eBPF:锁、I/O、网络、系统调用参数
   └── perf stat:cycles、instructions、cache、branch

例如 CPU 火焰图显示线程在 read 路径上消耗 CPU,但这并不说明磁盘慢。可以再观察块设备延迟、文件系统事件、socket 类型和进程状态。

反过来,Off-CPU 图显示线程在 futex 等待,也不能直接判断是锁竞争还是条件变量等待。应结合 eBPF 记录锁竞争、线程唤醒、持锁者或应用级锁名称。

观测工具之间的边界需要明确:

  • perf 的硬件事件受 PMU 和权限影响;
  • eBPF 程序受 verifier、内核版本和 BTF 能力影响;
  • kprobe 依赖内核符号和实现细节;
  • tracepoint 接口通常比 kprobe 稳定,但字段仍可能有版本差异;
  • 生产环境中都需要考虑采集开销、权限和数据敏感性。

十五、生产分析中的验证和恢复

一次采样结论至少应满足三个条件:

  1. 时间窗口与问题重合:不能在故障已经结束后采集正常流量。
  2. 符号版本匹配:采集时的 ELF、共享库、内核和模块可被恢复。
  3. 独立指标支持:热点应能和 CPU 利用率、延迟、I/O、锁或网络指标相互解释。

采集结束后,可以记录:

uname -a
perf version
perf report --header-only
cat /proc/sys/kernel/perf_event_paranoid

如果临时修改过权限或其他内核配置,应恢复原值:

OLD=$(cat /proc/sys/kernel/perf_event_paranoid)
# 在受控窗口内进行修改和采集
# 采集结束后恢复:
sudo sysctl -w kernel.perf_event_paranoid="$OLD"

更可靠的做法是把原值显式记录在变更系统中,而不是依赖 shell 变量,因为命令可能跨会话执行。


十六、结论:先区分时间,再选择证据

perf 采样的核心是:在事件发生时记录线程上下文,用大量样本估计 CPU 或某类事件在调用栈中的分布。调用栈展开决定“路径是否完整”,符号解析决定“地址是否可读”,硬件事件决定“热点的物理含义”。

火焰图的宽度表示聚合值,不表示横轴时间,也不能把父子函数宽度相加。CPU 火焰图描述 On-CPU 执行位置;Off-CPU 分析描述线程离开 CPU 后的等待或调度路径。两者必须分开采集、分开解释,再和应用、调度、锁、I/O、网络及远程依赖指标关联。

遇到性能问题时,最先要问的不是“哪张图最宽”,而是:

这段延迟发生在 CPU 执行、内核阻塞、运行队列等待,
还是远程依赖之外?

只有先回答这个时间归属问题,采样频率、调用栈方式、符号文件、PMU 事件和 Off-CPU 工具的选择才有明确依据。


系列导航与关联阅读

官方资料

本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。