线程池配太大,上下文切换成了CPU隐形杀手

本文是 Linux 系统排查基本功系列的第 2 篇 叙事框架:现象 → 排查过程 → 根因 → 修复 → 预防

问题现象

告警触发

某日上午 10 点 40 分,Prometheus 告警机器人向 SRE 值班群推送了一条告警:订单服务 order-svc-07 的接口耗时 p99 从 45ms 突然飙升至 320ms。几乎同时,另一条告警显示该节点的 CPU system 占用率达到了 58.7%,user 仅 33%,总 CPU 占用超过 92%。更值得注意的是一条关联告警——系统上下文切换(context_switches)达到 143,712/s,而基线只有 22,000/s,升高了 6.5 倍。

值班团队立刻响应,张工登录服务器开始排查。

SRE 值班群告警通知

上机排查遇阻

张工 SSH 登入 order-svc-07 后,第一件事就是执行 vmstat 1 5 查看系统状态:

vmstat 上下文切换飙升

输出立刻暴露了问题:cs(context switches)列稳定在 141,000-145,000/s,这远远超出正常范围。一台 8 核服务器每秒 14 万次上下文切换,意味着每个 CPU 每秒钟要切换 17,500 多次,即每 57 微秒就要切一次。与此同时,sy(system)列高达 58-60%,说明 CPU 有近六成的时间花在内核态(调度、锁管理、系统调用),而非业务代码。

接着查看 /proc/stat 确认了 ctxt 总量已经达到 1.42 亿次,这些都是从系统启动以来累计的上下文切换次数。结合 sar -w 的历史数据可以判断,这个趋势是从当天上午 10:30 左右开始恶化的。

初步猜测

张工的第一判断是:要么是某个进程 fork 了大量短时子进程,要么是 Java 服务内部线程数过多导致调度器不堪重负。王哥这时确认了上午 10:30 刚上线了新版本,主要变更就是放大了 Tomcat 和业务线程池的参数。

排查过程

第一步:定位上下文切换来源

操作动机:vmstat 已经暴露了系统级上下文切换异常高,但 vmstat 看不到每个进程的贡献。需要按进程细看,才知道是哪个应用在制造大量切换。

pidstat -w 按进程查看上下文切换次数:

pidstat 线程上下文切换

输出解读:cswch/s 是自愿上下文切换(主动让出 CPU,如等待锁或 IO 完成),nvcswch/s 是非自愿切换(时间片耗尽被调度器强制换出)。Java 进程 PID 15234 的自愿切换达到 239/s,非自愿切换达到 187/s。对比同一台机器的 nginx(cswch/s 不超过 1.2),差异悬殊。说明这个 Java 进程内部线程数极多、锁竞争极其激烈。

结论推导:非自愿切换 187/s 远超自愿切换的一半,意味着线程之间的 CPU 争抢非常激烈——每个线程分到的时间片过短,还没干完活就被迫让出 CPU。这是典型的 oversubscription 特征。

查看 /proc/15234/status 确认了问题所在:该 Java 进程创建了 487 个线程。对于一个只有 8 核的服务器来说,487 个线程同场竞技,调度器每秒要在它们之间做几百次选择和后文切换。

第二步:从 perf 看 CPU 到底花在哪

既然确认了 Java 进程是上下文切换的源头,接下来用 perf top 看 CPU 热点分布:

perf 调度器热点分析

结果非常典型:

排名 函数 CPU占比 含义
1 __schedule 12.4% 调度器主函数,每次进程切换的核心入口
2 _raw_spin_lock 10.8% 自旋锁竞争,大量线程在抢锁
3 do_futex 9.6% 用户态锁(Java synchronized/ReentrantLock)的底层实现
4 try_to_wake_up 8.7% 唤醒等待线程,每次 unparl/unlock 触发
5 finish_task_switch 7.9% 切换完成的收尾工作

perf stat 还暴露了一个关键数据:每指令周期比(instructions per cycle)只有 0.36。正常 CPU 密集型的 IPC 应该在 1.5-2.0 左右,0.36 说明 CPU 大量时间在停滞等待(cache miss、TLB miss、分支预测失败),而这些恰好是上下文切换的后遗症。

第三步:追查历史基线

sar -w 看上下文切换的时序趋势:

sar 历史上下文切换趋势

从数据可以清楚看到:10:10 之前,系统上下文切换稳定在 22,000/s 左右,CPU system 不到 9%。10:10 开始 cs 跳到 71,000/s,10:20 之后稳定在 141,000-143,000/s。CPU system 也从 8% 一路升到 58%。这个时间点与王哥的上线时间完美吻合。

第四步:代码审查确认配置问题

回到代码层面,王哥展示了这次上线的线程池配置变更:

有问题的线程池配置

问题一目了然:三个独立的业务线程池(orderQueryExecutororderProcessExecutororderNotifyExecutor),每个都配了 core=32、max=64、queue=2048。加上 Tomcat 的 max-threads=500,总线程数上限达到 500 + 3×64 = 692 个。

但机器只有 8 个 CPU 核。

根因分析

子原因 1:调度器过载——每秒 14 万次切换的数学

先算一笔经济账。pidstat 显示 Java 进程每秒产生 239+187=426 次切换。但 vmstat 显示系统总切换是 143,000/s。为什么呢?因为 vmstat 统计的是全系统所有线程的切换,包括内核线程(ksoftirqd、kworker、migration)、Java 进程内部的 487 个线程相互切换、以及其他守护进程。Java 进程的 426 次/s 是针对该进程汇总后的净切换,而内核每 tick(通常 250Hz/4ms)就有一次调度器 tick,各种内核线程也在不断相互切换。

每次上下文切换的成本包含两部分,理解这两部分的区别对于评估性能影响至关重要:

直接成本(约 3-10 us),这部分是 CPU 显性耗时: - 保存当前线程的寄存器(PC、SP、通用寄存器)到 PCB(Process Control Block) - 切换 MMU 页表(写入 CR3 寄存器,x86 架构下约 500-800 cycles) - TLB 刷新——Translation Lookaside Buffer 全部失效,后续所有内存访问都要走页表遍历 - 加载新线程的寄存器状态 - 执行 finish_task_switch 收尾,更新调度统计

间接成本(约 10-50 us,远超直接成本),这部分是 CPU 隐形成本: - L1/L2/L3 cache 冷启动:线程被换出后,其热数据在 cache 中被其他线程逐步覆盖。当线程再次被调度回来时,L1 指令 cache miss 率从接近 0% 飙升到 50-80%,因为上次缓存的指令和数据已经被新线程冲刷掉了 - TLB 刷新后,旧进程的大量虚拟地址到物理地址映射关系丢失,随后的内存访问需要逐条重新建立页表缓存 - 分支预测器(BTB)需要重新训练——分支预测表存储的是前一个线程的分支历史,新线程的代码路径完全陌生

需要注意的是,现代 CPU(如 Intel Skylake 及之后)通过 PCID(Process Context Identifier)技术部分缓解了 TLB 刷新的开销——切换时可以保留全局页表项。但 L1/L2 cache 的冷却问题仍然存在,因为 cache 是物理寻址的,不区分进程。这也是为什么上下文切换的间接成本通常比直接成本高 3-5 倍。

每秒 143,000 次切换 × 平均每次 15 us(保守估计)= 2,145,000 us = 2.145 秒/秒。也就是说,CPU 每秒有超过 2 秒花在了上下文切换本身(多核并行分摊后,单核视角放大了这个效应)。

下面这张图展示了一次完整上下文切换的 CPU 内部流程:

上下文切换生命周期

子原因 2:锁竞争与 Futex 风暴

perf top 中看到 do_futex 占了 9.6%,_raw_spin_lock 占了 10.8%。这两个数据揭示了锁竞争的严重性。

Java 的 synchronizedReentrantLock 在竞争激烈时会退化为内核态的 Futex 系统调用。流程如下:

线程尝试加锁 → CAS 快速路径失败 → 调用 sys_futex(FUTEX_WAIT) → 内核将该线程挂起 → 锁持有者释放锁时 sys_futex(FUTEX_WAKE) → 内核唤醒等待线程 → 调度器将线程切换回 CPU → 上下文切换发生。

487 个线程抢一把锁,意味着几乎每次加锁操作都会触发 Futex 系统调用,每次 Futex 唤醒都可能伴随一次上下文切换。

Futex 锁竞争恶性循环

子原因 3:反向缩放——线程越多吞吐越低

CPU 密集型应用的线程数并非越多越好。基本公式是:

最优线程数 = CPU 核数 + 1  (纯 CPU 密集型)
最优线程数 = CPU 核数 × (1 + 等待时间/计算时间)  (一般公式)

对于 order-svc-07 的场景,大多数请求处理是本地计算(订单校验、价格计算、状态流转),属于 CPU 密集型。因此最优线程数约为 8-16。当线程数达到 487 时,发生了严重的 oversubscription:

线程数与吞吐量的关系

子原因 4:线程局部性丧失

上下文切换还有一个容易被忽略的代价——线程局部性(thread locality)的丧失。在线程数合理的情况下,每个线程倾向于在同一个 CPU 核上运行较长时间,L1/L2 cache 中的数据高度相关,形成良好的局部性。但当线程数远超 CPU 核数时,调度器被迫频繁地将线程迁移到不同核上运行(从 perf stat 中可以看到 cpu-migrations 指标)。迁移意味着线程在核 A 上积攒的热 cache 内容全部作废,迁移到核 B 后一切从头开始。cache 预热周期通常在 5-20 us,这段时间内该线程几乎不产生有效产出。子原因 1 中的上下文切换生命周期图展示了每次切换时 cache 被冲刷的全过程,可以对照理解。

子原因 5:锁、切换、缓存三者相互强化

问题不是单因单果,而是三个因素的恶性循环:

  1. 线程过多 → 锁竞争加剧:更多线程同时争抢同一把锁,Futex 调用暴增
  2. 锁竞争加剧 → 更多上下文切换:线程在 park/unpark 之间频繁切换
  3. 更多切换 → 缓存污染加重:每次切换都导致 cache/TLB 刷新,IPC 从 1.8 跌到 0.36
  4. 缓存污染 → 吞吐下降 → 试图加更多线程:开发人员看到吞吐不够,直觉反应是加线程,结果适得其反

下图展示了线程状态转换和每次转换的上下文切换成本:

线程状态机与上下文切换触发点

根因汇总

各因素对 CPU 的贡献汇总:

因素 对 sy CPU 的贡献 占比
调度器 __schedule 切换开销 ~12% 20%
锁竞争(spin_lock + futex) ~20% 34%
线程唤醒/入队(try_to_wake_up) ~9% 15%
调度队列管理(enqueue/dequeue) ~8% 14%
其他系统调用 ~10% 17%
合计 ~59% 100%

修复方案

第一步:评估现状

当前配置的问题:

  • Tomcat max-threads=500:对于 8 核机器处理 CPU 密集请求,200 已经足够。Tomcat 线程池本质上是一个 Worker 池,每个线程处理一个请求。当所有线程都处于计算密集型状态时,500 个线程只会相互争抢 CPU。
  • 三个独立的业务线程池各 core=32 max=64:每个业务都创建自己的线程池,彼此隔离但又共用同一组 CPU 资源。建议统一为一个共享线程池,资源和负载可以弹性调配。
  • 线程池参数硬编码:没有根据 CPU 核数动态调整,换到不同规格的机器上要么不够用要么过度分配。

第二步:确定优化方向

  • 线程数 = f(CPU 核数):动态计算,不硬编码
  • Tomcat 保持 200(8×25,IO 密集型留有余量)
  • 业务逻辑尽量在 Tomcat 线程上执行,避免额外的线程池切换
  • 必须异步的场景使用统一的 common 线程池
  • 减少细粒度锁,检查锁范围是否过大

第三步:代码修改

优化后的配置类:

优化后的线程池配置

改动要点:

  1. 引入 availableProcessors():所有线程池参数基于 CPU 核数动态计算,不硬编码
  2. 统一线程池:业务逻辑尽量在 Tomcat 线程中完成,只有真正耗时的后台任务才提交到 commonTaskExecutor
  3. Tomcat 降为 200(8×25):考虑到请求中存在少量 IO 等待(DB 查询),保留一定余量
  4. 核心线程数合理commonTaskExecutor core=8 max=16,backupQueryExecutor core=5 max=8
  5. 队列容量从 2048 降至 512:过大队列会掩盖线程池满的问题,导致请求等待时间不可控

第四步:上线部署

修改配置后,逐步灰度上线。先让一台机器生效,观察 10 分钟确认无异常后再全量发布。

验证结果

即时指标

部署完成后,再次用 vmstatpidstat 验证效果:

修复后 vmstat 恢复正常

对比数据一目了然:

指标 修复前 修复后 变化
上下文切换/s 143,712 18,105 -87%
CPU system 58.7% 8.9% -85%
CPU idle 8.4% 23.7% +182%
线程数 487 127 -74%
P99 RT 320ms 42ms -87%
自愿切换/s 239 18 -92%
非自愿切换/s 187 7 -96%

非自愿切换从 187/s 降到 7/s,说明线程不再被调度器强制换出,时间片用完之前就能完成任务。

团队复盘

修复完成后,团队在值班群进行了复盘讨论:

复盘讨论

避坑建议

  1. 线程池参数要有容量测算依据:每次改 max-threads 前,先算一算:当前 QPS × 平均 RT = 活跃线程数。如果活跃线程数只有 50,把 Tomcat 调到 500 只会带来副作用。
  2. 区分 CPU 密集型和 IO 密集型:CPU 密集型用 核数+1,IO 密集型的倍数根据 IO wait 比例估算。不确定时用 JFR/Arthas profiling 看一看实际线程状态分布。
  3. 不要每个业务创建独立线程池:除非有明确的隔离需求(如避免慢任务拖垮快任务),否则统一线程池更容易管理和调优。多个小池子会造成资源碎片化。
  4. 上线前在压测环境验证线程数水位:用 Gperf 全链路压测观察 vmstat cs 和 pidstat cswch/nvcswch,确保切换次数在合理范围。cs 超过 5 万/s 就应该怀疑线程数过多。
  5. 监控 OS 级调度指标:除了应用层面的 QPS/RT/error,监控面板上应该加上 context_switches、cpu_system、voluntary/nonvoluntary_ctxt_switches、线程数这些 OS 层面的信号。
  6. 非自愿切换是反向缩放的前哨指标:如果 nvcswch/s 超过 cswch/s 的 30%,说明线程严重争抢 CPU。这时候加线程只会让系统更慢。
  7. IPC 是理解 CPU 效率的核心指标:用 perf stat 查看 instructions per cycle。IPC < 1.0 说明 CPU 大量停滞在 memory stall 上,常见于缓存抖动、上下文切换频繁的场景。

附:完整命令清单

# 查看系统上下文切换
vmstat 1 5
cat /proc/stat | grep ctxt

# 按进程查看上下文切换
pidstat -w -p ALL 1 3

# 查看进程线程数
cat /proc/{pid}/status | grep Threads
cat /proc/{pid}/status | grep -E 'voluntary|nonvoluntary'

# CPU 热点分析
perf top -K -p {pid} --sort=comm,dso,symbol
perf stat -e context-switches,cpu-migrations,cycles,instructions -p {pid} --sleep 5

# 历史切换趋势对比
sar -w -f /var/log/sysstat/sa{日期}

# 可用处理器核数(Java)
Runtime.getRuntime().availableProcessors()