taskset 绑核后 QPS 腰斩?8 核只用了 2 核

场景:8 核 2 NUMA 节点的交易服务,上线后 QPS 从 5000 掉到 2600,RT P99 从 20ms 抖到 300ms——CPU 总使用率才 40%,加机器也没用 路径:top -H 找热点线程 → taskset -pc 看绑定 → numactl -H 看拓扑 → numastat -p 看内存节点 → 绑核范围 + 内存绑定双修

上篇我们聊了 CPU 空闲但 Load 飙高的四种路径,这篇来看一个反过来的坑——CPU 明明够用,QPS 却腰斩

给 8 核的机器,Java 进程却只用 2 核。不是代码问题,是 taskset 把进程"关"进了笼子。


【症状】QPS 从 5000 掉到 2600

上线后一切正常,第三天开始掉

三天前给核心交易服务升了配:从 4 核升到 8 核,内存从 64G 升到 128G。前两天 QPS 稳定在 5000,一切正常。

第三天上午,值班同事看到 CPU 有 6 个核闲着——"浪费了",为了防止上下文切换和缓存抖动,顺手执行了一条命令:

taskset -c 0-1 -p <pid>

给 Java 进程绑了 CPU 0-1。

当天下午开始,监控曲线不对劲了:

  • QPS:从 5000 逐步滑落到 2600
  • RT P99:从 20ms 抖到 300ms,时不时冲到 1s
  • CPU 总使用率:才 40%

不是断崖式下跌,而是缓慢下滑——绑核让线程挤在 2 核上,RT 爬升 → 请求排队积压 → 客户端超时重试 → 重试又占 CPU → RT 更高,这个雪球滚了一下午,QPS 一点点被拖下来。

交易服务监控——QPS 下滑、RT 抖动、CPU 总使用率只有 40%

CPU 才用一半,QPS 却掉了一半。这不符合直觉。

团队的第一反应和所有人一样:查代码。没有慢 SQL,没有锁竞争,没有 GC 抖动。又加了一台机器——每台 QPS 还是 2600,总量只到 5200:核数翻倍也没换回该有的吞吐。绑核让进程吃不下新核,等于把同一个问题复制给了两台

加机器和重启都试过了,没用——不是资源不够,是资源被关进了笼子。团队群里已经有人开始怀疑是代码问题了。

真相藏在 top -H 里

top 默认显示的是进程级 CPU。要定位 Java 这种多线程程序,必须先按 H 切到线程视图,再按 P 按 CPU 排序。

top -H 线程视图——2 根线程各占 99%/98%,热点高度集中

top - 15:42:11 up 3 days,  2 users,  load average: 2.60, 2.40, 2.20
Tasks: 123 total,   1 running, 122 sleeping,   0 stopped,   0 zombie
%Cpu(s): 22.0 us, 15.0 sy,  0.0 ni, 62.0 id,  0.0 wa,  0.0 hi,  1.0 si,  0.0 st
KiB Mem : 134217728 total,  95217728 free,  9000000 used,  30000000 buff/cache

  PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
 6789 root      20   0 25.5g 4.4g  45m S 197.0   3.4   1:03:38 java

Threads:  89 total,   1 running,  88 sleeping,   0 stopped,   0 zombie

  PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
 6789 root      20   0 25.5g 4.4g  45m R  99.0   3.4  32:15.34 java
 6790 root      20   0 25.5g 4.4g  45m R  98.0   3.4  31:02.45 java
 6791 root      20   0 25.5g 4.4g  45m S   1.0   3.4   0:08.23 java
 6792 root      20   0 25.5g 4.4g  45m S   1.0   3.4   0:06.45 java
 6793 root      20   0 25.5g 4.4g  45m S   1.0   3.4   0:04.12 java
 6794 root      20   0 25.5g 4.4g  45m S   0.0   3.4   0:02.34 java
 6795 root      20   0 25.5g 4.4g  45m S   0.0   3.4   0:01.56 java

%Cpu(s) 显示机器整体才用了 37% 左右(us 22% + sy 15%),但注意最上面进程视图里的 197.0——进程自己已经打满了 2 个核

线程视图暴露了真凶:两根线程各自占了 99% 和 98%,剩下 5 根线程几乎不干活。机器一半核空着,进程却在 2 根线程上挤到冒烟。

top -H 本身不显示线程跑在哪个核上。8 核机器只看到 2 根线程打满、其余歇着,有两种可能:要么应用并行度本来就低,要么被绑核限制只能跑 2 个核。到底哪种,taskset -pc 下一步直接给出答案。

术语top 的进程级视图把进程内所有线程的 CPU 加总了。线程视图(top -H)才显示每个线程的真实占用。多线程程序排查第一件事就是切线程视图——否则热点会被平均值掩盖。想看线程实际跑在哪个核,用 ps -eLo pid,tid,psr,pcpu | sort -k4 -rn | head(psr 列就是 CPU 编号)。

谁的锅?taskset -pc 一查便知

进程的 CPU 亲和性(affinity——允许进程在哪些核上运行)是排查的第一个问题:

taskset -pc 6789
pid 6789's current affinity list: 0-1

0-1——这个 8 核进程被绑死在了 CPU0 和 CPU1 上。

taskset -pc <pid> 是查看进程 CPU 亲和性的命令,输出 0-1 表示进程只能在核 0 和核 1 上运行。8 核的机器,6 个核在围观。

绑核的同事动机不坏——"为了防止上下文切换和缓存抖动",本意是好的,方向却是错的。而且这解释了为什么前两天没事、第三天下午开始崩:不是升配出了问题,是绑核出了问题


【诊察】命令组合定位:绑核不是唯一问题

第一步:确认绑定范围

taskset -pc 6789          # 查看当前亲和性
taskset -pc 0-7 6789      # 先扩大范围,观察是否恢复

扩到 0-7 后,QPS 从 2600 回到 4200,但没回到 5000。RT 还是偶尔抖到 100ms+。

说明绑核范围是问题的一部分,但不是全部

第二步:numactl -H 看 NUMA 拓扑

8 核机器往往不是"8 个平等的核",而是 2 个 NUMA 节点。numactl -H(NUMA 拓扑查询命令,-H 是 --hardware 的简写)一跑就现原形:

numactl -H 输出——node0 有 CPU0-3 和内存,node1 有 CPU4-7 和内存,跨节点距离 21

available: 2 nodes (0-1)
node 0 cpus: 0 1 2 3
node 0 size: 64320 MB
node 0 free: 41000 MB
node 1 cpus: 4 5 6 7
node 1 size: 65536 MB
node 1 free: 50123 MB
node distances:
node   0   1
  0:  10  21
  1:  21  10

这台机器是 2 个 NUMA 节点: - node0:CPU 0-3,附赠一块本地内存 - node1:CPU 4-7,附赠另一块本地内存 - node distances:同节点访问开销是 10,跨节点是 21——跨节点内存访问比本地慢一倍

术语:NUMA(Non-Uniform Memory Access,非一致性内存访问)架构下,每个 CPU 访问自己节点内的内存快、访问别的节点的内存慢。机器核越多越可能是 NUMA 架构——numactl -H 第一行 available: N nodes 直接告诉你是不是。

第三步:numastat -p 看内存落在哪个节点

绑核只限制了 CPU 在哪个节点跑,没限制内存分配在哪个节点numastat -p 6789(查看进程内存的节点分布)揭开了最后一块拼图:

numastat -p 输出——进程 4479MB 内存中 2702MB 落在 node1,而 CPU 被绑在 node0

Per-node process memory usage (in MBs) for PID 6789 (java)
                           Node 0          Node 1           Total
                  --------------- --------------- ---------------
Huge                           0.00           0.00            0.00
Heap                         512.12        1890.88         2403.00
Stack                          0.05           0.01            0.06
Private                     1264.62         811.68         2076.30
----                  --------------- --------------- ---------------
Total                      1776.79        2702.57         4479.36

进程 4479MB 内存里有 2702MB 落在 node1(占 60%),其中堆(Heap)的 1890MB 基本都在 node1。 而 CPU 被绑在 node0——进程的堆访问大部分要跨节点。

术语numastat -p 按 Huge/Heap/Stack/Private 四类列出进程内存在每个节点的分布。Node 0/Node 1 两列对应 NUMA 节点。看哪列大,就知道内存偏向哪个节点。

判断标准:进程内存在非绑核节点的比例接近或达到一半以上(本例 60% 在 node1,而 CPU 全在 node0)就是严重跨节点——正常情况内存应主要落在 CPU 运行的节点。这个数字解释了为什么刚才扩核后 QPS 只回到 4200 没到 5000:线程能跑了,但近六成堆访问仍要跨节点

为什么内核没自动修好? 这台机器跑的是内核 3.10——自动 NUMA balancing(numa_balancing)在 3.8 引入、3.13 起默认开启,3.10 默认关闭(cat /proc/sys/kernel/numa_balancing 输出 0)。所以跨节点状态持续了三天没人来搬。现代内核(3.13+)默认开启会自动迁移,但手动绑核仍会干扰这个机制。

命令对照表

两条命令的输出互相印证:

命令 发现 结论
taskset -pc 6789 affinity 只有 0-1 进程被绑死 2 核,6 核围观
numactl -H 2 节点,跨节点距离 21 内存与 CPU 分属两个 node
numastat -p 6789 node1 占 60%(2702MB) 大部分内存在绑核节点之外

三条线索指向同一个真相:绑核只绑了 CPU,没绑内存,还绑窄了。


【路径】下次遇到直接跑

四命令排查组合

看到"CPU 总使用率不高但 QPS 掉、RT 抖"时,按这个顺序排查:

# 1. 线程视图找热点(多线程程序第一步)
top -H -p <pid>          # 按 H 切线程视图,按 P 按 CPU 排序
# 期望看到:少数线程各占 90%+,热点高度集中
# 想看线程实际跑在哪个核:ps -eLo pid,tid,psr,pcpu | sort -k4 -rn | head

# 2. 查 CPU 亲和性
taskset -pc <pid>        # 输出 0-1 → 被绑死 2 核
grep Cpus_allowed_list /proc/<pid>/status   # Cpus_allowed_list: 0-1 → 与 taskset 一致,确认绑窄

# 3. 查 NUMA 拓扑
numactl -H               # available: N nodes → 是不是 NUMA 架构
lscpu | grep NUMA        # 另一种方式看节点信息

# 4. 查进程内存的节点分布
numastat -p <pid>        # node1 占比高而 CPU 在 node0 → 内存在对面的 node
cat /proc/<pid>/numa_maps | head -20   # 逐段看每块内存落在哪个节点

以本文的 PID 6789 为例,第 2/4 步的真实输出:

# 第 2 步:亲和性
$ taskset -pc 6789
pid 6789's current affinity list: 0-1

# 第 4 步:内存节点分布
$ numastat -p 6789
                           Node 0          Node 1           Total
Heap                         512.12        1890.88         2403.00
Private                     1264.62         811.68         2076.30
Total                      1776.79        2702.57         4479.36

判断规则:

看到 说明 下一步
热点线程挤在少数核 CPU 亲和性过窄 taskset -pc 查绑定
affinity 只有 2-3 个核 绑核范围太小 扩到整个 node 或解绑
numactl -H 显示 2+ 节点 是 NUMA 架构 检查内存是否跨节点
numastat 内存在非绑核节点占 ≈50% CPU 与内存跨节点 同时绑内存或改分配策略

经验之谈:这四条命令跑完不超过 30 秒,从"CPU 不高但 QPS 掉"到"绑核绑窄了 + 内存跨节点"——问题定位 1 分钟。跨节点严重度的判读标准见上文诊断段。


【定位】绑核是怎么把 Java 拖垮的?

三层危害,一层比一层隐蔽

你以为 taskset -c 0-1 只是"少用了几核"?不是。它对 Java 是三重打击:

第一层:线程全部挤在 2 个核上排队。

进程的几十个线程只能在 CPU0/CPU1 上运行。核就两个,线程一堆——CPU0/CPU1 打满到 99%,剩下 6 个核围观。不是 CPU 不够,是线程被塞进了笼子

第二层:绑核的时机,决定了 JVM 会不会"自废武功"。

绑核发生在运行期(对已启动的进程执行 taskset -p),此时 JVM 已经按 8 核配置好了——GC 线程 8 个、ForkJoinPool 并行度 7,都照常工作。所以本场景里,JVM 自身的并行度没有受影响。

但如果你是在启动时绑核(taskset -c 0-1 java -jar),情况完全不同:JVM 启动时通过 sched_getaffinity() 探测可用 CPU 数,taskset 绑定后它读到的就是 2——于是 GC 线程、ForkJoinPool 并行度、并行编译等自适应参数全部按 2 核收缩,整个运行时并行能力降级。

术语sched_getaffinity() 是 Linux 获取进程 CPU 亲和性的系统调用。JVM 的 Runtime.availableProcessors() 在 Linux 上就基于它——所以启动时用 taskset 绑核,会直接改变 JVM 感知的 CPU 数量。运行期绑核则不影响已完成的探测。

第三层:绑核不绑内存 → 跨节点访问惩罚。

Linux 的内存分配默认是 first-touch 策略——内存页落在第一个访问它的 CPU 所在节点。JVM 启动时没绑核,堆被各个启动线程触碰,按 first-touch 落在触碰它的 CPU 所在节点——本例大部分堆落到了 node1。

后来运行期把 CPU 收窄到 node0,但堆还躺在 node1 上——taskset 只绑 CPU 不绑内存。numastat 显示进程 60% 内存在 node1,这意味着近六成内存访问都要跨节点,延迟翻倍(距离 21 vs 10)。这正是刚才扩核后 QPS 没回到 5000 的原因。

Diagram: taskset 绑核 + 未绑内存 → CPU 在 node0、60% 内存在 node1 → 近六成访问跨节点;修复后 CPU 与内存同 node

内核在做什么?

这不是应用代码的问题——是内核调度器与内存分配策略共同造成的结果:

  1. 内核调度器严格遵循 CPU 亲和性掩码(sched_setaffinity 设置的 bitmask),即使其他核空闲,也绝不把线程调度到亲和性之外的核上。这是硬约束,不是"尽量"。
  2. 内存分配器按 first-touch 原则把页放到第一个触达它的 CPU 的节点上,与进程当前的 CPU 亲和性无关——所以运行期收窄绑核,堆仍然留在启动时分布的节点上。
  3. JVM 运行时在启动时根据 sched_getaffinity() 的返回值决定并行度。运行期收窄绑核不影响已完成的探测,但启动时就绑核,JVM 会把整台机器当成少数核来配置。

三个机制独立运作、互不知情——组合起来就是"CPU 够用、内存够用、就是慢"。

金句:"绑核不是给进程配 CPU,是在给内核下命令——下错了,它连你剩下的核和内存都不让你碰。"


【处方】绑核的三条正确姿势

姿势一:解绑,让内核自己调度(推荐)

大部分业务场景,Linux 的调度器做得比人好。把绑核解除,内核自然把线程铺到所有核上:

# 解绑 = 把 affinity 设为全部核(0-7 按实际核数调整,进程级即时生效)
taskset -pc 0-7 <pid>

姿势二:真的要绑,CPU 和内存一起绑

如果确实需要绑核(比如隔离性能敏感的进程),必须同时指定 CPU 节点和内存节点,用 numactl 而不是 taskset

# 启动时绑定:CPU 和内存都绑到 node0(推荐做法)
# node0 的核是 0-3,绑定就覆盖整个 node,不要只绑 2 个核
numactl --cpunodebind=0 --membind=0 java -jar app.jar

numactl --cpunodebind 绑定 CPU 节点,--membind 绑定内存节点——CPU 在哪、内存就跟到哪。注意它绑定的是整个 node0(CPU 0-3),不是某个具体核,这正是正确的"按节点绑"。

姿势三:确认修改生效

# 验证 1:亲和性已扩大
taskset -pc <pid>
# 期望:0-7(覆盖全部核)

# 验证 2:内存分布趋于本地
numastat -p <pid>
# 期望:node1 占比显著下降,内存集中在 CPU 所在节点

# 验证 3:RT 回到基线
curl -w '%{time_total}\n' -o /dev/null -s http://localhost:8080/api/trade
# 期望:回到基线附近(基线 P99 20ms,单次 22ms 属正常波动)

修复后实测输出(姿势一解绑 + 重启进程后):

# 验证 1:亲和性已扩大
$ taskset -pc 6789
pid 6789's current affinity list: 0-7

# 验证 2:内存已本地化
$ numastat -p 6789
                           Node 0          Node 1           Total
Huge                           0.00           0.00            0.00
Heap                        2182.00         221.00         2403.00
Stack                          0.06           0.00            0.06
Private                     1794.24         282.06         2076.30
Total                      3976.30         503.06         4479.36
# node1 占比从 60% 降到 11%——跨节点访问几乎清零

# 验证 3:RT
$ curl -w '%{time_total}\n' -o /dev/null -s http://localhost:8080/api/trade
0.022
# 单请求 22ms,P99 回到基线附近

注意taskset 只改 CPU 亲和性,不会搬动已经分配的内存页。上面 node1 占比从 60% 降到 11%,来自两件事:

  1. 重启进程(最干净)——新进程按 first-touch 从"第一个访问页的 CPU"所在节点分配内存,一次到位。本例采用此方式。为什么重启后内存集中在 node0 而不是像首次启动那样分散?因为重启命令由 node0 上的 shell 发起,JVM 初始化线程恰好跑在 node0 的核上,堆页就按 first-touch 落到了 node0。首次启动时负载高、线程铺开,才会分散两节点。
  2. numa_balancing 自动迁移——仅当内核开启了 numa_balancing(3.13+ 默认开启)时,亲和性放宽后它会在数分钟内把高频访问的页迁移回 CPU 节点。本机 3.10 默认关闭,此机制不生效。

修复命令与验证输出——taskset 扩大范围 + numastat 确认内存本地化

生产环境注意事项

场景 正确做法
大部分业务进程 不要手动绑核,让内核调度
需要隔离的进程 numactl --cpunodebind=X --membind=X 一起绑
容器环境 检查 cgroup cpuset(/sys/fs/cgroup/cpuset.cpus),容器内 taskset 可能读到宿主机的核
想改内存分配策略 numactl --interleave=all 轮询分配,或调整 numa_balancing(内核 3.13+ 默认开启自动均衡)

最关键的一条:如果内核 numa_balancing 开启(现代内核默认开启),它会自动尝试把线程和内存往同节点迁移。你手动绑了核反而可能干扰这个自动机制——手动绑核之前,先问自己:内核的自动均衡做得比你好吗?

修复后 QPS 回到 4800,RT P99 稳定在 22ms 附近(基线 20ms)。回不到 5000 属正常——接近基线即达标。整个排查 40 分钟,其中确认根因 2 分钟。

内核版本说明sched_getaffinity 自 Linux 2.6 起存在,所有现代内核一致。numactl 依赖 numactl 包(CentOS: yum install numactl,Ubuntu: apt install numactl)。自动 NUMA balancing 在内核 3.8 引入、3.13 起默认开启(用 /proc/sys/kernel/numa_balancing 查看当前值)。cgroup v1 的 cpuset 路径为 /sys/fs/cgroup/cpuset/,v2 为 /sys/fs/cgroup/cpuset.cpus,两者路径不同。


🔗 个人博客:https://opencao.cn 📺 公众号:Ai拆代码的曹操 🌟 知识星球:Ai拆代码的曹操

下篇我们聊 NUMA 架构下跨节点内存访问延迟偏高——为什么同一台机器,换个进程跑就快了 50%。