
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 调试信息展开代价是开销更大、产出更大。一般先默认 fpframe 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.mapperf 就能解析更省事的做法是直接用 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 --colorio --titleOff-CPU Time --countnameus 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-CPUoffcputime -df -p $(pgrep -f myapp | head -1) 30 offcpu.folded flamegraph.pl --colorio --titleOff-CPU --countnameus 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 传统上要 root5.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 或缺 debuginfoperf buildid-list对比安装 debuginfo 包或带符号重编采样样本数远低于预期线程大量 Off-CPU对比预期样本数转 Off-CPU 分析Java 栈显示为[JIT]JIT 代码无符号映射检查/tmp/perf-PID.map挂 perf-map-agent 或换 async-profileroffcputime 栈断在中间栈展开深度不足看栈顶部是否有断裂dwarf 模式或加大栈参数Off-CPU 图里大量 idle/swapper采集到空闲任务观察最宽块的名字用-p限定进程或加过滤p99 抖动但 CPU 不高调度延迟长尾runqlat -m -P查 CPU 饱和或绑核调优先级等锁时间长但持锁者不慢持锁者被下游卡住wakeuptime看唤醒链缩短持锁范围或改无锁容器内工具报 PID 找不到命名空间不一致docker top核对用对应命名空间的 PIDeBPF 工具 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 等于完整墙钟时间这个账本关系才是长期管用的东西。