线上有个服务,CPU 使用率长期只有 12%,但 P99 延迟从 80ms 悄悄涨到了 1.2s,运维看监控面板一脸茫然——CPU 不忙、内存不涨、磁盘 IO 也不高,那这一秒多到底耗在哪了。这类问题我在 Linux 性能分析里遇到过很多次,答案几乎都指向同一个盲区:大家习惯盯着 On-CPU 的热点函数看,却忘了进程一生中真正跑在 CPU 上的时间往往只占很小一部分,剩下的大头全是 Off-CPU 的等待。这篇东西就是围绕 On-CPU 和 Off-CPU 这两条主线,把 Linux 上从工具选型、参数计算、采集命令到火焰图读图、等待类型分诊的完整链路捋一遍。内容偏向实操,适合已经在用 perf、看过几张火焰图但还没搞明白"CPU 不忙为什么还慢"的同学,也适合做稳定性保障、想给自己工具箱补上 Off-CPU 这块短板的同行。全文涉及的命令都能直接抄,参数为什么这么定我也会一并说清楚。
1. 概念厘清:On-CPU 与 Off-CPU 的边界在哪
1.1 用食堂打饭理解两种"耗时"
一个线程从被创建到退出,它的墙上时间(wall time)可以被切成两段:一段是真正占着 CPU 核心执行指令的时间,叫 On-CPU 时间;剩下的全是 Off-CPU 时间,也就是它没在跑的时间。用食堂打饭类比:你从进门到端着饭坐下总共花了 20 分钟,其中真正被阿姨打菜的时间只有 1 分钟,剩下 19 分钟分别花在排队、找座位、等同伴、发现饭卡没钱回去拿钱上。On-CPU 分析找的是"阿姨打菜为什么这么慢",Off-CPU 分析找的是"这 19 分钟堵在哪个环节了"。绝大多数人做性能优化只做了前一半,所以才会出现 CPU 使用率很低但系统很慢的怪现象。
再把 Off-CPU 拆细一点,它其实包含两类性质完全不同的等待:第一类是可运行但没被调度(runnable but not running),线程状态是 R,随时可以被扔上 CPU,只是当前没有空闲核心或者被别的更高优先级任务抢了;第二类是被阻塞(blocked),线程状态变成 S(可中断睡眠)或 D(不可中断睡眠),它连被调度的资格都没有,得等某个事件把它唤醒。这两类的排查手法、优化方向完全不同,前者要查 CPU 是否饱和、cgroup 有没有限流、优先级调度是否合理,后者要查 IO、锁、网络、定时器。分不清这两类,Off-CPU 分析就会变成一团浆糊。
1.2 On-CPU 视角能回答什么,不能回答什么
On-CPU 分析的核心是采样:以固定频率中断 CPU,把当前正在执行的指令地址和调用栈抓下来,跑一段时间后统计哪些函数被采到的次数最多。它的强项非常突出——能直接告诉你 CPU 时间被哪些代码吃掉了,是用户态的算法热点,还是内核态的系统调用和内存管理开销;配合火焰图看调用链的宽度,一眼就能定位到某个具体的计算或者某个低效的序列化逻辑。我之前优化过一个日志模块,On-CPU 火焰图上snprintf下面挂着vfprintf的宽条,改成预分配缓冲拼接后 CPU 直接降了三成,这就是 On-CPU 的典型战绩。
但它有个硬边界:只统计"在 CPU 上跑"的那部分时间。如果瓶颈是等待,On-CPU 火焰图上什么都看不见,或者只会看到一个很矮很虚的结论——"没什么热点"。很多新手在这里会误判,认为"没热点就是没问题",实际上代码可能 95% 的时间都在futex_wait或者epoll_wait里睡着,采样根本抓不到。还有一个容易被忽略的点:On-CPU 采样统计的是样本分布,不是精确耗时。样本数太少时(比如只采了几百个样本),单个函数的占比误差可能到百分之十几,做结论前一定要看样本总量够不够。经验值是,想让占比误差控制在 1% 量级,总样本数最好上千,这也决定了后面采样时长和频率怎么定。
1.3 Off-CPU 为什么总被漏掉
Off-CPU 分析长期缺位,原因很实际:实现起来比 On-CPU 麻烦得多。On-CPU 采样只需要周期性打断,而 Off-CPU 需要在进程发生上下文切换的瞬间记录时间戳,等它下次被唤醒时再算差值,这要求工具能挂到内核的调度事件上。早年能做这件事的基本只有 ftrace,配置繁琐、输出需要自己写脚本解析,几乎没有可视化。eBPF 成熟之后情况才彻底变了,一个offcputime命令就能把带用户态和内核态调用栈的等待时间全抓出来,还能折叠成火焰图。
另一个原因是思维惯性。监控面板上 CPU、内存、磁盘、网络四大件都在显眼位置,唯独"进程在等什么"没有直观指标。load average 算半个,但它把 runnable 和 D 状态进程混在一起算,信息量很粗。真正能直接反映 Off-CPU 的指标,比如每个进程的 run queue latency、阻塞时长分布,默认都不会被采集。所以下次遇到"资源都不忙但就是慢",先别怀疑人生,八成是 Off-CPU 出了状况,把这一侧的观测补上,问题往往就现形了。
2. 方案设计:两种视角的观测矩阵与工具选型
2.1 先做判断:这次该看哪一边
动手之前先花两分钟做个粗略分诊,能省掉大量无效采集。把系统当成一个黑盒,输入是请求量,输出是延迟,中间看四个信号:CPU 整体使用率、load average 与核心数的比值、run queue 长度(vmstat 1的 r 列)、D 状态进程数。如果 CPU 使用率贴近核心数上限、load 大于核心数、r 列持续大于核数,那基本是 CPU 争抢,优先做 On-CPU,顺带看 runqlat。如果 CPU 使用率低、load 却很高,或者 D 状态进程一堆,那重点在阻塞型 Off-CPU。如果 CPU 不高、load 也不高但延迟抖,那大概率是 runnable 等待或者间歇性锁争抢,得靠 Off-CPU 追踪按时间轴看。
我通常的固定动作是三条命令快速摸底:
vmstat 1 5 mpstat -P ALL 1 5 ps -eo pid,stat,wchan:32,comm --sort=-pcpu | awk 'NR==1 || $2 ~ /^D/'第一条看 r 列和 CPU 分布,第二条看是不是少数核心被打满(软中断或者单线程瓶颈经常是这种形态),第三条专门捞 D 状态进程和它们卡在哪个内核函数上。这三条跑完,该往哪个方向深入基本就有数了。别小看这个习惯,它能避免在错误的维度上花几小时。
2.2 工具矩阵:perf、ftrace、eBPF 各自的位置
Linux 上做这两类分析,主力的三个工具各有所长,不是替代关系。
perf是 On-CPU 分析的标准答案。它基于硬件 PMU 或者软件时钟做采样,开销可控,能同时拿到用户栈和内核栈,输出是perf.data,可以用perf report交互式看,也能折叠成火焰图。它的弱点是做 Off-CPU 不擅长,perf sched能提供调度延迟数据但缺少完整的调用栈关联,很难直接定位到代码行。
eBPF(通过 bcc 或 bpftrace)是目前 Off-CPU 分析最顺手的方案。它可以在sched_switch这类调度点上挂程序,精确记录每个进程从切出到切回的时间差,并且同时抓内核栈和用户栈。bcc 的offcputime、offwaketime、runqlat、runqlen基本覆盖了 Off-CPU 的主流需求,bpftrace 版本的offcputime.bt也能达到差不多的效果。前提是内核要够新(4.x 以上一般没问题)、有 BTF 信息、有足够的权限。
ftrace是兜底方案。老内核、没有 eBPF 支持的环境、或者权限受限只能读 tracefs 的时候,ftrace 是唯一的选择。它的原始输出是一堆文本事件,需要用脚本加工,但胜在几乎所有 Linux 内核都自带。trace-cmd这个前端工具能大幅降低使用难度,perf sched本质上也是走的这条路。
| 分析目标 | 首选工具 | 备选 | 输出形态 |
|---|---|---|---|
| On-CPU 热点 | perf record | bpftrace profile.bt | 火焰图、perf report |
| Off-CPU 阻塞时长 | bcc offcputime | bpftrace offcputime.bt | Off-CPU 火焰图 |
| 唤醒链路 | bcc offwaketime | ftrace sched_wakeup | 双向火焰图 |
| 调度排队延迟 | bcc runqlat | perf sched latency | 直方图 |
| 调度事件明细 | perf sched timehist | trace-cmd report | 表格 |
| 阻塞点粗筛 | ps wchan / /proc/PID/stack | /proc/PID/schedstat | 文本 |
2.3 采样参数怎么定:频率、时长与开销估算
参数不能拍脑袋,得算一遍。以 perf 为例,采样频率-F 99表示每秒钟对每个目标采样 99 次。为什么是 99 而不是 100?因为 100Hz 容易和内核里的定时器周期形成锁步(lockstep),导致每次都在同一个代码位置被打断,采样结果出现系统性偏差,99 这种质数频率能有效错开。这是 Brendan Gregg 早年就总结出来的经验,跟着用就行。
开销估算分两块。以一台 64 核机器、-F 99 -a -g --call-graph dwarf为例:采样率是 99 × 64 ≈ 6300 次/秒。如果启用 dwarf 栈展开,每次采样要拷贝最多 8KB 的用户栈到 perf 的缓冲区,内存带宽消耗大约是 6300 × 8KB ≈ 50MB/s,这个量级对现代服务器毫无压力。CPU 开销主要来自展开过程本身,一次 dwarf 展开大概几微秒到几十微秒,按 20 微秒算,6300 × 20µs ≈ 0.13 秒/秒,摊到 64 个核上相当于单核 13% 左右。这个开销在生产环境的高峰期做 30 秒采集是可以接受的,但不要一采就是半小时。
推论出几个实用规则:时长上,99Hz 采 30 秒能得到约 3000 个样本,对单核定位够用了;如果要看低频函数(占比不到 1% 的),要么延长到 5 分钟,要么提高频率到 999Hz,但后者开销同步涨十倍。范围上,能用-p PID限定进程就别用-a全系统,能用--加时间窗口就别手动 Ctrl-C。栈深度上,--call-graph dwarf,8192里的 8192 是拷贝字节数,递归深的应用要调大,但要记得每翻倍开销也翻倍。Off-CPU 这边的考量不太一样,它记录的是事件而不是周期采样,开销取决于上下文切换的频率,一台每秒切换几十万次的机器上全系统采集会有明显压力,所以优先用-p PID缩小范围。
3. On-CPU 实操:从采集命令到火焰图读图
3.1 前置准备:符号、权限与内核参数
采集之前有三件事必须确认,否则拿到的是废数据。第一是符号解析。用户态函数的符号来自二进制的符号表,如果程序编译时加了-s或者做过 strip,火焰图里只剩一串地址,看了等于没看。生产环境的二进制一般会剥离符号,这时候要准备带符号的版本或者独立的 debuginfo 包,用perf buildid-cache -a /path/to/debuginfo加进去。内核函数的符号受kptr_restrict影响,想看内核符号需要echo 0 > /proc/sys/kernel/kptr_restrict。
第二是权限。/proc/sys/kernel/perf_event_paranoid这个参数决定了普通用户能做什么:值是 2(多数发行版的默认)时只能测自己进程的用户态;改成 1 可以测内核态;改成 -1 才完全放开。生产环境改这个参数要谨慎并且记得改回去,更稳妥的办法是给采集账号单独授权或者用 root 在受控窗口内操作。第三是栈展开方式。-g默认走 frame pointer,开销小但依赖编译选项,而主流发行版的软件包普遍开了-fomit-frame-pointer,栈会断,所以实际使用建议显式写--call-graph dwarf。
# 确认内核是否支持 PMU,虚拟机里经常没有 perf list | grep -m1 cycles # 没有 PMU 时改用软件事件 perf record -e cpu-clock -F 99 -g -p 12345 -- sleep 30注意:云主机、容器、部分虚拟化平台会屏蔽 PMU,表现为
perf record报 "No supported events" 或者采样数为 0。这时候换成-e cpu-clock或-e task-clock,精度略降但能用。
3.2 采集命令逐参数拆解
一条典型的 On-CPU 采集命令长这样:
perf record -F 99 -g --call-graph dwarf,8192 -p 12345 -o /tmp/on-cpu.data -- sleep 60逐项解释。-F 99是采样频率,理由前面说过。-g开启调用栈记录。--call-graph dwarf,8192指定用 dwarf 展开并且最多拷贝 8192 字节的栈,Java、Go、深层递归的服务建议给到 16384。-p 12345只盯这一个进程,比-a全系统干净得多,也更容易在火焰图上读出来。-o指定输出文件,放在/tmp是为了避免写到慢速磁盘影响结果。-- sleep 60是最优雅的限时方式,比在另一个终端kill -INT靠谱,perf收到子进程退出会自动收尾并落盘。
如果确实需要全系统视角,比如想搞清楚是哪个进程在偷 CPU,用-a替换-p:
perf record -F 99 -g --call-graph dwarf -a -o /tmp/sys.data -- sleep 30采完之后先别急着出图,用perf report快速扫一眼,看样本总数和排名前列的符号是否符合预期:
perf report -i /tmp/on-cpu.data --stdio --no-children -g none | head -40如果发现样本数只有几百、或者前几名全是[unknown],说明参数或者符号有问题,回去查而不是硬着头皮出图。顺带补一个很多人不知道的用法:perf stat不做采样,只做计数,适合对比优化前后。
perf stat -e cycles,instructions,cache-misses,context-switches,cpu-migrations -p 12345 -- sleep 10context-switches这个计数在这里格外有用,它是 Off-CPU 的间接信号——切换次数高得离谱,说明进程在频繁睡眠和唤醒,该转头去看 Off-CPU 了。
3.3 折叠与出图:三步拿到火焰图
火焰图的生成链是固定的三步:导出文本、折叠栈、渲染 SVG。
# 一次性准备工具 git clone --depth 1 https://github.com/brendangregg/FlameGraph.git /opt/FlameGraph export PATH=$PATH:/opt/FlameGraph # 三步出图 perf script -i /tmp/on-cpu.data > /tmp/on-cpu.perf stackcollapse-perf.pl /tmp/on-cpu.perf > /tmp/on-cpu.folded flamegraph.pl --title="On-CPU Flame Graph" --width=1600 /tmp/on-cpu.folded > /tmp/on-cpu.svgperf script把二进制数据转成每行一个样本的可读文本,量大的时候这一步会慢,几十万样本大概要跑十几秒。stackcollapse-perf.pl把相同调用栈的样本合并计数,输出格式是函数A;函数B;函数C 123这样的分号路径加计数。flamegraph.pl负责渲染,--width调宽度是为了让长函数名不至于挤成一团。如果采的是 Java 程序,额外加stackcollapse-perf.pl --all或者配合perf-map-agent生成/tmp/perf-PID.map,否则 JIT 编译出来的方法名全是地址。
火焰图的读法只有三条规则,记住就不会读错。看宽度不看高度:横向宽度代表样本占比,也就是 CPU 时间占比,纵向深度只是调用层级,跟耗时无关。自上而下找瓶颈:从一个宽条往下走,看是哪条子路径贡献了大部分宽度。平顶山要警惕:如果某个函数呈现宽而平的顶,说明采样时大量时间停在这一层,可能是死循环、可能是被内联展开的函数合并了,也可能是符号缺失导致的假象,需要结合perf annotate看具体指令。我见过一次典型的假象:整张图顶部一片巨大的[unknown],查了半天是容器里的二进制做了 strip,补上 debuginfo 后真凶是某个正则表达式编译。
3.4 读图与典型案例:用户态热点和内核态热点
用户态热点和内核对热点的应对思路完全不同,看图的第一个动作就是判断宽条落在哪一侧。火焰图左侧一大块是内核栈,右侧是用户栈,中间是分界。内核态的宽条集中在几个地方:copy_user_enhanced_fast_string或memcpy这类内存拷贝,通常意味着大量数据在用户态和内核态之间来回搬;sys_read、sys_write下面挂着文件系统调用链,说明在同步 IO 上耗 CPU;tcp_sendmsg一类的网络栈函数,配合__alloc_skb说明小包太多,网络栈开销吃掉了 CPU。
用户态热点更好办,直接对应到代码。有个我印象很深的案例:一个日志服务的火焰图上std::string::_M_mutate和operator new占了将近 40% 的宽度,往下追是高频率地拼接字符串并且每条日志都新建对象。改成栈上缓冲加writev批量落盘,CPU 使用率从 65% 掉到 28%,P99 也跟着降了。这类优化收益大、风险低,是 On-CPU 分析最舒服的场景。
还有一种情况要特别提醒:火焰图上看到kswapd、kcompactd这些内核线程占用不低,别去优化它们本身,那是内存压力的表现,应该去看内存分配和页回收,perf stat -e page-faults,minor-faults,major-faults能给出线索。同理,softirq上来的网络收包开销,真正该动的是收包方式和中断亲和性,不是那个函数。看图的功夫有一半在于把现象映射回根因,这需要一点系统知识的积累,多看上几十张图就有感觉了。
4. Off-CPU 实操:把"等待"这件事量化
4.1 offcputime 与 runqlat 的分工
Off-CPU 工具里,offcputime和runqlat解决的是两个不同的问题,很多人会混着用。offcputime回答的是"进程切出去之后睡了多久、睡在哪个调用栈上",它统计的是阻塞时间,也就是线程从主动让出 CPU 到被唤醒之间的时长,输出带完整调用栈,能定位到代码。runqlat回答的是"进程已经就绪但等了多久才被调度上",它统计的是排队时间,输出是延迟直方图,只能告诉你排队的严重程度,不能告诉你为什么排。
这两个视角是互补的。一种常见情况是 runqlat 的 P99 很高但 offcputime 的时间总和不大,说明 CPU 争抢严重,属于 runnable 型 Off-CPU,处理方向是扩核、降优先级争抢、检查 cgroup 限流。反过来如果 offcputime 显示某线程 80% 的时间都在睡,而 runqlat 很平,那就是纯粹的阻塞问题,方向是 IO 和锁。我在排查一个消息消费延迟问题时就吃过这个亏,一开始盯着 runqlat 看,觉得延迟不高没问题,后来跑 offcputime 才发现消费线程大量时间卡在futex_wait上,是一个共享计数器的锁争抢,跟 CPU 一点关系都没有。
runqlat的判读需要一些阈值经验,这些数字不是标准,是我自己积累的参考值:P99 在 1ms 以内算健康;1ms 到 10ms 之间要看业务对抖动的敏感度;超过 10ms 基本可以断定 CPU 资源供给不足或者被限流,尤其是容器环境要第一时间去看 cgroup 的 throttling 计数。还有一个进阶工具runqlen看的是队列长度而不是等待时长,适合长期监控,可以作为常态化指标采起来。
4.2 采集与出图:Off-CPU 火焰图
bcc 版本的offcputime是上手最快的。Ubuntu 上装bpfcc-tools之后命令名一般带-bpfcc后缀,其他发行版可能是裸名,先which确认一下。
# 采集指定进程 30 秒的阻塞栈,-d 输出分隔符格式,-f 输出折叠格式 offcputime-bpfcc -df -p 12345 30 > /tmp/offcpu.folded # 出图,注意用 --color=io 区分色系,避免和 On-CPU 图混淆 flamegraph.pl --color=io --title="Off-CPU Time Flame Graph" \ --countname=us /tmp/offcpu.folded > /tmp/offcpu.svg几个参数值得单独说。-p 12345限定进程,如果不加就是全系统,输出会很大;-f输出折叠格式直接喂给flamegraph.pl,省掉一步转换;-u只看用户态栈、-k只看内核态栈,缩小数据量时有用;-m 1000可以过滤掉小于 1 毫秒的等待,压制噪声。采全系统时我一般会加-m 500,否则那些频繁进出、每次只睡几十微秒的线程会把图刷得没法看。
--countname=us这个细节值得说一下。offcputime的默认单位是微秒,火焰图上每条栈的宽度代表的是累计微秒数,所以图注必须标明单位,否则看图和沟通时容易把"宽度 5000"误解成其他含义。Off-CPU 火焰图的读法和 On-CPU 一样看宽度,但有一个重要差异:底部那一层是进程名,不是函数,因为 Off-CPU 是从进程维度切进去的。所以如果你采的是全系统,第一件事是从底部找出宽度最大的那个进程,再往上看它的调用栈。
没有 eBPF 环境时,bpftrace 也能干这件事,把下面这段存成offcpu.bt然后bpftrace offcpu.bt即可:
bpftrace -e ' tracepoint:sched:sched_switch /args->prev_state != 0/ { @start[args->prev_pid] = nsecs; } tracepoint:sched:sched_switch { if (@start[args->next_pid] != 0) { @us[kstack, ustack] = sum((nsecs - @start[args->next_pid]) / 1000); delete(@start[args->next_pid]); } } interval:s:30 { exit(); } '这段逻辑是:第一个探针在进程切出并且prev_state != 0(即不是可运行状态,而是真的睡了)时记下时间戳;第二个探针在它被切回来的时候取出时间戳算差值,按内核栈加用户栈聚合。prev_state != 0这个判断是区分阻塞和抢占的关键,去掉它,runnable 等待也会被算进来,得到的是广义 Off-CPU 时间——有些场景下这正是你想要的,比如想量化 CPU 争抢,那就故意去掉这个过滤。
4.3 等待类型分诊:从 wchan 和调用栈判断堵在哪
拿到 Off-CPU 火焰图之后,关键动作是分诊,也就是判断这些等待属于哪一类,因为不同类别对应完全不同的处理路径。我这里整理了一张对照表,基本上覆盖了我遇到过的八九成情况。
| 线程状态 | 典型内核栈/wchan | 等待类型 | 处理方向 |
|---|---|---|---|
| D | __blkdev_direct_IO、rwsem_down_read_slowpath | 同步磁盘 IO、文件系统锁 | iostat 看 await,考虑异步化或换存储 |
| D | nfs_*、cifs_* | 网络文件系统卡顿 | 检查挂载参数、超时设置,尽量本地化 |
| S | futex_wait_queue_me | 用户态锁争抢 | 找锁持有者,缩小临界区或换无锁结构 |
| S | sk_wait_data、tcp_recvmsg | 网络读等待 | ss -ti 看重传和窗口,检查对端 |
| S | do_nanosleep、hrtimer_nanosleep | 业务主动 sleep、重试退避 | 检查重试策略是否过于保守 |
| S | ep_poll、poll_schedule_timeout | 事件驱动空闲等待 | 正常现象,但占比过高说明负载不足 |
| R | 无固定 wchan(不在睡眠) | 调度排队 | runqlat 量化,查 CPU 饱和与 cgroup 限流 |
一个容易被忽视的维度是状态码和内核栈的交叉验证。有些等待从内核栈上看不出来,但从状态码能推断。比如大量 D 状态进程但内核栈指向一个看起来无害的函数,很可能是驱动或者存储层的超时重试,这时候去看 dmesg 有没有超时告警比盯着火焰图更有效。反过来,S 状态的进程如果内核栈显示在futex上,而用户栈指向一个很短的函数,那基本可以确定是锁竞争,接下来要做的是找出谁持有锁,bpftrace挂futex相关探针或者直接看代码里的临界区范围。
还有一类特殊情况是容器环境下的 CPU 限流,它在 Off-CPU 火焰图上表现得很隐蔽——等待栈可能只是普通的调度相关函数,看不出异常。这时候必须去看 cgroup 的统计:
cat /sys/fs/cgroup/cpu.stat # nr_periods / nr_throttled / throttled_usecthrottled_usec不为零就说明进程因为超出 quota 被强制掐掉了,这就是纯粹的 Off-CPU 时间,而且火焰图上看不出明显原因。这个坑我踩过,排查了两天才想到去看 cpu.stat,本质原因是一个批处理任务和在线服务共享了 cgroup quota。
4.4 没有 eBPF 的兜底:ftrace 与 perf sched
有些环境上不了 eBPF:内核太老、容器权限不够、或者安全策略禁止加载程序。这时候perf sched是最省事的选择,它对权限要求低,几乎所有能跑 perf 的地方都能用。
# 记录调度事件,范围限定到目标进程 perf sched record -p 12345 -- sleep 20 # 输出每个任务的调度延迟汇总 perf sched latency -i perf.data # 按时间轴看每次切换的等待时长 perf sched timehist -i perf.data | head -50perf sched latency会给出每个任务的wait time avg、wait time max、sch delay avg等指标,其中 wait time 就是任务就绪但没上 CPU 的排队时间,sch delay 是实际运行时间偏离理想值的程度。它缺少调用栈关联,所以只能定位到线程,不能直接定位到代码,但配合代码走查通常够用了。timehist的输出是一行一次的调度事件,适合看时间轴上的抖动模式,比如每隔 10 秒出现一次长等待,那就去找定时任务。
ftrace 的路子更底层一些,用trace-cmd封装之后也不算难:
# 同时抓切换和唤醒事件 trace-cmd record -e sched_switch -e sched_wakeup -P 12345 sleep 20 trace-cmd report | head -30原始输出是每个事件的明细行,包含prev_comm、prev_state、next_comm等字段,需要自己写脚本把切出和切回配对算时间差。我一般只在 eBPF 完全不可用时才走这条路,因为写解析脚本的时间成本不低。另外还有一个零成本的方法值得常备:/proc/PID/schedstat和/proc/PID/status。
cat /proc/12345/schedstat # 三个数字:在 CPU 上运行的时间(ns)、在运行队列上等待的时间(ns)、运行时间片次数 grep -E 'voluntary|nonvoluntary' /proc/12345/statusschedstat的第二个数字就是该任务累计的就绪等待时间,用它除以总运行时间能得到一个粗略的 Off-CPU 占比,判断"这个进程是不是大部分时间都在等"。voluntary_ctxt_switches和nonvoluntary_ctxt_switches的比值也很有信息量,前者远大于后者说明是主动阻塞,后者大说明是被抢占。这两个文件的特点是开销几乎为零,可以定时采样做长期监控,比每次上手跑追踪工具高效得多。
5. 常见问题与排查技巧实录
5.1 符号与堆栈类问题
现象一:火焰图上大片[unknown]。排查顺序是:先确认二进制有没有符号,file和nm -C binary | head看符号表是否存在;再确认 debuginfo 有没有加载,perf buildid-cache -l列出已加载的;最后确认是不是 JIT 语言(Java、Node、部分 Go 场景)需要额外的 perf map 文件。Java 服务最省事的做法是加-XX:+PreserveFramePointer启动参数,再配合perf-map-agent生成映射表,否则栈里只有[unknown]加一堆地址。
现象二:栈是断的,只有一两层。绝大多数是 frame pointer 被省略导致的,改用--call-graph dwarf基本能解决。如果换了还是断,检查--call-graph dwarf,8192的字节数够不够,递归深的程序要加到 16384 甚至 32768。还有一种情况是内核栈被截断,需要调/proc/sys/kernel/perf_event_max_stack,默认值偏小。
现象三:内核符号显示成地址。十有八九是kptr_restrict没放开,或者当前用户的 capability 不够。临时放开是echo 0 > /proc/sys/kernel/kptr_restrict,正式环境建议通过能力授权解决,而不是长期把限制关掉。
提示:符号问题占了 On-CPU 分析失败原因的一大半。养成习惯:出图之后先看底部有没有完整的用户栈和内核栈,栈不完整就直接修,别硬读。
5.2 采集本身把系统搞挂了怎么办
这个问题真实存在,尤其是全系统高频采样叠加深度栈展开。我见过一次事故:有人在 128 核机器上用-F 999 -a -g --call-graph dwarf采了十分钟,结果显示开销把核心占满,业务接口大面积超时。教训有两条:一是先算开销再上,二是先小范围试采再扩大。
具体的规避手段有四个。第一,用-p精确限定目标进程,别动不动就-a。第二,频率从 99 起步,确认没问题再考虑提高。第三,用-- sleep N严格限时,避免忘了停。第四,先在预发环境或者从库上验证一遍命令再上生产。对于 eBPF 工具,offcputime的开销主要跟上下文切换次数相关,一台每秒切换 50 万次的机器上全系统采集就是自找麻烦,加-p或者用-m过滤短等待都能显著降低负载。
还有一个容易忽略的点是输出文件的写入位置。perf.data可能很大,几百 MB 起步,如果写到业务所在的慢速盘上,IO 压力会传导到业务。固定写到/dev/shm或者本地 SSD 的临时目录,采完立刻拷走。
5.3 容器、非 root 与老内核的坑
容器里跑 eBPF 是最容易出问题的一环。需要确认几件事:容器有没有CAP_SYS_ADMIN和CAP_BPF(或者--privileged),/sys/kernel/debug有没有挂进去,/sys/kernel/btf/vmlinux存不存在(btf 缺失时 bcc 会尝试用 kernel headers 编译,失败就报错)。如果容器和宿主机共享 PID namespace,-p用的就是宿主机的 PID,注意别搞混。权限实在拿不到,退一步用宿主机上的特权容器做旁路采集,或者改用perf sched这类低权限要求的工具。
非 root 场景下,perf_event_paranoid决定了能做什么。值是 2 时只能测自己进程的用户态热点,这个其实已经能解决不少算法优化问题了;想看内核态或者别的进程就得提权。我一般的建议是把采集脚本设计成两档:低权限档只做用户态采样,高权限档做全栈,用一个开关控制,这样开发同学在本地也能自助排查,不用每次都找运维开权限。
老内核的问题主要在 eBPF 支持不完整,4.9 以下很多工具跑不起来。这时候的替代路径是perf sched加/proc/PID/schedstat加/proc/PID/wchan采样,虽然原始但有效。wchan采样特别简单粗暴:写个循环每 100ms 读一次目标进程的 wchan,统计出现频率最高的几个值,就能大致知道它在等什么,几十行脚本搞定。
5.4 问题速查表
把上面这些整理成一张表,出问题的时候按顺序排查。
| 现象 | 最可能的原因 | 快速验证 | 处理 |
|---|---|---|---|
火焰图全是[unknown] | 符号缺失或 JIT 未生成映射 | nm -C binary看符号表 | 补 debuginfo 或开 PreserveFramePointer |
| 栈只有一两层 | frame pointer 被省略 | 换 dwarf 重采 | 加--call-graph dwarf,16384 |
| 采样数为 0 | 无 PMU 或权限不足 | perf list、看 paranoid 值 | 换-e cpu-clock或提权 |
| CPU 低但延迟高 | 阻塞型 Off-CPU | offcputime -df -p PID 30 | 按等待类型分诊处理 |
| 延迟抖动无规律 | 调度排队 | runqlat看 P99 | 查 CPU 饱和与 cgroup 限流 |
| D 状态进程多 | 磁盘或网络文件系统 | ps -eo stat,wchan,comm | 看 iostat 与 dmesg |
| 容器里延迟周期性尖刺 | cgroup CPU 限流 | cat /sys/fs/cgroup/cpu.stat | 调整 quota 或拆分组 |
| 采集期间业务告警 | 采样开销过大 | 对比采集前后的 CPU | 降频率、限 PID、缩短时长 |
6. 组合定位流程与我的几点心得
6.1 一个可复用的排查顺序
把 On-CPU 和 Off-CPU 拼起来用,我固定走这么一条流程,从粗到细,每一步都能独立收敛问题。
第一步,建立基线。vmstat 1、mpstat -P ALL 1、ps捞 D 状态,三条命令十秒钟跑完,判断大方向是 CPU 型还是等待型。这一步的产出是一个假设,比如"怀疑是同步 IO 等待"。第二步,验证假设。如果是 CPU 型,直接上perf record出 On-CPU 火焰图;如果是等待型,上offcputime出 Off-CPU 火焰图。第三步,交叉验证。这一步很多人会跳过但很有价值:用perf stat -e context-switches,cpu-migrations,page-faults看计数,用/proc/PID/schedstat算 Off-CPU 占比,如果 On-CPU 火焰图显示热点占比和perf stat的 CPU 时间对得上,结论才可信。
第四步,量化收益。优化前把关键指标记下来:火焰图占比、P99 延迟、CPU 使用率、每秒上下文切换数。改完之后用完全相同的命令重采一遍做对比,别凭感觉说"快了"。我习惯把优化前后的两张火焰图并排放在一起看,宽条是不是缩短了、移到了别处、还是消失变成了一块新的瓶颈,这个对比过程本身经常能发现下一层的优化点。
第五步,沉淀成监控。一次性的排查价值有限,把稳定可用的指标变成常态化采集才有复利。我一般会挑三个指标做长期监控:runqlat的 P99(反映调度健康度)、目标进程/proc/PID/schedstat的等待占比(反映 Off-CPU 趋势)、cgroup 的throttled_usec(反映限流)。这三个指标的开销都极低,可以每分钟采一次,画成趋势图,比事后救火强太多。
6.2 几个我踩过的坑和私藏技巧
先说几个真金白银换来的教训。别在业务高峰期第一次跑采集命令,我见过太多人拿着网上抄来的命令直接在高峰执行,然后引发二次故障。别只看平均值,性能问题几乎都藏在长尾里,Off-CPU 的等待时间尤其如此,一个平均 2ms、P99 800ms 的等待,平均值看上去人畜无害,但用户体验就是被那 1% 毁掉的,所以offcputime出来的图要配合runqlat的直方图一起看。别把工具输出当结论,火焰图告诉你哪宽,不告诉你为什么宽,从现象到根因那一步得靠代码走查和系统知识,这一步没有捷径。
再分享几个我觉得很好用的技巧。第一个是用-m做噪声过滤。全系统offcputime采出来经常是一团乱麻,加个-m 500只看 500 微秒以上的等待,图立刻清爽,宽条也会集中到真正的瓶颈上。第二个是用offwaketime看唤醒链路,它能同时显示"谁在等"和"谁唤醒的",很多生产者消费者模型的延迟问题只有看双向栈才能定位到是消费端慢还是生产端没及时喂数据。
第三个技巧是把wchan做成常驻的低成本采样。用一个一行的定时任务每隔 60 秒记录一次关键进程的 wchan,跑上一周,你就能得到一份这个服务"平时都在等什么"的画像。等哪天出问题,直接和这份基线对比,异常点自动浮现。这个方法的性价比高到离谱,几乎零成本却能在关键时刻省下几小时。
第四个是注意时间单位和时钟源。offcputime默认微秒,runqlat默认也是微秒但可以-m切毫秒,perf sched的输出单位不统一,看的时候一定先确认表头。我就因为把某个工具的输出当毫秒读,把一个 300 微秒的问题当成了 300 毫秒,白紧张了半天。
最后提一个容易被忽略的观测角度:线程级而不是进程级。现代服务普遍是多线程模型,进程整体看 CPU 使用率很健康,但某个线程可能长期被饿着。perf record -t TID、offcputime -t TID都能精确到线程,配合top -H -p PID看线程级的 CPU 分布,很多"进程级指标正常但业务就是慢"的谜题在这里解开。我排查过一个 RPC 框架的延迟毛刺,最后发现是某个负责心跳的线程被业务线程长期抢占,而进程级 CPU 一直很平稳,全程只有线程级视角才能看到。
这套东西说到底就是一句话:看性能不能只盯着 CPU 在忙什么,更要盯着它在等什么。Tools 会用只是入门,知道什么时候该用哪个、结果怎么解读、下一步往哪走,才是真正拉开差距的地方。