3、CPU瓶颈分析:CPU使用率过高、上下文切换频繁、线程死锁、CPU缓存未命中等问题定位与解决。

CPU 瓶颈,说白了就是你的程序把 CPU 给「喂饱」了,或者喂的方式不对。我见过太多团队,一看到 CPU 飙到 100% 就慌了,上来就加机器。其实很多时候,问题根本不在硬件,而在代码本身。

这一章,我们就来聊聊 CPU 相关的几个典型问题:使用率过高、上下文切换频繁、线程死锁,还有 CPU 缓存未命中。嗯,每个坑我都踩过,希望你能少走弯路。

3.1 CPU 使用率过高:是计算密集还是代码有坑?

CPU 使用率 100%,不一定就是坏事。如果你的程序就是做视频编码、科学计算的,那 100% 反而是好事——说明资源用满了。但如果你写的是一个 Web 服务,CPU 长期 90% 以上,那就要警惕了。

定位工具:

  • top / htop:第一眼就看 %CPU 列,哪个进程高一眼就能看到。
  • perf top:实时看 CPU 在「忙」什么函数。我个人习惯先用这个,比 strace 轻量得多。
  • 火焰图:用 perf record 采集数据,生成火焰图。一眼就能看出哪个调用栈最「烫」。

实战案例:

有一次我接手一个后台服务,CPU 使用率一直在 85% 左右。用 perf top 一看,发现 strlen() 占了 30% 的 CPU。仔细查代码,原来是在一个循环里反复对同一个长字符串调用 strlen()。说白了,就是每次循环都重新计算长度,完全没必要。改成在循环外算一次长度,CPU 直接降到 30%。

常见原因:

  • 死循环或空转(比如 while(true) 里没加 sleep)
  • 频繁的字符串操作、内存分配
  • 不合理的正则表达式回溯
  • 序列化/反序列化过于频繁

我的小技巧: 别只看 CPU 使用率,还要看 %user 和 %sys 的比值。如果 %sys 很高,说明系统调用太频繁,可能是锁竞争或者 I/O 模型有问题。

3.2 上下文切换频繁:看不见的性能杀手

上下文切换,就是 CPU 从一个线程切到另一个线程。每次切换都要保存和恢复寄存器、刷新 TLB,成本不低。如果切换太频繁,CPU 大部分时间都在「做家务」,而不是真正干活。

怎么看?

  • vmstat 1:看 cs(context switch)列。如果每秒超过几万次,就要注意了。
  • pidstat -w:按进程看上下文切换次数。

为什么会频繁切换?

  • 线程数远大于 CPU 核数。你想想看,100 个线程抢 4 个核,不切才怪。
  • 锁竞争激烈。线程拿不到锁就挂起,拿到锁又唤醒,来回切换。
  • I/O 阻塞太多。比如每个请求都开一个线程去等数据库返回。

我曾经踩过的坑: 一个 Java 服务,线程池设成了无界队列 + 核心线程数 200。结果高峰期线程数飙到 2000,上下文切换每秒 15 万次。CPU 看起来忙得要死,但吞吐量反而下降了。后来改成协程 + 有限线程池,问题解决。

解决思路:

  • 控制线程数,一般建议线程数 = CPU 核数 * (1 + 等待时间/计算时间)。
  • 用协程/纤程替代线程(Go 的 goroutine、Java 的虚拟线程)。
  • 减少锁的粒度,或者用无锁数据结构。

3.3 线程死锁:程序「假死」的元凶

死锁,就是两个线程互相等对方释放锁,结果谁也动不了。程序看起来还活着(进程在),但就是不干活了。

怎么发现?

  • jstack(Java):直接打印线程栈,死锁会被明确标记出来。
  • pstack(C/C++):类似 jstack,看线程调用栈。
  • gdb attach:挂到进程上,看每个线程在等什么锁。

经典死锁场景:

// 线程1
lock(A);
lock(B);

// 线程2
lock(B);
lock(A);

两个线程各拿一把锁,然后等对方释放。嗯,这就是教科书级的死锁。

避坑指南:

  • 尽量使用 固定顺序加锁。比如所有线程都先锁 A 再锁 B,就不会死锁。
  • 使用 tryLock 并设置超时,拿不到就释放已有锁,避免死等。
  • 减少锁的嵌套层级。能用一个锁解决的,别用两个。

我个人习惯: 在代码里加一个死锁检测线程,每隔几秒检查一次所有线程的状态。如果发现某个线程长时间处于 BLOCKED 状态,就报警。这招救过我两次。

3.4 CPU 缓存未命中:被忽视的性能黑洞

CPU 缓存(L1/L2/L3)的速度比内存快两个数量级。如果你的数据在缓存里,访问只要几个纳秒;如果不在,就要去内存拿,几十甚至上百纳秒。你想想看,如果频繁缓存未命中,性能损失有多大。

怎么发现?

  • perf stat -e cache-misses:直接看程序的缓存未命中率。
  • valgrind --tool=cachegrind:模拟缓存行为,给出详细的命中率报告。

常见原因:

  • 伪共享(False Sharing):多个线程修改同一个缓存行上的不同变量。虽然变量不同,但缓存行是整块加载的,一个线程改了,其他线程的缓存行就失效了。
  • 数据访问不连续:比如按列遍历二维数组,而不是按行。因为数组在内存里是行优先存储的,按列访问会频繁跳过缓存行。
  • 链表遍历:链表节点在内存里是分散的,每次访问都可能触发缓存未命中。

伪共享的例子:

// 两个线程分别修改 count1 和 count2
struct data {
    int count1;  // 线程1 修改
    int count2;  // 线程2 修改
};

count1 和 count2 很可能在同一个缓存行里。线程1 改了 count1,线程2 的缓存行就失效了,即使它只关心 count2。解决办法是加 padding,让它们不在同一缓存行。

优化方法:

  • 数据对齐:用 __attribute__((aligned(64))) 或 std::hardware_destructive_interference_size。
  • 尽量使用数组而不是链表。数组在内存里是连续的,缓存友好。
  • 循环时按内存顺序访问数据(行优先)。

我曾经犯过的错: 写一个多线程统计程序,每个线程更新自己的计数器。结果性能还不如单线程。用 perf 一看,缓存未命中率高达 40%。就是因为伪共享。后来把计数器放到不同的缓存行里,性能提升了 3 倍。

3.5 总结:CPU 调优的「三板斧」

好了,这一章的内容不少。我帮你理一下核心思路:

问题 核心指标 常用工具 解决方向
CPU 使用率过高 %CPU, %user, %sys top, perf, 火焰图 减少无效计算、优化算法
上下文切换频繁 cs (context switch) vmstat, pidstat 控制线程数、使用协程
线程死锁 线程状态 BLOCKED jstack, pstack, gdb 固定加锁顺序、tryLock
CPU 缓存未命中 cache-misses perf, cachegrind 数据对齐、连续访问

说实话,CPU 调优没有银弹。但只要你掌握了这几个工具和思路,大部分问题都能快速定位。嗯,下一章我们会聊内存瓶颈,到时候你会发现,很多「内存问题」其实也是 CPU 问题——因为内存慢了,CPU 就得等。

记住一句话:先看 CPU 在忙什么,再想怎么让它不忙。