第6章:CPU分析——BPF最成熟的战场

📑 目录

CPU瓶颈的可怕之处不在于它存在,而在于它往往伪装成’一切正常’——使用率不高、没有明显错误,但响应就是慢。BPF的CPU工具让你穿透这层伪装,直接看见内核调度器背后的真相。

故事场景:双十一前夜的’幽灵卡顿’

11月10日23:47,距离双十一流量洪峰还有13分钟。某电商平台的订单服务集群已经扩容到了平时的8倍,但监控上却出现了一个诡异的数字:

CPU使用率:31%。P99延迟:2.3秒。

"这不科学!"小李盯着监控大屏,"CPU才用了三分之一,为什么会卡?"

老张正在喝第三杯咖啡,闻言把杯子放下,打开了三个终端窗口:

窗口一sudo profile -F 99 -a 30 > /tmp/out.stacks —— 采集30秒CPU栈样本
窗口二sudo runqlat -m 60 —— 观察调度延迟分布
窗口三sudo runqslower 1000000 —— 捕获超过1ms的调度延迟事件

30秒后,老张把 out.stacks 喂给了 FlameGraph(火焰图生成工具)

git clone https://github.com/brendangregg/FlameGraph
cd FlameGraph
./stackcollapse-bcc.pl /tmp/out.stacks | ./flamegraph.pl > cpu.svg

他打开生成的SVG,指着一片突兀的"尖塔":"看见了吗?这片红得发紫的火焰——__mutex_lock_slowpath。你们的订单服务在锁竞争。31%的CPU使用率是假的,因为大部分线程卡在等锁上,不占CPU但占调度队列。"

小李凑近屏幕:"那为什么 top 没显示出来?"

"因为 top 看的是CPU时间,而BPF看的是调度时间。"老张点开第三个终端,runqslower 的输出正在滚动:大量线程在可运行队列(Run Queue)中等待了2-5ms才拿到CPU——对于延迟敏感型服务,这就是死刑。

"解决方案?"小李问。

"把连接池从1000个线程降到200个,改用协程。"老张合上笔记本,"问题不是CPU不够,是线程太多,调度器在’排队叫号’上花了太多时间。"

监控大屏上的数字在五分钟后开始回落。双十一零点,P99稳在了180ms。

小李在事后报告里写了一句:"感谢BPF,让我们在被压垮之前看见了真正的敌人。"

一、CPU性能背景:为什么’使用率’会说谎

在深入BPF工具之前,我们需要先理解CPU性能分析的几个核心概念。传统工具(topmpstatperf)的盲区,正是BPF的价值所在。

1.1 三种CPU时间

graph TB
    subgraph "CPU时间分类"
        A[CPU总时间]
        A --> B[on-CPU时间]
        A --> C[off-CPU时间]
        B --> B1[用户态: 业务逻辑]
        B --> B2[内核态: 系统调用 / 中断]
        C --> C1[IO等待: 磁盘 / 网络]
        C --> C2[锁等待: mutex / futex]
        C --> C3[调度延迟: Run Queue]
        C --> C4[自愿睡眠: sleep / poll]
    end
  • on-CPU(CPU上执行):线程正在CPU上执行指令。topmpstat 测量的是这部分时间。
  • off-CPU(CPU外等待):线程因为某种原因让出了CPU。这部分又细分为:
    • IO等待:等磁盘、等网络、等缓存
    • 锁等待:等 mutexfutexspinlock
    • 调度延迟:在Run Queue里排队等CPU(即使在CPU空闲时也可能发生!)
    • 自愿睡眠:主动调用 sleep()epoll_wait()futex_wait()

关键洞察:一个服务P99延迟高,但CPU使用率只有30%,往往意味着大量时间花在了off-CPU等待上——尤其是锁竞争和调度延迟。传统工具看不到这些,但BPF可以。

1.2 传统工具的盲区

工具测量对象盲区BPF替代
top进程级CPU使用率看不到内核态热点、看不到调度延迟profile + cpudist
mpstatCPU级利用率看不到线程级瓶颈、看不到off-CPU时间runqlat + offcputime
perf recordCPU采样需要perf_event_paranoid设置、用户态符号解析麻烦profile(BCC自带)
strace系统调用追踪开销巨大(ptrace),生产环境不可用syscount(BPF实现)

1.3 CPU缓存与性能

现代CPU性能越来越依赖于缓存命中率,而非原始频率。一个关键指标是 CPI(Cycles Per Instruction,每指令周期数)

  • CPI < 1:指令级并行(ILP)做得好,或者缓存命中率极高
  • CPI ≈ 1-3:正常范围
  • CPI > 5:严重缓存未命中或分支预测失败

BPF可以通过 PMU(Performance Monitoring Unit,性能监控单元) 硬件探针采集CPI:

sudo bpftrace -e 'profile:hz:99 {
    @cycles = count(perf_event::cpu-cycles);
    @instructions = count(perf_event::instructions);
}
END {
    printf("CPI: %.2f\n", (float)@cycles / @instructions);
}'

(注意:这段命令需要内核支持硬件事件BPF,部分内核版本可能不可用。)

1.4 CPU亲和性与NUMA效应

在多路服务器和 NUMA(Non-Uniform Memory Access,非统一内存访问) 架构中,CPU性能不仅取决于自身,还取决于它访问的是哪一块内存:

  • 本地内存访问:CPU访问直接挂在它名下的内存控制器——延迟最低
  • 远程内存访问:CPU访问其他NUMA节点的内存——延迟可能翻倍

BPF可以帮助你诊断NUMA相关的问题。例如,追踪线程的CPU迁移:

sudo bpftrace -e 'tracepoint:sched:sched_migrate_task {
    @[args->comm, args->orig_cpu, args->dest_cpu] = count();
}'

如果某个关键服务的线程频繁在不同NUMA节点之间迁移(如从CPU 0到CPU 16),那么它很可能在承受额外的远程内存访问开销。

老张曾经遇到过一个案例:一个数据库服务的P99延迟在白天正常,但在夜间批处理任务开始后翻倍。runqlat 显示调度延迟正常,但 profile 火焰图显示大量时间花在了 __memmoveclear_page 上——这是内存子系统的"症状"。

最终用上面的bpftrace命令发现:夜间批处理把数据库线程从NUMA Node 0挤到了Node 1,而数据库的共享缓冲区(shared_buffers)90%分配在Node 0。解决方案很简单:用 numactl --cpunodebind=0 --membind=0 把数据库绑定回自己的"家"。

"NUMA问题像幽灵,"老张说,"top 看不到它,perf 可能忽略它,但BPF能让它显形。"

二、BPF CPU工具全景

BCC和bpftrace提供了覆盖CPU分析全链条的工具。我们可以按分析目标把它们分成几类:

mindmap
  root((BPF CPU工具))
    on-CPU分析
      profile - CPU栈采样
生成火焰图 funccount - 热点函数计数 stackcount - 调用栈频率 cpudist - CPU运行时间分布 off-CPU分析 runqlat - 调度延迟分布
排队等CPU多久 runqslower - 慢调度事件
捕获超过阈值的延迟 offcputime - off-CPU时间栈
在哪让出CPU 中断与系统调用 softirqs - 软中断耗时 hardirqs - 硬中断耗时 syscount - 系统调用频率 综合火焰图 offcpu-flamegraph
oncpu+offcpu合并

2.1 profile:生成火焰图的’底片’

profile 是BCC工具中最重要的CPU分析工具。它以固定频率(默认49Hz或99Hz)对所有CPU进行栈采样,输出格式可以直接喂给 FlameGraph 脚本生成火焰图。

# 采集全系统CPU栈30秒(-a表示所有CPU,-F 99表示每秒99次采样)
sudo profile -F 99 -a 30 > /tmp/profile.out

# 生成火焰图
git clone --depth 1 https://github.com/brendangregg/FlameGraph.git
./FlameGraph/stackcollapse-bcc.pl /tmp/profile.out | \
    ./FlameGraph/flamegraph.pl --title "CPU Flame Graph" > cpu.svg

火焰图的阅读方法:

  • 宽度 = 样本数:越宽的函数,占用的CPU时间越多
  • 纵轴 = 调用深度:从下到上是调用栈
  • 颜色 = 无意义:只为区分不同函数,不代表温度或好坏
  • 点击交互:SVG格式支持点击某函数后只显示以它为根的子树

老张的习惯是:先看火焰图最宽的几片"平原"——那是CPU时间的主要消耗者;再看有没有突兀的"尖塔"——那可能是异常热点。

2.2 runqlat:调度延迟的’体检报告’

runqlat 可能是BCC中最被低估的工具之一。它测量的是线程从"变为可运行"到"真正拿到CPU"之间的等待时间——这就是调度延迟(Scheduling Latency)

# 以毫秒为单位,显示调度延迟分布
sudo runqlat -m

# 只追踪特定进程的调度延迟
sudo runqlat -P $(pgrep -n nginx)

典型输出:

Tracing run queue latency... Hit Ctrl-C to end.

     msecs               : count     distribution
         0 -> 1          : 234567    |******************************|
         2 -> 3          : 45678     |******                      |
         4 -> 7          : 12345     |*                           |
         8 -> 15         : 890       |                            |
        16 -> 31         : 234       |                            |
        32 -> 63         : 56        |                            |
        64 -> 127        : 12        |                            |

解读:大部分线程在1ms内拿到CPU,但有一小部分尾巴拖到了32ms以上。对于延迟敏感型服务(如网关、数据库),即使是1%的线程经历10ms调度延迟,也足以把P99拉垮。

调度延迟的常见原因:

  1. CPU饱和:Run Queue平均长度大于CPU核心数
  2. 优先级反转:高优先级任务被低优先级任务阻塞
  3. CFS调度器参数不当sched_min_granularity_nssched_latency_ns 设置不合理
  4. NUMA远程内存访问:线程被调度到远离其内存的NUMA节点
  5. CPU隔离/绑核问题tasksetcpuset 配置错误

小李在双十一那晚学到的最重要一课是:调度延迟和CPU使用率不是一回事。一个32核的服务器,CPU使用率30%,但如果有1000个线程在争抢,调度延迟照样能把P99搞崩。

2.3 runqslower:捕获’异常病人’

如果说 runqlat 是"全身体检",那么 runqslower 就是"异常预警"。它只记录调度延迟超过阈值的事件:

# 捕获调度延迟超过1ms(1,000,000纳秒)的所有事件
sudo runqslower 1000000

输出格式:

TIME     COMM             PID           LAT(us)  PREV COMM        PREV PID
23:48:01 order-service    18432          2345    nginx            11234
23:48:01 order-service    18433          1890    php-fpm          22345

这告诉你:哪些线程在什么时间经历了长时间调度延迟,以及它们从谁手里"抢到"了CPU(PREV COMM)。如果 PREV COMM 总是同一个进程,那说明这个进程是"CPU霸凌者"——可能需要调整其nice值或cpuset。

老张建议把 runqslower 的输出重定向到一个日志文件,然后用 logrotate(日志轮转工具) 管理——这本质上是一个零开销的内核级调度延迟监控,比任何用户态agent都更精确。

2.4 cpudist:CPU运行时间的’X光片’

cpudist 测量的是线程在CPU上连续运行的时间分布——这与调度延迟是互补的:

# 测量off-CPU时间的分布(线程让出CPU后多久才回来)
sudo cpudist -O -m

# 测量on-CPU时间的分布(线程拿到CPU后跑了多久)
sudo cpudist -m

如果on-CPU时间分布呈现"全是短脉冲"(大部分<1ms),说明调度器在频繁切换线程——可能是线程数过多或锁竞争严重。如果off-CPU时间呈现"长尾巴",说明线程经常在等IO或锁。

2.5 softirqs & hardirqs:中断的’黑白名单’

中断是CPU时间的"偷渡客"——它不属于任何进程,所以 top 里看不到。但BPF可以:

# 统计软中断的CPU时间分布
sudo softirqs -m 10

# 统计硬中断的CPU时间分布
sudo hardirqs -m 10

软中断(softirq)和硬中断(hardirq)的高CPU消耗通常意味着:

  • 高网络吞吐:网卡中断和NAPI轮询
  • 高磁盘IO:块设备中断
  • 定时器风暴:内核定时器过度密集
  • RCU风暴:Read-Copy-Update回调堆积

2.6 syscount:系统调用的’人口普查’

syscount 统计每个进程的系统调用频率和总耗时:

# 按进程名聚合,显示系统调用次数和耗时
sudo syscount -P -m

这是发现"系统调用密集型"进程的快速方法。例如,一个做大量小文件随机读的应用可能会在 read()openat() 上消耗不成比例的系统调用开销——尽管每次系统调用本身很快,但数量巨大时总开销不可忽视。

三、BPF单行程序:CPU分析的’急救包’

当BCC工具不够灵活时,bpftrace单行程序可以快速定制分析逻辑。

3.1 简单的CPU栈采样

# 以99Hz采样所有CPU,打印PID和内核栈
sudo bpftrace -e 'profile:hz:99 { @[kstack, pid, comm] = count(); }'

3.2 追踪特定函数的CPU时间

# 测量 do_syscall_64 的耗时分布
sudo bpftrace -e 'kprobe:do_syscall_64 { @start[tid] = nsecs; }
kretprobe:do_syscall_64 /@start[tid]/ {
    @ns = hist(nsecs - @start[tid]);
    delete(@start[tid]);
}'

3.3 调度延迟的单行分析

# 统计每个进程的平均调度延迟
sudo bpftrace -e 'tracepoint:sched:sched_wakeup { @start[args->pid] = nsecs; }
tracepoint:sched:sched_switch /args->next_pid != 0/ {
    $pid = args->next_pid;
    if (@start[$pid]) {
        @[comm] = avg(nsecs - @start[$pid]);
        delete(@start[$pid]);
    }
}'

3.4 上下文切换原因分析

# 统计每个进程主动让出CPU的原因( voluntary vs involuntary )
sudo bpftrace -e 'tracepoint:sched:sched_switch {
    @[args->prev_comm, args->next_comm, args->prev_state] = count();
}'

prev_state 的值告诉你进程为什么让出CPU:0=仍在运行(被抢占)、1=可中断睡眠、2=不可中断睡眠(D状态,通常是IO等待)。

3.5 更多bpftrace CPU单行命令

# 追踪CPU迁移事件(发现NUMA/绑核问题)
sudo bpftrace -e 'tracepoint:sched:sched_migrate_task {
    printf("%s migrated: CPU %d -> %d\n", args->comm, args->orig_cpu, args->dest_cpu);
}'

# 统计每个CPU上的任务负载分布
sudo bpftrace -e 'tracepoint:sched:sched_switch {
    @[cpu] = count();
}'

# 追踪CPU频率变化(发现DVFS/省电模式问题)
sudo bpftrace -e 'tracepoint:power:cpu_frequency {
    @[cpu] = lhist(args->state, 1000000, 3000000, 100000);
}'

最后一条命令对排查云环境中的"诡异性能波动"尤其有用。某些云厂商的虚拟机可能会因为宿主机的节能策略而动态调整CPU频率——从3.0GHz降到1.2GHz,你的P99自然翻倍,但监控上看CPU使用率反而"更健康"了。BPF能帮你抓到这种"看不见的降频"。

四、从火焰图到行动:CPU分析的决策树

拿到火焰图后,下一步是什么?老张总结了一张决策树:

flowchart TD
    A[生成CPU火焰图] --> B{最宽的热点是?}
    B --> C[应用层函数
如: process_order]
    B --> D[库函数
如: malloc / JSON解析]
    B --> E[内核函数
如: __mutex_lock]
    B --> F[中断处理
如: net_rx_action]
    C --> G[优化业务逻辑
算法复杂度 / 缓存] D --> H[换库或调库参数
如: jemalloc / tcmalloc] E --> I{锁类型} I --> J[futex/mutex
减少临界区 / 无锁结构] I --> K[spinlock
避免在spinlock中睡眠] F --> L{中断类型} L --> M[网络
RPS/XPS / NIC多队列] L --> N[磁盘
IO调度器 / NVMe多队列] L --> O[定时器
合并定时器 / 用hrtimer]

4.1 案例:火焰图显示 __mutex_lock_slowpath 很宽

这是锁竞争的经典特征。行动步骤:

  1. offcputime 生成off-CPU火焰图,确认线程主要在等哪个锁
  2. stackcount 统计持有该锁时的调用栈,找到"锁热点代码"
  3. 优化策略:缩小临界区、用读写锁替代互斥锁、用无锁数据结构(如RCU、原子操作)、或者干脆减少线程数

4.2 案例:火焰图显示 net_rx_action 很宽

这说明软中断处理(NAPI轮询)消耗了大量CPU。行动步骤:

  1. 检查网卡是否启用了多队列:ethtool -S eth0 | grep queue
  2. 检查RPS/XPS配置:/sys/class/net/eth0/queues/rx-*/rps_cpus
  3. 考虑升级到支持 RSS(Receive Side Scaling,接收端扩展) 的网卡
  4. 检查是否开启了 ** busy polling** :/proc/sys/net/core/busy_poll

4.3 案例:火焰图显示 page_fault 相关函数很宽

这说明内存访问模式有问题。行动步骤:

  1. 检查是否有大量匿名内存分配(可能意味着频繁GC)
  2. biolatency 检查是否有swap IO(如果开启了swap)
  3. memleak 检查是否有内存泄漏导致频繁分配
  4. 考虑使用大页(HugePages)减少TLB miss

4.4 案例:调度延迟高但CPU空闲——线程爆炸

这是双十一那晚的真实场景。行动步骤:

  1. runqlat 确认调度延迟分布
  2. cpudist -O 确认off-CPU等待时间
  3. offcputime 生成off-CPU火焰图,定位线程让出CPU后在等什么
  4. 优化策略:减少线程数、改用事件驱动(epoll/io_uring)、用协程替代线程(如Go goroutine、Rust async)

五、最佳实践与常见误区

5.1 采样频率的选择

profile 工具的采样频率(-F 参数)是一个微妙的权衡:

频率优点缺点适用场景
49Hz低开销,避免与50/60Hz定时器共振样本少,短事件可能漏掉生产环境长期采样
99Hz样本充足,仍较低开销可能产生周期性噪音常规分析
997Hz极高分辨率明显开销,可能影响被测系统定位极短热点

Brendan Gregg 选择49Hz和99Hz不是随意定的——它们避开了50Hz/60Hz(电源频率)和100Hz/1000Hz(常见内核定时器频率),减少了系统性偏差。

5.2 符号解析的坑

火焰图的准确性严重依赖于符号解析——把栈上的地址转换成函数名。常见的符号问题:

  1. 内核符号:需要 /proc/kallsyms 可读,且内核编译时启用了 CONFIG_DEBUG_INFO
  2. 用户态符号:需要编译时带 -g 选项,且未strip
  3. Java/Go等JIT语言:需要运行时符号转储(如Java的 perf-map-agent 、Go的 GODEBUG=clobbertrace=1

BCC的 profile 工具内置了对Java和Go的符号支持(通过运行时perf-map),但配置可能较复杂。

5.3 on-CPU vs off-CPU的选择

场景分析方法工具
CPU使用率高,延迟也高on-CPU火焰图profile
CPU使用率不高,但延迟高off-CPU火焰图offcputime + runqlat
间歇性卡顿同时采集on/off-CPUprofile + runqslower
多线程服务线程级调度分析runqlat + cpudist

老张的座右铭:"当 top 说谎时,BPF说真话。"

5.4 综合排查流程:老张的’五步曲’

经过无数个凌晨的打磨,老张总结了一套标准化的CPU性能排查流程。他把它打印出来,贴在每个新入职工程师的显示器旁:

flowchart LR
    subgraph "Step 1: 快速确认"
        A1[top / htop] --> A2{CPU高?}
        A2 -- 是 --> B
        A2 -- 否 --> C
    end
    subgraph "Step 2: on-CPU火焰图"
        B[profile -F 99] --> B1[火焰图分析]
        B1 --> B2{热点?}
    end
    subgraph "Step 3: off-CPU分析"
        C[runqlat -m] --> C1{调度延迟?}
        C1 -- 正常 --> C2[offcputime
锁/IO等待] C1 -- 异常 --> C3[runqslower
捕获具体事件] end subgraph "Step 4: 中断排查" D[softirqs / hardirqs] --> D1{中断消耗高?} D1 -- 是 --> D2[网卡多队列
RPS/XPS] D1 -- 否 --> E end subgraph "Step 5: 系统调用" E[syscount -P] --> E1{syscall密集型?} E1 -- 是 --> E2[减少syscall
批量IO / 缓冲] end B2 --> D C2 --> D C3 --> D E1 -- 否 --> F[完成诊断] E2 --> F

小李后来把这张流程图简化成了一个 bash alias ——输入 cpu-diagnose ,自动按顺序运行前三个工具并生成报告。"工具是死的,"老张说,"但流程是活的。把流程自动化,你就能在凌晨两点保持清醒。"

总结

flowchart LR
    subgraph "数据采集"
        P["profile
on-CPU火焰图"] R["runqlat/runqslower
调度延迟"] C["cpudist
CPU运行分布"] O["offcputime
off-CPU火焰图"] I["softirqs/hardirqs
中断分析"] S["syscount
系统调用"] end subgraph "分析决策" F["火焰图分析
热点定位"] D["延迟分布
调度瓶颈"] A["行动方案
优化策略"] end P --> F O --> F R --> D C --> D I --> D S --> D F --> A D --> A

核心要点:

  1. CPU性能分析必须区分on-CPU和off-CPU:高延迟不一定伴随高CPU使用率——off-CPU等待(锁、调度延迟、IO)往往是真正的罪魁祸首
  2. 火焰图是on-CPU分析的终极武器profile 工具以接近零开销的频率采样CPU栈,生成的火焰图让你一眼定位热点函数
  3. runqlat是调度延迟的照妖镜:它测量线程在Run Queue中排队的时间,是发现"CPU看起来不忙但响应很慢"问题的关键工具
  4. runqslower用于捕获异常事件:设定阈值后只记录超长的调度延迟,适合与监控告警系统集成
  5. 中断是CPU时间的隐形消耗者softirqshardirqs 能发现 top 看不到的"偷渡客"

小李后来把双十一那晚的排查过程写成了一个内部SOP,标题叫《当CPU使用率31%但P99是2.3秒时该做什么》。第一步永远是:

sudo runqlat -m 60 &
sudo profile -F 99 -a 30 > /tmp/out.stacks

"BPF不会让你失望,"老张在SOP的批注里写道,"它只会让你看见你以前选择不去看的东西。"

小李把这句话加粗,放在了文档第一页。