排查线上程序 CPU 飙升时我第一反应几乎总是perf top或perf record -F 99 -g。这两个命令背后依赖的就是 Linux perf 的周期性采样机制性能计数器每隔固定间隔溢出一次中断处理器把当前执行位置连同调用栈一起记录下来。你最后看到的火焰图、热点函数排名、perf report 里的百分比全部来自这一条条细小的样本。用 perf 用了很多年真正把“样本是怎么从 PMU 硬件计数器一路走到用户态 perf.data 文件里”这条链路想清楚是最近一次排查诡异的周期抖动时才补的课。过去我只知道选频率、看火焰图对于中断为什么走 NMI、环形缓冲区为什么用 mmap、-F和-c之间内核到底做了什么几乎一片空白。这篇就一层层拆开来看内容包括 PMU 溢出中断、NMI 语义、环形缓冲区、采样频率调节和调用栈回溯最后附上我在实际项目里常用的验证手段和参数选择。不管你是刚学会perf record -g的新手还是已经用 perf 排查过不少问题、想补底层知识短板的工程师这轮拆解应该都能帮你少踩几个坑。1. 一个样本点的完整旅程从 PMU 溢出中断到 perf.data1.1 为什么是“计数器溢出”而不是“定时器到点”“周期性采样”听起来像是闹钟每隔固定时间去看一眼程序在干嘛。如果只做软件实现确实可以用高精度定时器hrtimer完成到点就把当前执行现场记录下来。但 perf 默认的 CPU 周期事件cycles不走这条路它依赖硬件 PMUPerformance Monitoring Unit的计数器溢出。PMU 寄存器的工作原理是每个 CPU 时钟周期硬件计数器自增一次当计数值达到你设定的阈值就触发一次中断。把初始值设为 10000意味着每经过 10000 个 CPU 周期就会产生一次中断。中断处理程序拿到当前的指令指针 IP、进程号 PID、调用栈等信息这就组成了一条 sample。用 PMU 而不用定时器的原因很实际定时器在任何状态下都会均匀到点包括 idle、包括锁等待而 PMU 溢出是“事件等间隔”程序执行得越密集采样点越密程序空转时采样点就自动变稀。这恰好把采样预算花在了 CPU 真正干活的地方热点区域天然获得更多样本报告里的百分比更能反映真实开销。1.2 中断上下文里的“轻量”要求硬件计数器溢出在 x86 上通常以 NMINon-Maskable Interrupt形式进来。之所以用 NMI是因为普通可屏蔽中断可能被关中断的代码挡住一旦采样中断丢了样本就不连续。NMI 不可屏蔽保证“只要溢出了就一定会执行采样逻辑”。但 NMI 上下文能做的事情极少不能睡眠、不能分配内存、不能拿普通锁。所以内核把采样设计得非常轻从寄存器取 IP 和标志位、读取当前任务信息、做一次调用栈回溯、把样本写入预先分配好的环形缓冲区然后返回。整个过程不触发 page fault、不分配内存这也是 perf record 自身 CPU 开销通常很小的根源。很多人在 Citus 或者 MySQL 这种重负载场景下跑-F 99实测采样开销也只有 1% 上下正是因为这条中断路径被压到了极致。1.3 perf_event_open用户态先把“要采什么”告诉内核要启动采样perf 工具最终通过perf_event_open()系统调用创建 event。这个系统调用把一个struct perf_event_attr交给内核里面最关键的有type/config指定事件种类比如硬件周期事件PERF_TYPE_HARDWARE / PERF_COUNT_HW_CPU_CYCLES或软件事件PERF_TYPE_SOFTWARE / PERF_COUNT_SW_CPU_CLOCKsample_period或sample_freq采样周期/采样频率sample_type每个样本要带哪些字段比如PERF_SAMPLE_IP、PERF_SAMPLE_TID、PERF_SAMPLE_TIME、PERF_SAMPLE_CALLCHAINread_format样本附带的事件计数值等。命令行参数和这些字段几乎是一一对应的-e cycles对应 config-F 99设置sample_freq-g追加PERF_SAMPLE_CALLCHAIN。perf tool 会在每个在线 CPU 上各自打开一个事件实例因为 PMU 的计数器是每 CPU 一套每个实例有自己独立的环形缓冲区互不干扰。如果想看真实的 attr 内容可以执行perf record -vverbose 输出里会把完整的perf_event_attr结构体打印出来对照着看会非常有感觉。1.4 一个样本最终写成了什么内核把样本写入 ring buffer 后用户态perf record进程循环读取解析成一条条记录并序列化到perf.data。一条采样记录大致包含这些内容内容说明record 头record type、sizesample_id事件 ID、时间戳、CPU、PID/TIDregs / IP被采样位置的寄存器快照/指令指针callchain压栈回溯得到的函数地址序列period本次采样对应的实际计数间隔perf report拿到这些地址后再用符号表解析成函数名聚合统计出热点排名。perf script则把这些记录原样导出成文本用于自己写脚本分析。理解了这条数据路径后面调参、排错时心里会踏实很多。2. 采样周期与采样频率内核如何把“每秒 99 次”换算成计数器初值2.1-c固定周期与-F固定频率的差别perf record -c 10000 -e cycles的含义是每数够 10000 个 CPU 周期采一针。程序跑得越快单位时间里的样本越多。这是固定的“事件等间隔”采样样本密度完全跟随事件发生速率。perf record -F 99 -e cycles则希望“每秒大约 99 次采样”。这里的问题来了CPU 周期事件的发生速率取决于 CPU 瞬时频率、流水线状态、缓存命中率不是一个固定速率。想让每秒采样次数稳定在 99内核就必须动态调整溢出阈值——上一秒偏快了就把阈值调大偏慢了就把阈值调小。两类周期各有适用场景-c适合做事件计数相关的分析比如观察每发生多少次 cache miss 之后的基本行为-F适合做通用热点剖析因为最终报告里的百分比更接近时间占比。日常做火焰图绝大多数情况用-F就对了。2.2 内核的调 period 反馈环路-F背后的实现并不神秘。内核在每次溢出处理的路径里会比较实际采样率与目标频率再动态调整下一个溢出周期。具体到 event 上会维护hwc-last_period和hwc-period_left等字段溢出后根据经过的时间算出真实采样密度和-F目标对比再微调下一次的计数器初值。用大白话说如果这一秒只采了 80 次内核下个周期就把阈值减小让中断更频繁如果采了 150 次就把阈值放大。这个反馈最终会收敛到接近目标采样率。采完一次后你可以用下面这条命令验证实际效果perf script -F time | awk -F[.:] {print $1} | uniq -c正常情况下-F 99的每分钟样本量分摊到每秒基本在 95~102 次之间波动。如果程序本身负载变化很大波动会更明显但只要不是量级上的偏差都不用担心。注意-F的单位是“每秒样本数”不是“每 CPU 每秒”。perf record -a全系统采样时每个 CPU 都在跑这套调节逻辑总样本量会随 CPU 数量放大。2.3 为什么大家都推荐 99 这个数值99 经常出现在各类教程里背后的原因和信号处理里的抗混叠有点关系。它离 100 只差 1采样间隔不会和秒边界以及常见的 100Hz 工作负债对齐能明显减少采样和任务节拍之间产生固定相位共振的概率。如果采样点每次都恰好撞在同一个状态上热点分布会严重失真。换成 49、97、199 这类数值也有类似效果。另一个实际原因是开销。-F 99意味着每秒 99 次中断和 99 次栈回溯在大多数机器上已经算非常温和了-F 999以上时采样开销可以占到 1~3% 的 CPU而-F 10000基本就是一种测量干扰。我在做热点评测时通常先用-F 49看全局定位到具体模块后再用-F 199细化。2.4 采样偏置样本是事件权重不是时间权重周期性采样有一个容易被忽略的性质它等分的不是时间而是事件发生次数。CPU cycles 事件只在 CPU 活跃时递增所以采样点天然集中在高 CPU 占用区域一个短但不频繁的热点区域可能只拿到极少样本而一个温和但持续的热点反而占据大量份额。这不一定是坏事但看报告时得有这个概念。另外用 cycles 事件采样时看到的“时间占比”其实会被 CPI每指令周期数扭曲。memory stall 很重的代码cycles 计数在等待内存期间同样在涨于是这类函数在报告里占比会偏高。想看得更细通常得把instructions、cache-misses放进同一个采样组里交叉比对而不是只盯着 cycles 事件看。3. 环形缓冲区内核写、用户读的那块共享内存3.1 布局一页控制块加一圈数据页每个 perf 事件创建后用户态可以通过 mmap 映射一片内存。这片内存的第一页是perf_event_mmap_page结构内核用它暴露控制字段从第二页开始才是真正存放样本数据的环形缓冲。两个关键字段是data_head内核下次写入的位置data_tail用户态已经读到的位置。内核只写data_head用户态只更新data_tail两者相减就是缓冲区里积压的有效数据量。这个设计是典型的单生产者单消费者模式每个 CPU 一个事件实例生产者和消费者各一个不需要额外加锁靠读写内存屏障保证顺序。这也是整个采样机制能保持低延迟的根基。3.2 watermark 与批量唤醒如果每写一条样本就唤醒一次perf record进程系统会被调度抖动拖垮。perf 因此引入了 watermark水位机制内核向缓冲区写入达到预先设定的水位后才通过 poll / eventfd 通知用户态来读。perf record会批量把新到的样本拖走写到perf.data写盘过程也做了缓冲不会每条记录都同步 fsync。perf record -m/--mmap-pages参数控制的就是这片环形缓冲的页数。默认值在多数发行版上大约是每 CPU 几 MB实际测试中如果-F调得高、且开了-g单条样本可能到几十 KB缓冲区很容易被填满。填满之后新样本会被丢弃并累计 lost 计数如果开启了 overwrite 模式则反过来覆盖旧样本适合只关注最新状态的长时采样场景。无论哪种都会让热点统计失真。查看丢样本最简单的是在perf report的头部信息里找 lost samples 统计或者用perf script -D | grep lost直接数 lost 记录条数。如果 lost 比例偏高第一选择是加大 mmap 缓冲比如perf record -m 256M第二选择是降低-F把单样本体积降下来。3.3 每个 CPU 一个 ring bufferperf.data 只有一个perf tool 启动物理事件时会在每个在线 CPU 上创建一个 event 实例。这样 PMU 计数器就是按 CPU 走的环形缓冲之间没有竞争采样延迟和锁开销都被压到最低。用户态的perf record用 poll 同时监控所有 fd收集完后再把所有 CPU 的样本按时间戳合并写进同一个perf.data。所以你在perf report里看到的样本总数大致等于“每 CPU 每秒采样数 × CPU 数 × 运行秒数”。这也解释了为什么同样的-F 99在 32 核机器上产生的perf.data比单核机器大很多估算体积时不能只看目标进程。我见过不少人在多核机器上跑-F 999 -a -g三分钟直接把磁盘写满了其实就是没算明白这一层。4. 调用栈回溯与符号解析看到“热点函数”的前提条件4.1 三种回溯路径fp、dwarf、orc光有 IP 地址还不够火焰图和完整热点路径依赖的是调用栈。perf 的-g/--call-graph参数背后有三种回溯方式fpframe pointer沿着保存的帧指针寄存器链往上走。速度最快但对编译要求高代码必须保留帧指针-fno-omit-frame-pointer才有效。现代发行版默认开了 O2 优化很多程序里的帧指针被移除fp 回溯走到一半就会断掉。dwarf利用 ELF 里的.eh_frame调试展开表做回退。能为高阶优化代码重建完整栈但开销大采样成本可能是 fp 的几倍。orc内核 4.14 之后的默认回溯器由 objtool 在编译期生成 ORC 表运行时开销极小解决了历史上内核栈回溯慢、栈表格式复杂的问题。用户态程序默认走 fp如果程序没有帧指针perf report里会看到大量[unknown]和断链。遇到这种程序我会直接用perf record -g --call-graph dwarf代价是开销略高但热点数据完整得多。对于内核调用链用 ORC 之后基本不用纠结栈表性能问题。4.2 符号能不能解析取决于权限和符号表样本记录里存的是一堆地址。把地址翻译成函数名是perf report的另一大工作用户态符号perf 按 mmap 记录找到可执行文件路径再用 ELF 符号表解析。程序如果 strip 过就只能显示地址或[unknown]需要从 debuginfo 或单独的符号文件补救。内核符号内容来自/proc/kallsyms这个文件受kptr_restrict和perf_event_paranoid双重保护。非特权用户经常只能看到全零地址要让内核调用链完整通常要用 root 跑采样和分析。容器里常见坑同一路径的二进制容器内和宿主机符号路径对不上。perf archive可以帮你在宿主上把符号包抓出来迁到另一台机器再分析。这些细节平时不明显一旦报告里全是[unknown]十有八九是符号解析环节出了问题而不是程序真的神秘到无法分析。4.3 JIT 和解释器程序怎么采Java、Node、Python 这类程序的问题更复杂函数是运行时 JIT 生成的没有 ELF 符号。perf 对 Java 的解决办法是通过-XX:PreserveFramePointer配合 perf-map-agent 生成/tmp/perf-PID.mapperf 工具会读取这个 map 把 JIT 方法地址翻译成函数名Node 类似。Go 程序因为是静态编译通常帧指针和符号都在-F 99 -g直接就能采到漂亮的调用链这也是我分析 Go 服务时最省心的原因。如果看到样本 IP 全部落在同一个 runtime 函数比如解释器主循环里首先检查是不是栈回溯断了而不是程序真的只有一个热点。不少人在 Python 程序上踩过这个坑以为热点全在一个 C 扩展函数里实际上是用户态 Python 栈没被回溯出来。5. 实操中验证采样效果与踩坑清单5.1 拿到 perf.data 后先做三件事我在拿到一份perf.data后第一步不是看火焰图而是先做三件事perf script | wc -l看样本总量是不是符合预期在perf report头部或perf script -D里检查有没有 lost events抽查几条perf script输出的 callchain确认栈是完整的。预期样本量可以按-F 99× CPU 数 × 运行秒数来估算。如果差了一个数量级多半是采样权限没到位、目标进程频繁睡眠、或者输出文件写入异常差一点点则正常。另外在采样前后各跑一次perf stat -d对比被采样程序的 CPI 和 cache miss 数据能辅助判断测量本身有没有过度干扰目标。采样本身应当是负作用最小化的“旁路观察”如果目标程序的性能指标面目全非那就要把-F降下来或者换更轻的采样事件。5.2 常见报错与解法这里列几个我实际踩过的报错和对应解法报错信息原因常规解法No permission to collect statsperf_event_paranoid限制sudo sysctl kernel.perf_event_paranoid-1或以 root 执行perf_event_open() failed: Operation not permitted容器 seccomp/capability 限制以 privileged 容器运行或检查宿主机许可perf_event_open with sample_freq... too high采样频率超过内核保护阈值降低-F或调整/proc/sys/kernel/perf_event_max_sample_rateFailed to mmap with No space left on device环形缓冲区总量太大减小-m或减少并发事件数callchain 大量[unknown]用户态程序缺帧指针/符号用--call-graph dwarf或加-fno-omit-frame-pointer重新编译内核默认会限制全系统最大采样率常见默认值是 100000。如果发现自己设了-F 100000但实际采样频率被砍掉一半不是程序出错是内核在帮你挡自伤。调perf_event_max_sample_rate之前先掂量一下把采样频率拉爆只会让测量结果全是采样中断本身的开销。5.3 我的常用参数组合最后分享几组我在不同场景下稳定使用的参数定位进程内热点perf record -F 99 -g --call-graph dwarf -p pid跑 30~60 秒。全系统内核态洞察perf record -e cycles:k -a -g -F 99 -- sleep 10。快速实时看热点perf top -F 99 -g低开销直接刷新瞬时热点。多事件交叉分析perf record -e cycles,instructions,branches,cache-misses -F 99 -a -g -- sleep 5。这些组合我用了很久基本没有翻过车。对于采样开销敏感的场景可以先perf stat跑一遍看目标程序的 CPI 和耗时变化再决定要不要上完整采样。务实的做法是“先用低频率看全局再针对可疑模块提高频率”而不是一上来就在全系统上堆高采样率。我自己的体会是周期性采样这个机制设计重点始终围绕“尽量少打扰、尽量多记录”。PMU 溢出、NMI、环形缓冲区、动态调 period每一层都在往这个目标上靠。理解了这条链路再看到perf report里的百分比时你就能判断哪些数据可信、哪些地方需要调参数而不是盲目相信第一份报告。如果在你的环境里也遇到了采样结果明显不合理的情况我建议先从 lost events 和 callchain 完整性查起十个里面有八个问题出在这两个地方。