用 perf 和 eBPF 观察 Linux CPU 与函数
最后更新:
同一段任务的耗时变成了昨天的两倍,先问它实际用了多少 CPU,再问 CPU 时间花在哪些调用栈上。若线程大部分时间都在睡眠,接下来要追的是唤醒、系统调用或某个函数的延迟。perf stat、perf record 和 eBPF 追踪能分别回答这些问题,三者的结果不能直接互换。
perf stat:计数器能说明什么
perf stat 给出采集期间各类事件的计数。Linux 通过 perf_event_open() 暴露多类事件:task-clock、context-switches、page-faults 是内核维护的软件事件;cycles、instructions 等则通常来自 CPU 的性能监控单元(PMU)。PMU 提供数量有限的性能监控计数器(PMC),内核选择事件并安排它们占用计数器。比较两个版本的同一任务时,可以先看 CPU 时间和经过时间,再结合可用的硬件事件:
| |
命令中的选项与负载参数可以这样读,英文用于助记:
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 统计一个只等待的进程,再和上面的哈希任务比较:
| |
看两列: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 再把同类样本合并。下面用软件时钟事件采样同一个有界任务:
| |
这次采样和读报告用到以下参数:
-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 都可能在样本里出现。先按命令、二进制或共享库、符号看一张不累加子调用的表:
| |
--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。
CPU 时钟采样主要看到线程正在 CPU 上运行时的代码。若应用慢在锁、I/O 或调度队列里,睡眠的那段时间不会出现在这种 CPU 调用栈中。可以先看 Linux CPU 和 I/O 排查,确认症状后再挑具体事件追踪。
eBPF:在已知位置观察事件
如果已知要问的是“这个进程是否频繁打开文件”,就不用从随机抽到的调用栈反推。可以把 eBPF 程序附加到系统调用入口,按进程过滤,在内核 map 中累计次数,结束后读出结果。BPF 程序在加载时经过内核验证;每次触发仍要执行程序,高频事件上逐条打印会增加开销,输出缓冲也可能丢事件。只想知道次数或分布时优先用聚合。
| 探针 | 放在哪里 | 适合问什么 |
|---|---|---|
tracepoint | 内核预先定义的事件,有命名的字段 | 某个系统调用、调度或块设备事件何时发生。先核对目标内核上的事件名和字段。 |
kprobe / kretprobe | 动态附加到内核函数入口或返回处 | 没有对应 tracepoint 时追查一个内核函数。符号、函数原型和可附加性随内核版本与构建变化。 |
uprobe / uretprobe | 用户态可执行文件或共享库的指令位置 | 观察某个用户态函数的调用。要选对进程实际映射的二进制;优化、内联和符号剥离会影响定位。 |
usdt | 用户态程序或库在构建时埋好的命名探针 | 观察应用特有的事件,例如请求开始。要确认当前构建确实包含探针及其参数。 |
例如,在存在 sys_enter_openat 且允许加载探针的测试机上,用 bpftrace 查看字段,再统计一个 cat 进程的调用:
| |
这里的 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 字段存在,再运行:
| |
这个 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 标准库。较旧的 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 安全说明、perf_event_open(2)、perf-stat(1)、perf-record(1)、内核 tracepoint、kprobe、uprobe、bpftrace 0.24 语言与探针、bpftrace 0.24 命令行。