

同一段任务的耗时变成了昨天的两倍，先问它实际用了多少 CPU，再问 CPU 时间花在哪些调用栈上。若线程大部分时间都在睡眠，接下来要追的是唤醒、系统调用或某个函数的延迟。`perf stat`、`perf record` 和 eBPF 追踪能分别回答这些问题，三者的结果不能直接互换。

<!--more-->

## perf stat：计数器能说明什么

`perf stat` 给出采集期间各类事件的计数。Linux 通过 `perf_event_open()` 暴露多类事件：`task-clock`、`context-switches`、`page-faults` 是内核维护的软件事件；`cycles`、`instructions` 等则通常来自 CPU 的性能监控单元（PMU）。PMU 提供数量有限的性能监控计数器（PMC），内核选择事件并安排它们占用计数器。比较两个版本的同一任务时，可以先看 CPU 时间和经过时间，再结合可用的硬件事件：

```bash
perf stat -e task-clock,context-switches,page-faults -- \
  sh -c 'dd if=/dev/zero bs=1M count=512 status=none | sha256sum'

perf stat -e '{cycles,instructions}' -- \
  sh -c 'dd if=/dev/zero bs=1M count=512 status=none | sha256sum'
```

命令中的选项与负载参数可以这样读，英文用于助记：

- `stat`（statistics，统计）：输出事件的累计计数。
- `-e` / `--event`（event，事件）：选择事件；逗号分隔多个事件，`{cycles,instructions}` 把两者组成同一事件组。
- `--`：结束 `perf` 的选项，后面是要运行的命令及其参数。
- `sh -c '...'`（command，命令）：让 shell 执行整条管道，使 `perf` 观察这个任务，而不只是管道的一端。
- `dd if=/dev/zero`（input file，输入文件）：从不断提供零字节的设备读取数据。
- `bs=1M`（block size，块大小）、`count=512`：每块 1 MiB，共复制 512 块，也就是 512 MiB。
- `status=none`：不打印 `dd` 的常规统计信息；数据经管道交给 `sha256sum` 计算 SHA-256 摘要。

`task-clock` 累计任务在线程被调度运行时消耗的 CPU 时间。多线程任务的累计值可以大于经过时间；反过来，经过时间变长而 CPU 时间差不多，值得进一步查阻塞或调度等待。`instructions / cycles` 是同一观测范围内平均每周期执行的指令数（IPC）。它会随指令类型、内存等待、CPU 型号和频率变化，不能只凭 IPC 给实现排优劣。`page-faults` 包含不需要磁盘读取的缺页，不能当作磁盘 I/O 次数。

同时请求的硬件事件太多时，内核可能轮流启用事件。`perf` 可按 `原始计数 × 启用时间 / 实际运行时间` 估算；各阶段的负载不均匀时，这个估算会偏。把 `cycles` 和 `instructions` 放在同一事件组，有助于让它们在相同的时间段内计数。先用 `perf list` 看可用事件，再检查结果有没有 `<not supported>`、`<not counted>` 或很低的运行比例；这些都不能当作零次事件。

### 两个命令都慢，只有一个把时间花在 CPU 上

同样用 `task-clock` 统计一个只等待的进程，再和上面的哈希任务比较：

```bash
perf stat -e task-clock,context-switches -- sleep 1
perf stat -e task-clock,context-switches -- \
  sh -c 'dd if=/dev/zero bs=1M count=512 status=none | sha256sum'
```

看两列：`seconds time elapsed` 是观察到的经过时间，`task-clock` 是被测任务累计的 CPU 时间。在 Debian 12 arm64 测试容器中，两次都约 1 秒结束，但 `sleep` 只用了约 0.23 ms 的 CPU 时间，哈希管道约用了 961 ms。若线上请求的经过时间从 100 ms 涨到 400 ms，而被测线程的 CPU 时间仍在原来范围，下一步可以去查睡眠、锁和可运行队列，继续放大 CPU 采样窗口未必能看到那段延迟。反过来，若 CPU 时间也明显上升，用 `perf record` 确认新增的工作落在哪。

如果硬件计数可用，也别只盯 IPC。举个假设：旧版处理相同输入执行 10 亿条指令、消耗 10 亿个周期，IPC 是 1；新版执行 24 亿条指令、消耗 12 亿个周期，IPC 是 2。新版每周期执行的指令更多，但总周期数也更多。是否真的变快，仍要在相同工作量下对照经过时间。

## perf record：时间主要花在哪些调用栈上

计数器只告诉你累计有多少，不能指出哪段代码消耗最多。`perf record` 则记录采样时的指令位置和可用的调用栈，`perf report` 再把同类样本合并。下面用软件时钟事件采样同一个有界任务：

```bash
perf record -e cpu-clock -F 99 --call-graph dwarf -o ./sha-perf.data -- \
  sh -c 'dd if=/dev/zero bs=1M count=512 status=none | sha256sum'
perf report --stdio -i ./sha-perf.data
```

这次采样和读报告用到以下参数：

- `-e cpu-clock`：选择软件 CPU 时钟事件。
- `-F 99` / `--freq 99`（frequency，频率）：请求每秒约 99 次采样，实际频率受内核调节。
- `--call-graph dwarf`：记录用于回溯调用栈的用户栈数据，采用 DWARF 调试信息展开；保留了帧指针的程序可以试 `--call-graph fp`，其中 fp 是 frame pointer。
- `-o ./sha-perf.data` / `--output`（output，输出）：指定采样文件。
- `--stdio`：把报告打印到终端，不进入交互式界面。
- `-i ./sha-perf.data` / `--input`（input，输入）：指定要读取的采样文件。

样本占比高，表示采样时经常落在这一段代码或调用链；样本百分比不是精确的函数耗时，也不统计每次调用。若热点只剩地址或 `[unknown]`，先检查二进制与调试符号是否匹配，再检查栈回溯是否成功。`dwarf` 的采集开销和文件大小也会增加。任务太短、样本太少时，先增加可控任务的工作量。

### 同一份采样，先看函数自己用掉多少

采集命令包含管道，上游 `dd` 和下游 `sha256sum` 都可能在样本里出现。先按命令、二进制或共享库、符号看一张不累加子调用的表：

```bash
perf report --stdio --no-children --call-graph none \
  --sort comm,dso,symbol -i ./sha-perf.data
```

- `--no-children`：不把下层调用的样本累计到上层符号，只统计采样指令所在的符号。
- `--call-graph none`：这次报告不展开调用树，仍可用原数据生成完整报告。
- `--sort comm,dso,symbol`：按命令名（comm）、动态共享对象（Dynamic Shared Object，dso，包括二进制和共享库）及符号（symbol）分组并排序。

再看默认报告中的 `Children` 列，可以追到进入热点的上层调用。假如一个请求处理函数本身只拿到少量样本，但调用栈上的哈希函数经常被采到，关闭 `Children` 后前者排名会下降。同一次采样会计入多个上层函数的 `Children`，所以不能把该列相加当成 CPU 占用。没有符号时仍可用 `comm` 区分进程，并检查 `dso` 是否能定位到实际二进制。计算口径见 [`perf report` 的 Overhead Calculation](https://man7.org/linux/man-pages/man1/perf-report.1.html#OVERHEAD_CALCULATION)。

CPU 时钟采样主要看到线程正在 CPU 上运行时的代码。若应用慢在锁、I/O 或调度队列里，睡眠的那段时间不会出现在这种 CPU 调用栈中。可以先看 [Linux CPU 和 I/O 排查](/posts/linux-performance-issue-optimization-cpu-io/)，确认症状后再挑具体事件追踪。

## eBPF：在已知位置观察事件

如果已知要问的是“这个进程是否频繁打开文件”，就不用从随机抽到的调用栈反推。可以把 eBPF 程序附加到系统调用入口，按进程过滤，在内核 map 中累计次数，结束后读出结果。BPF 程序在加载时经过内核验证；每次触发仍要执行程序，高频事件上逐条打印会增加开销，输出缓冲也可能丢事件。只想知道次数或分布时优先用聚合。

| 探针 | 放在哪里 | 适合问什么 |
| --- | --- | --- |
| `tracepoint` | 内核预先定义的事件，有命名的字段 | 某个系统调用、调度或块设备事件何时发生。先核对目标内核上的事件名和字段。 |
| `kprobe` / `kretprobe` | 动态附加到内核函数入口或返回处 | 没有对应 tracepoint 时追查一个内核函数。符号、函数原型和可附加性随内核版本与构建变化。 |
| `uprobe` / `uretprobe` | 用户态可执行文件或共享库的指令位置 | 观察某个用户态函数的调用。要选对进程实际映射的二进制；优化、内联和符号剥离会影响定位。 |
| `usdt` | 用户态程序或库在构建时埋好的命名探针 | 观察应用特有的事件，例如请求开始。要确认当前构建确实包含探针及其参数。 |

例如，在存在 `sys_enter_openat` 且允许加载探针的测试机上，用 bpftrace 查看字段，再统计一个 `cat` 进程的调用：

```bash
bpftrace -lv 'tracepoint:syscalls:sys_enter_openat'
bpftrace -e 'tracepoint:syscalls:sys_enter_openat /pid == cpid/ { @opens = count(); }' \
  -c '/bin/cat /dev/null'
```

这里的 bpftrace 参数是：

- `-l`（list，列出）：列出匹配名称的探针。
- `-v`（verbose，详细）：与 `-l` 合写为 `-lv` 时，进一步显示探针参数和字段。
- `-e '...'`：把引号中的文本作为 BPF 程序执行；与 `perf -e` 的选择事件含义不同。
- `-c '...'`（command，命令）：启动指定的子进程，子进程退出后结束追踪。

`cpid` 是 `-c` 启动的子进程 PID，`-c` 本身不会自动把探针限制在这个进程；`/pid == cpid/` 才是进程过滤条件。这里统计的是 `openat` 的进入次数，包含动态链接器等启动代码发起的调用，也包含随后失败的调用；其他打开文件的系统调用不在统计内。

### 分开看打开成功、失败和慢调用

如果一批文件操作变慢，仅看总次数无法判断是失败重试多了，还是成功打开也变慢。用同一个线程的入口和返回配对，并根据返回值分开统计。下例适用于 bpftrace 0.24，先用 `bpftrace -lv 'tracepoint:syscalls:sys_exit_openat'` 确认本机事件和 `ret` 字段存在，再运行：

```bash
bpftrace -e '
tracepoint:syscalls:sys_enter_openat /pid == cpid/ {
  @started[tid] = nsecs;
}

tracepoint:syscalls:sys_exit_openat /pid == cpid && @started[tid] != 0/ {
  $failed = args.ret < 0 ? 1 : 0;
  @opens[$failed] = count();
  @latency_us[$failed] = hist((nsecs - @started[tid]) / 1000);
  delete(@started, tid);
}

END { clear(@started); }
' -c '/bin/cat /dev/null /proc/self/.bpftrace-openat-demo-missing'
```

这个 `cat` 会成功打开 `/dev/null`，然后尝试一个不存在的文件，最后报告找不到文件。我用 `strace -e trace=openat` 确认了前者返回描述符、后者返回 `ENOENT`。`@opens[0]` 统计成功返回，`@opens[1]` 统计失败返回；动态链接器的尝试也算在内，不能预期每组恰好一次。在 Ubuntu 24.04（内核 6.8、x86_64）上实测，前一个计数示例得到 `@opens: 4`；这个示例得到 5 次成功、13 次失败，13 次失败里只有 1 次是那个不存在的文件，其余是 `cat` 打印错误信息时，libc 按 locale 逐个查找 `libc.mo` 翻译文件，`strace` 能看到同样的 13 次 `ENOENT`。`@latency_us` 的每个桶表示有多少调用落在该微秒区间，测的是从进入到返回的经过时间，包含任务中途被调度出去的时间，不能当成内核在 CPU 上工作的时间。先按成功和失败看分布，再决定是查失败路径还是查打开动作本身。

时间戳用线程 ID `tid` 作键，避免同一进程的两个线程覆盖起点。返回时找不到入口就略过，找到后立即删除。这个配对适用于单个线程的系统调用；迁移到可能递归进入的函数探针时，单靠线程 ID 只能存一层，还要处理嵌套。`hist` 和 `delete` 的语义见 [bpftrace 0.24 标准库](https://bpftrace.org/docs/release_024/stdlib)。较旧的 bpftrace 不接受 `delete(@started, tid)` 这种双参数写法：0.20.2 会报 `delete() requires one argument (2 provided)`，要改成 `delete(@started[tid])`。

## 遇到无权限和事件不可用

分别检查是否有权限、事件是否存在、能否附加。`perf_event_paranoid`、`CAP_PERFMON` 和安全策略会限制 `perf_event_open()`；使用容器时还可能被 seccomp 拒绝。允许软件事件也不代表虚拟机暴露了硬件 PMU。BPF 追踪还要看 BPF 权限、内核配置和探针可见性，例如 bpftrace 枚举 tracepoint 时需要可访问的 tracefs。`bpftrace --info` 与 `bpftrace -l` 可以帮助区分内核缺功能和当前环境看不到事件。

- `--info`（information，信息）：打印 bpftrace 检测到的内核与构建能力。
- `-l`：列出当前环境中可见的探针；与 `--info` 对照查看，不代表列出的探针一定有权限附加。

在 Debian 12.15 arm64 容器中，`perf 6.1.190` 默认返回 `No permission to enable task-clock event.`。只为隔离测试容器增加 `CAP_PERFMON` 后，上面的软件计数和采样命令都跑通：另一次运行的哈希管道统计得到约 `882 ms` 的 `task-clock`（前文的 961 ms 来自不同的一次运行），一次采集拿到 `86` 个样本，报告中的主要调用链归在 `sha256sum` 下，若干栈帧只显示地址。硬件事件组仍返回 `The cycles event is not supported.`。这些数字只描述该次运行，不是性能基准。

在同一 Linux 虚拟化环境中，测试容器无法读取 `/sys/kernel/tracing/available_events`，bpftrace 示例因此改在一台 Ubuntu 24.04 物理机上用 root 运行：bpftrace 0.20.2、内核 6.8.0，`-lv` 列出了 `sys_exit_openat` 的 `long ret` 字段，两个示例都成功附加并输出了上面的结果（第二个示例用旧的 `delete` 写法）。普通用户在这台机器上读 `/sys/kernel/tracing/events` 会被拒绝，这也是权限问题的一种表现。采集失败时，保留错误并在允许采集的测试机复核，无需为此修改宿主机的全局权限设置。

参考：[Linux perf 安全说明](https://docs.kernel.org/admin-guide/perf-security.html)、[`perf_event_open(2)`](https://man7.org/linux/man-pages/man2/perf_event_open.2.html)、[`perf-stat(1)`](https://man7.org/linux/man-pages/man1/perf-stat.1.html)、[`perf-record(1)`](https://man7.org/linux/man-pages/man1/perf-record.1.html)、[内核 tracepoint](https://www.kernel.org/doc/html/latest/trace/tracepoints.html)、[kprobe](https://www.kernel.org/doc/html/latest/trace/kprobes.html)、[uprobe](https://www.kernel.org/doc/html/latest/trace/uprobetracer.html)、[bpftrace 0.24 语言与探针](https://bpftrace.org/docs/release_024/language)、[bpftrace 0.24 命令行](https://bpftrace.org/docs/release_024/cli)。

