1. 先把 On-CPU 和 Off-CPU 这两个词掰开揉碎
性能分析这件事,做得久了会发现一个很尴尬的现象:很多人拿到一台卡顿的机器,第一反应是 top、ps、然后开 perf 抓火焰图,看到 CPU 使用率不高、火焰图也没几个尖峰,就得出"系统没问题"的结论。但业务方还在催,接口还是慢。问题出在哪?出在只看了线程"跑在 CPU 上"的那段时间,忽略了线程"不在 CPU 上"的那段更漫长的时间。Linux 性能分析里的 On-CPU 和 Off-CPU,说的就是把一个线程的完整生命周期切成两半来看:On-CPU 是线程真正占用处理器执行指令的时间,Off-CPU 是线程虽然活着、但没有被处理器执行的时间。这两块时间加起来,才是一个请求从进来到返回的真实墙钟耗时。
我做过的排查里,至少有六成的"性能问题"根子在 Off-CPU 上:等锁、等磁盘 IO、等网络回包、等定时器、等被唤醒调度。而大家习惯用的工具几乎全是为 On-CPU 设计的。这就导致一个结构性盲区——你越用 On-CPU 的工具,越会把注意力放在计算热点上,越容易漏掉阻塞点。所以这篇内容想聊的,就是怎么把这两半时间都量出来、怎么看、怎么改。适合已经会用 top、perf 这类基础工具,但遇到"CPU 不高却很慢"的场景会卡住的运维、后端开发和 SRE,也适合刚接触 Linux 性能调优、想建立一套完整分析框架的朋友。
1.1 一个类比:餐厅后厨和等位区
把服务器想象成一家餐厅,CPU 核心就是厨师,线程就是一道道待做的菜。On-CPU 时间相当于厨师正在灶台前颠勺的时间,Off-CPU 时间是这道菜从点单到出餐之间,厨师没碰它的所有时间——可能是在等配菜送过来(等 IO),可能是灶台被别的菜占着(等锁),也可能是厨师被叫去处理别的单子、这道菜就搁在一边(调度延迟)。
只统计厨师颠勺的时长,你会觉得后厨效率挺高;但顾客感受到的是从点单到上菜的完整等待。这两者之间的差距,就是 Off-CPU 分析要填的坑。理解了这层类比,后面所有的工具和指标,其实都是在回答两个问题:厨师到底在哪个灶台上花了多少时间(On-CPU 热点),以及这道菜在没被烹饪的时候究竟卡在哪一步(Off-CPU 阻塞点)。
1.2 为什么只盯 On-CPU 会系统性误判
CPU 使用率是个比值,分子是忙的时间,分母是总时间。当系统大量线程都在睡觉等待时,这个比值天然就低,看起来"很闲"。但这恰恰可能是最糟的状态:线程数开了一堆,每个都在等同一个资源,吞吐上不去,延迟还高。这类问题在 On-CPU 视角里几乎隐身,因为压根没有多少指令在跑。
更麻烦的是,一些常见结论会把人带偏。比如"CPU 使用率低说明负载轻,可以再压一压",实际可能是下游某个依赖把请求全堵住了;再比如"火焰图没热点所以代码没问题",实际瓶颈在 syscall 睡眠或者 futex 等待上,采样器根本没采到什么栈。所以建立 On-CPU 和 Off-CPU 的双视角,不是为了多学几个命令,而是为了堵住这个判断漏洞。
1.3 把线程时间账本拆开
一个线程从被创建到退出,它的时间可以粗略拆成三块:Running(在 CPU 上跑)、Runnable(就绪但在等 CPU)、Sleeping(睡眠,等某个事件)。Running 就是 On-CPU,后两者合起来是 Off-CPU。这里有个容易被忽视的细节:Runnable 状态本身也是一种浪费,它说明 CPU 不够分,或者调度策略让某些线程迟迟轮不上。很多"偶发尖刺"就是 Runnable 排队造成的。
把这三块量化出来,你就能给任何一次慢请求做一个"耗时归因表":多少时间在算,多少时间在等锁,多少时间在等 IO,多少时间在等调度。归因清楚了,优化方向基本就是明牌。接下来的章节,我会先讲 On-CPU 怎么采得准,再讲 Off-CPU 怎么把等待栈抓出来,最后给一个从现象到修复的完整推演。
2. On-CPU 分析:采样、火焰图与热点定位
On-CPU 分析的目标很明确:找出线程把时间花在了哪些代码路径上。主流做法是基于定时中断的采样(sampling),而不是插桩计数,因为采样对性能影响小、能反映真实分布,也不需要改代码。采样器每隔一个固定周期打断一次 CPU,抓下当前的调用栈,攒够样本数之后按栈聚合,就得到了调用分布的近似。样本越多,统计越稳。
2.1 perf 采样机制和几个必须懂的参数
Linux 上最通用的采样工具就是 perf。它依赖内核的 perf_event 子系统和硬件/软件 PMU,能按时间频率或事件计数触发采样。一条典型的抓取命令是这样:
perf record -F 99 -g -p 12345 -- sleep 30 perf script > perf.out ./stackcollapse-perf.pl perf.out > perf.folded ./flamegraph.pl perf.folded > oncpu.svg几个参数值得掰扯清楚。-F 99 表示每秒采样 99 次。为什么不是 100?因为整数 100 容易和系统里其他 100Hz 的周期任务(比如某些定时器)产生锁相,采出来的样本会偏向某个固定相位,导致分布失真。99 是个质数,能打散这种共振。-g 是抓完整调用栈,没有它你只能看到最顶层的函数,看不到是谁调用的,定位就没有上下文。-- sleep 30 是限定采集时长,用 sleep 而不是直接 Ctrl-C,是为了让脚本化更稳定、退出更干净。
还有两个参数在排查特定问题时很关键:-e 可以指定事件,默认是 cycles,但你也可以换成 cache-misses、branch-misses 来抓特定瓶颈;--call-graph dwarf 会在用户态栈难以回溯时改用 DWARF 调试信息展开,代价是开销更大、产出更大。一般先默认 fp(frame pointer)跑一遍,栈断了再考虑 dwarf。
注意:perf 的内核栈符号来自 /proc/kallsyms,用户态符号需要带调试信息的二进制。生产环境经常遇到符号被 strip 掉的情况,采出来全是十六进制地址,白忙一场。动手前先确认目标进程有没有 debuginfo 或者带符号的构建产物。
2.2 火焰图怎么读,哪些形状代表什么问题
火焰图的横轴是样本占比(不是时间顺序),纵轴是调用深度,每一块是一个栈帧,块越宽说明该函数在采样里出现得越多。读图的核心心法就一句:先找最宽的叶子,再看它是被谁调用的。最宽的叶子通常是实际的 CPU 消耗大户,但它的"元凶"可能在它的调用者那一层——比如一个通用的序列化函数很宽,但真正的问题是某个业务逻辑反复调它。
几种典型形状对应不同问题。"平顶山"形状,底部宽、顶层一排差不多的宽块,通常是循环或批处理在反复执行同类操作,优化点是降复杂度或加缓存。"塔尖"形状,某一层特别宽,下面迅速收窄,说明有个单点函数吃满了 CPU,直接盯它。"断层"形状,中间某层突然很窄,说明采样在这个函数里分布稀疏,要么是它执行快,要么是栈在那断了,需要配合 dwarf 或检查符号。
2.3 符号丢失、栈截断和 JIT 代码这三类坑
实话说,On-CPU 分析最容易翻车的不是命令不会敲,而是采出来的东西没法看。第一类坑是符号丢失,解决方式是装对应版本的 debuginfo 包,或者用perf buildid-list确认 build-id 能不能对上 debug 文件。第二类坑是栈截断,frame pointer 被编译器优化掉了(-fomit-frame-pointer 是很多发行版的默认),这时候要么重新编译加 -fno-omit-frame-pointer,要么切到 dwarf 模式。深度太深时 perf 默认只抓一部分栈,可以用--call-graph dwarf,65528加大栈大小。
第三类坑是 JIT 代码,Java、Node、部分 Go 场景都会遇到,采出来是一堆[JIT]或者匿名地址。Java 的解法是挂 perf-map-agent,让 JIT 编译时把方法地址映射写到 /tmp/perf-PID.map,perf 就能解析;更省事的做法是直接用 async-profiler 抓 CPU 火焰图,它对 JIT 代码的映射是内建的。Node 可以用--perf-basic-prof之类的方式输出映射表。这类工作如果一开始没规划好,事后补会很难受,建议在部署阶段就把符号和映射准备好。
3. Off-CPU 分析:把"等待"这件事量化
聊完 On-CPU,重头戏来了。Off-CPU 分析要回答的是:线程没在跑的时候,到底在等什么,等了多久。传统思路是插桩记录每个阻塞点的进入和退出时间,但这要求改代码、而且覆盖不到内核路径。更优雅的做法同样是"跟踪切换":内核每次发生上下文切换时都会记录谁被换下、谁被换上,如果我们在这两个时刻打点,就能算出每个线程两次被调度之间隔了多久,并且抓下它被换下时的调用栈——那个栈就是它"睡觉的原因"。
3.1 线程离开 CPU 的几种典型原因
归纳一下,线程从 Running 变成 Off-CPU,常见就这几类。第一类是睡眠等待,主动调用会睡眠的 syscall 或同步原语,比如 read/write 阻塞、futex 等待、nanosleep。第二类是等锁,包括用户态自旋锁的退化、互斥量、文件锁,内核里 futex 是重灾区。第三类是等 IO,包括磁盘、网络、管道,很多时候表现为 epoll_wait 或 read 阻塞。第四类是被抢占,时间片用完或被高优先级线程挤下去,这时候线程还是 Runnable,只是没轮上。
前几类是 Sleeping,第四类是 Runnable。区分它们很重要:Sleeping 说明有外部依赖拖着,优化方向是缩短依赖或并发化;Runnable 说明 CPU 资源或调度配置有问题,优化方向是加资源或调优先级、绑核。很多分析工具会把两者混在总 Off-CPU 时间里,看的时候要留意能不能拆开。
3.2 offcputime 与 wakeuptime:等待栈和唤醒链
BCC 工具集里的 offcputime 是最常用的入口。它会跟踪上下文切换,统计每个线程 Off-CPU 的累计时长,并按被换下时的栈聚合:
offcputime -df -p 12345 30 > offcpu.folded flamegraph.pl --color=io --title="Off-CPU Time" --countname=us offcpu.folded > offcpu.svg参数含义:-d 打印分隔符,方便后续折叠;-f 输出折叠格式,直接喂给火焰图脚本;-p 限定进程;最后一个数字是采集秒数。得到的 Off-CPU 火焰图跟 On-CPU 长得像,但横轴宽度是阻塞时长(微秒),读法是"哪个栈把线程卡住得最久"。
offcputime 解决的是"线程在哪睡",但它不回答"谁把它叫醒的"。这时候就要请出 wakeuptime。它把阻塞栈和唤醒栈拼在一起,直接告诉你:A 线程在某个 futex 上睡了 200ms,是被 B 线程唤醒的。这个信息非常值钱,因为它把因果链给串起来了——很多锁竞争的根因,不是持锁线程慢,而是持锁线程被别的更慢的操作卡住了。
wakeuptime -p 12345 30实测下来,wakeuptime 在排查线程池互相等待、上游把下游拖死的场景里特别好用。缺点是对复杂调用栈的输出会比较长,需要配合过滤看。
3.3 runqlat:被忽视的调度延迟
前面说过 Runnable 状态是独立的一类浪费,量化它主要靠 runqlat(老版本叫 runqslower)。它统计任务从进入就绪队列到真正被调度执行之间的延迟分布,输出是直方图:
runqlat -m -P 10-m用毫秒做单位,-P按进程聚合。看这个输出主要看长尾:如果 p99 到了几十毫秒,说明某些线程经常在就绪队列里等很久,CPU 饱和或者有 CPU 密集任务在抢。这类延迟在应用层往往表现为莫名的抖动,而且因为它不消耗 CPU 时间,常规监控根本看不到。线上偶发毛刺排查,runqlat 是我必开的一个工具。
注意:offcputime 统计的时长里其实已经隐含包含了 Runnable 的等待,因为线程从被换下到再次被换上,中间可能既有睡眠也有排队。要精确拆开,需要同时看 offcputime 和 runqlat,用差值估算。"睡了 100ms 里有 80ms 是在排队"这种结论,只能靠两个工具配合才能得出。
4. 一次真实排查的完整推演
讲完工具,来走一遍完整流程,比干讲参数有用得多。以下是我处理过的一个典型场景,做了脱敏和简化,但思路和步骤是原样的。
4.1 现象描述和初步假设
现象:某服务接口在压测时 p99 从 20ms 涨到 900ms,但机器 CPU 使用率只有 25%,IO 也不高,监控上看不出明显异常。单看资源指标,这台机器简直空闲得可以去度假。这种"资源不紧张但延迟爆炸"的形态,基本可以判定瓶颈在 Off-CPU,而且大概率是排队或者锁,不是计算。
先立几个假设:一是线程池被某个慢调用占满,新请求只能排队;二是某个共享资源(连接池、缓存、锁)成了串行点;三是 GC 或者定时任务周期性抢占导致抖动。接下来逐个验证。
4.2 用 On-CPU 排除计算密集
第一步还是先排除计算问题,毕竟它最好查。抓 30 秒 On-CPU 火焰图:
perf record -F 99 -g -p $(pgrep -f myapp | head -1) -- sleep 30 perf script | ./stackcollapse-perf.pl | ./flamegraph.pl > oncpu.svg结果很干净,最宽的叶子占不到 5%,没有任何函数吃满。这就基本排除了 CPU 热点,也侧面印证问题在等待。同时看了一眼 On-CPU 的采样总数,30 秒 99Hz 应该接近 3000 个样本,实际只采到不到 800 个——这个差值本身就是信号:大量时间线程根本没在跑,采样器采不到。
4.3 用 Off-CPU 定位到锁竞争
接下来抓 Off-CPU:
offcputime -df -p $(pgrep -f myapp | head -1) 30 > offcpu.folded flamegraph.pl --color=io --title="Off-CPU" --countname=us offcpu.folded > offcpu.svg火焰图一出来,最宽的一条栈非常显眼:从业务入口一路到pthread_mutex_lock,再往下是futex_wait,累计阻塞时间占了总 Off-CPU 时间的六成以上。也就是说,大量线程都堵在同一把锁上。顺着栈找到那个 mutex 对应的代码位置,是一个本以为"读多写少"的共享配置缓存,实际每次刷新都会整表加写锁,而刷新又调用了下游接口,一旦下游慢,锁就持很久。
再用 wakeuptime 确认唤醒链:
wakeuptime -p $(pgrep -f myapp | head -1) 30输出清楚地显示,唤醒这些等锁线程的正是那个持锁的刷新线程,而刷新线程自己又阻塞在下游网络读上。到这里因果链完整了:下游慢 → 刷新线程持锁时间长 → 其他线程全堵在锁上 → 请求排队 → p99 爆炸。
4.4 验证与修复
验证方式很直接:给共享缓存的刷新逻辑改成"先算好再原子替换指针",也就是读路径无锁、写路径只做一次指针交换,把持锁时间从"整个下游调用"缩到"指针赋值"。改完复压,p99 回落到 30ms 以内,Off-CPU 火焰图上那条 futex_wait 的宽度基本消失了。
这次排查的价值不在于用了多高级的工具,而在于先用 On-CPU 排除、再用 Off-CPU 定位、最后用 wakeuptime 串因果这个固定套路。你把这套顺序记下来,遇到类似问题就不用靠猜了。
5. 环境准备与工具选型的一些硬门槛
工具再好,环境不支持也白搭。Off-CPU 这类分析工具大多基于 eBPF,对内核版本和权限有实打实的要求,提前确认能省掉很多抓耳挠腮的时间。
5.1 内核版本与 eBPF 能力自检
先看内核版本:
uname -roffcputime、wakeuptime 这类工具依赖挂载 kprobe 的能力,实践上建议内核 4.15 以上,5.x 更稳。4.9 到 4.14 之间有些工具能跑但偶有兼容问题。检查内核配置:
grep -E "CONFIG_BPF|CONFIG_BPF_SYSCALL|CONFIG_KPROBE" /boot/config-$(uname -r)几个关键项要能查到,BCC 工具集才能正常工作。如果是最小化安装的发行版,可能连 bcc-tools 包都没装。
5.2 安装 BCC 与依赖
主流发行版基本都有现成包:
# Debian/Ubuntu 系 sudo apt-get install bpfcc-tools linux-headers-$(uname -r) # RHEL/CentOS 系 sudo yum install bcc-tools kernel-devel-$(uname -r)装完工具一般在/usr/sbin或者/usr/share/bcc/tools下,可能带-bpfcc后缀。perf 和火焰图脚本另装:perf 跟内核版本绑定,最好用包管理器装,避免自己编译出坑;FlameGraph 是一堆 Perl 脚本,clone 下来即可。
5.3 容器和虚拟化环境的特殊处理
容器环境里有两个必踩的坑。第一是 PID 命名空间:容器里看到的 PID 和宿主机不是一回事,perf 和 bcc 工具如果跑在宿主机上,得用宿主机的 PID,用docker top或者ps -eLf | grep去定位。BCC 的-p参数接受的是它所在命名空间里的 PID,跑在容器内就填容器内 PID。第二是权限:eBPF 传统上要 root,5.8 以后有了 CAP_BPF 和 CAP_PERFMON 可以降权,但生产上多数还是用 root 或者 privileged 容器。
虚拟化环境里,硬件 PMU 事件可能不可用,perf 的部分功能会退化,但软件事件(cpu-clock)和采样依然可用,Off-CPU 工具基于 kprobe 基本不受影响。这一点在云主机上不需要太担心。
提示:如果 offcputime 报 "HINT: ... failed to attach" 之类的错误,先确认内核开了 CONFIG_BPF 相关选项,再确认自己没有在受限的 seccomp 环境里跑。有些安全加固过的环境会禁用 bpf() 系统调用。
6. 常见问题与排查技巧速查
把经常遇到的坑整理成一张表,排查时直接对号入座,比翻文档快。
| 现象 | 可能原因 | 排查手段 | 处理方式 |
|---|---|---|---|
| On-CPU 火焰图全是地址符号 | 二进制被 strip 或缺 debuginfo | perf buildid-list对比 | 安装 debuginfo 包或带符号重编 |
| 采样样本数远低于预期 | 线程大量 Off-CPU | 对比预期样本数 | 转 Off-CPU 分析 |
Java 栈显示为[JIT] | JIT 代码无符号映射 | 检查/tmp/perf-PID.map | 挂 perf-map-agent 或换 async-profiler |
| offcputime 栈断在中间 | 栈展开深度不足 | 看栈顶部是否有断裂 | dwarf 模式或加大栈参数 |
| Off-CPU 图里大量 idle/swapper | 采集到空闲任务 | 观察最宽块的名字 | 用-p限定进程或加过滤 |
| p99 抖动但 CPU 不高 | 调度延迟长尾 | runqlat -m -P | 查 CPU 饱和或绑核调优先级 |
| 等锁时间长但持锁者不慢 | 持锁者被下游卡住 | wakeuptime看唤醒链 | 缩短持锁范围或改无锁 |
| 容器内工具报 PID 找不到 | 命名空间不一致 | docker top核对 | 用对应命名空间的 PID |
| eBPF 工具 attach 失败 | 内核能力或 seccomp 限制 | 查内核 config 和安全策略 | 换环境或提权 |
这张表里我最想强调的两行是"采样样本数远低于预期"和"等锁时间长但持锁者不慢"。前者是一个很隐蔽的信号,很多人看到 CPU 不高就直接放弃 perf 了,其实差值本身就在告诉你答案;后者是翻身常犯的错——盯着等锁的人看,忽略了叫醒他们的人。把这两个视角建立起来,绝大多数"诡异延迟"都能找到入口。
再补几个实操心得。第一,采集时长别太短,Off-CPU 事件往往稀疏,10 秒可能采不到几次,建议至少 30 秒,长尾问题可以拉到几分钟。第二,采样时尽量复现问题场景,空跑采集没意义,要压测就边压边采。第三,火焰图看的是分布不是顺序,别试图从图里读出时间先后,要看先后得用 trace 类工具。第四,perf script产出的中间文件可能很大,几十秒采集几百 MB 是常事,注意磁盘空间和后续处理的内存占用。
7. 一些踩坑之后才明白的经验
最后聊点工具之外的东西,都是实打实踩出来的。刚接触 Off-CPU 分析时,我老想找"一个命令搞定",后来发现这套分析的价值恰恰在于组合:On-CPU 排除计算、offcputime 找阻塞点、wakeuptime 串因果、runqlat 补调度,四个环节缺一个,结论就可能偏。
还有一个体会是,Off-CPU 时间不是越低越好,而是越"可解释"越好。有些等待是设计使然,比如正常的网络 RTT、必要的批量攒批,这些等待是划算的;真正要干掉的,是那些没必要的、可以并发化的、因为实现缺陷被放大的等待。所以别追求把 Off-CPU 压到零,先追求能说清楚每一段等待花在哪、值不值。
另一个容易忽略的点是采样开销。eBPF 工具在高频事件上(比如上下文切换特别频繁的系统)本身也会产生可观测的额外负载,采集的时候要留意对被分析进程的影响,别把观察行为变成了干扰因素。压测对比时,最好先跑一次不采集的基线,再跑采集版本,把采集开销从结果里扣掉,不然容易冤枉代码。
再分享一个小技巧:如果线上不方便跑 root 工具,可以先从/proc/PID/status里的 voluntary_ctxt_switches 和 nonvoluntary_ctxt_switches 看趋势,两个值涨得快,说明上下文切换频繁,Off-CPU 问题概率大,这就是个零成本的先行指标,用来决定要不要上重武器很够用。
这套东西我用了好几年,越用越觉得它不是什么高深技术,就是把"线程到底在干嘛"这个问题拆成两半来回答的朴素方法。工具会随内核版本变,理解"On-CPU 加 Off-CPU 等于完整墙钟时间"这个账本关系,才是长期管用的东西。