Linux 性能调优系列(十六): Linux CPU 隔离实战,为什么实时线程绑核后延迟还会抖?
1 | 作者:李晓辉 |
上一篇我们讲了实时调度策略。但你可能已经发现,把任务设成 SCHED_FIFO 优先级 50,延迟抖动依然存在。这一篇我们来讲:优先级之外,还有哪些东西在干扰你的任务,怎么把它们赶走。
先看一个典型现象,我在培训里经常遇到这样的提问:
老师,机器 CPU 才 20%,业务线程也设了实时优先级,为什么 P99 延迟还是时不时飙一下?
这个问题的答案,往往不在调度策略里,而在”这个 CPU 上到底还有谁”。
为什么要关心 CPU 在哪里跑
线程迁移让缓存变冷
线程从 CPU 2 迁到 CPU 5,它在 CPU 2 的 L1/L2 里积累的热数据就用不上了。数据并没有”失效”,而是在新 CPU 上变冷了,要重新从内存或 L3 加载。这就是延迟抖动的一个来源。
中断会直接打断你
网卡收包、磁盘完成 I/O,都会产生中断。中断不看你的线程优先级,来了就打断。 你的实时线程再高优先级,也得让路。
同一个 CPU 上还住着别人
后台服务、监控 agent、kworker、ksoftirqd 都可能在你的 CPU 上排队。
两个概念要分清:
| 概念 | 回答的问题 | 性质 |
|---|---|---|
| CPU 亲和性 | 这个任务只能在哪些 CPU 上跑? | 限制任务自己 |
| CPU 隔离 | 别的东西能不能不要来这些 CPU? | 限制别人 |
很多人只做了前者就以为万事大吉,这是最常见的误区,后面会专门讲。
flowchart TB
G["目标:关键线程在 CPU 2-3 上不被打扰"] --> A["业务线程绑核<br/>taskset / CPUAffinity / cpuset"]
G --> B["其他用户态进程让开<br/>系统服务只用 CPU 0-1"]
G --> C["中断让开<br/>smp_affinity / irqbalance / managed_irq"]
G --> D["内核干扰降低<br/>nohz_full / rcu_nocbs"]
G --> E["实测验证<br/>/proc/interrupts / rtla / cyclictest"]这五件事缺一不可,后面的内容就是按这张图逐个展开。
先学会看 CPU 拓扑
CPU 列表与位掩码
Linux 里表示 CPU 集合有两种写法,很容易混淆。
列表写法,人类友好:
1 | 0-3 # CPU 0,1,2,3 |
十六进制位掩码,每一位对应一个 CPU,最低位是 CPU 0:
1 | f = 1111 → CPU 0-3 |
这很重要,因为不同接口吃不同的格式:
| 接口 | 格式 |
|---|---|
taskset -p 的输出 | 掩码 |
taskset -cp 的输出 | 列表 |
/proc/irq/N/smp_affinity | 十六进制掩码 |
/proc/irq/N/smp_affinity_list | 列表 |
写错格式的后果很有意思:往 smp_affinity 里 echo 2,你以为是 CPU 2,实际是掩码 0x2 = CPU 1。新手建议一律用 _list 结尾的接口,不容易算错。
例如:
1 | [root@localhost ~]# taskset -p 1013 |
lscpu 看什么
1 | lscpu |
| 字段 | 含义 |
|---|---|
CPU(s) | 逻辑 CPU 总数 |
Thread(s) per core | 每个物理核有几个逻辑核(超线程,大于 1 说明开了 SMT) |
Core(s) per socket | 每个插槽有几个物理核 |
Socket(s) | 插槽数 |
NUMA node(s) | NUMA 节点数 |
On-line CPU(s) list | 当前在线的 CPU |
看老李的样例:
1 | [root@localhost ~]# lscpu |
超线程兄弟核
如果开着超线程,两个逻辑核共享同一个物理核的执行单元。你隔离了 CPU 2,但它的兄弟核 CPU 6 上跑着别的负载,照样会干扰你。
1 | [root@localhost ~]# cat /sys/devices/system/cpu/cpu2/topology/thread_siblings_list |
生产建议:隔离的时候,要么把兄弟核成对隔离,要么干脆关闭 SMT。
NUMA:CPU 和内存是”邻居”关系
多路服务器上,每个插槽有自己的核心、缓存和本地内存。访问本地内存快,访问别的节点的内存慢。
现代 CPU 一个插槽内也可能划成多个 NUMA 节点(比如 AMD 的 NPS 模式、Intel 的 SNC),所以别想当然地认为”一个插槽 = 一个节点”,以
lscpu为准。
1 | [root@localhost ~]# dnf install numactl -y |
重点看每个节点有哪些 CPU、多少内存,以及节点之间的 distance。
这对绑核的意义是:绑核不能只看 CPU 编号。 你把线程绑到节点 1 的 CPU,但内存是在节点 0 上分配的,每次访问都要跨节点,延迟反而更差。
让 CPU 和内存都限制在节点 0:
1 | [root@localhost ~]# numactl --cpunodebind=0 --membind=0 sleep 100000 & |
看每个节点的内存分配情况:
1 | [root@localhost ~]# numastat -p $(pidof sleep) |
--cpunodebind=0:只允许进程运行在 NUMA 节点 0 的 CPU 上。这是 CPU 亲和性,效果类似 taskset,但是按”节点”而不是按 CPU 编号来指定。
--membind=0:只允许进程从节点 0 的内存里分配。这是严格限制:如果节点 0 的内存用完了,不会去别的节点借,而是直接分配失败,严重时触发 OOM。
两个合起来的意思是:线程只在节点 0 的 CPU 上跑,内存也只从节点 0 分配,所以所有内存访问都是本地访问,没有跨节点的额外延迟。
flowchart LR
subgraph N0["NUMA 节点 0"]
C0["CPU 0-3"] --- M0["本地内存"]
end
subgraph N1["NUMA 节点 1"]
C1["CPU 4-7"] --- M1["本地内存"]
end
P["进程<br/>cpunodebind=0<br/>membind=0"] --> C0
P -.只用这里的内存.-> M0
C0 -. 跨节点访问,慢 .-> M1一个常见的坑:membind 太严格,--membind 是硬限制,节点内存不够就失败。如果你只是希望”优先用本地内存,不够再借别的节点”,改用:
1 | numactl --cpunodebind=0 --preferred=0 sleep 10000 & |
| 参数 | 行为 |
|---|---|
--membind=0 | 只能用节点 0 的内存,用完就失败 |
--preferred=0 | 优先用节点 0,不够再用别的节点 |
--interleave=all | 内存在所有节点间轮流分配,适合需要大内存带宽、不在乎单次延迟的场景(比如数据库) |
--localalloc | 在进程当前运行的 CPU 所在节点上分配内存 |
另一个相关写法,如果你想按具体 CPU 编号而不是节点来指定,用 --physcpubind(或 -C):
1 | numactl --physcpubind=2,3 --membind=0 sleep 10000 & |
这样 CPU 精确到 2、3,内存仍限定在节点 0。
还有一个常被忽略的点:网卡也挂在某个 NUMA 节点上。,处理这块网卡数据的业务线程,最好和网卡在同一个节点上。-1 的意思是:内核没有这块设备的 NUMA 节点归属信息,也就是”不属于任何特定节点 / 未知”。
1 | [root@localhost ~]# ip -br a |
从网卡名 ens160 和 MAC 前缀 00:0c:29(VMware)来看,机器是 VMware 虚拟机,ens160 是虚拟网卡 vmxnet3,直接是-1,因为
- 虚拟机本身没有 NUMA 拓扑:只有一个 node 0,虚拟 PCI 设备也就没有”挂在哪个节点”的概念;
- 虚拟化层没有把这个信息传给客户机:即使宿主机是多路服务器,虚拟网卡也不是真实的物理 PCIe 设备,没有物理插槽归属;
- 物理机上偶尔也会出现:BIOS/ACPI 没有提供该设备的节点信息(比如 ACPI 表里缺少
_PXM,或者开启了节点交错 / NUMA 被关闭)。
taskset:快速验证的好工具
taskset 最适合”先试一下效果”。
查看,输出掩码
1 | [root@localhost ~]# taskset -p 1013 |
查看,输出列表(更直观)
1 | [root@localhost ~]# taskset -cp 1013 |
修改已运行的进程,注意要带 -p:
1 | [root@localhost ~]# taskset -cp 2 1013 |
启动新进程时不带 -p:
1 | [root@localhost ~]# taskset -c 2 sleep 10000 & |
这里和上一篇的
chrt一样:改已有进程用-p,启动新进程不用。 如果把taskset -c 2 1013当成改 PID,它会去执行一个叫1013的命令然后报错。
多线程程序的坑
taskset -cp 2 1013 只修改了线程 1013 自己,也就是主线程。进程里已经存在的其他线程不受影响。加 -a,修改进程内所有已存在的线程
1 | taskset -acp 2 1013 |
另外,新创建的线程会继承创建者的亲和性,子进程也一样。
亲和性是硬限制
taskset 设置的是硬限制:线程只能在这些 CPU 上跑,即使别的 CPU 全空闲也不行。所以别绑得太死。如果你只绑一个 CPU,那个 CPU 上一旦有人占着,你的线程就只能等。taskset 的修改是临时的,进程重启就没了,只适合验证。
systemd:让绑核”留下来”
生产环境里的服务应该交给 systemd 管理。
1 | [root@localhost ~]# mkdir /etc/systemd/system/sshd.service.d/ |
在打开的编辑器里写:
1 | [Service] |
然后:
1 | [root@localhost ~]# systemctl daemon-reload |
验证
1 | [root@localhost ~]# systemctl show sshd -p CPUAffinity |
这个例子其实很有代表性:**把 sshd 这类”非关键”服务绑到 CPU 0-1,就是在给隔离核让路。**还可以做全局设置。编辑 /etc/systemd/system.conf:
1 | [Manager] |
这样 systemd 启动的所有服务默认只用 CPU 0-1,需要关键 CPU 的服务再单独覆盖。改完建议重启,对新启动的服务才完整生效。
注意:
CPUAffinity只约束服务自己。它不会把别人赶出你的 CPU,也不处理中断。
cgroup v2 cpuset:按组划分 CPU
如果要给”一组任务”划地盘,用 cpuset。先确认是 cgroup v2:
1 | [root@localhost ~]# stat -fc %T /sys/fs/cgroup |
手工体验一遍
1 | # 第一步:让根 cgroup 把 cpuset 控制器交给子 cgroup |
第一步最容易漏。 不启用控制器,rt_group 目录里根本不会有 cpuset.cpus 文件。
关于内存节点,v2 里 cpuset.mems 默认继承父级,不是必须设置(v1 才是)。需要限制内存节点时再设:
1 | echo 0 > /sys/fs/cgroup/rt_group/cpuset.mems |
独占:用 partition,不是 cpu_exclusive
cpuset.cpu_exclusive 是 v1 的接口。v2 里要用 cpuset.cpus.partition:
1 | [root@localhost ~]# echo isolated > /sys/fs/cgroup/rt_group/cpuset.cpus.partition |
它有三种取值:
| 值 | 含义 |
|---|---|
member | 普通成员,默认值 |
root | 分区根,这些 CPU 独占给本组,组内仍然做负载均衡 |
isolated | 独占,并且关闭组内负载均衡,和 isolcpus 的效果类似,但可以动态调整 |
读回来如果看到 isolated invalid (...),括号里是失败原因,说明条件不满足,比如 CPU 被兄弟组占用了。
isolated的一个后果:组内不再自动做负载均衡,你的线程不会自己跑到另一个 CPU 上去。所以组内线程要自己明确绑核,否则可能全挤在一个 CPU 上。
在 systemd 系统上更推荐
RHEL 上 cgroup 树是 systemd 管理的,直接手工 mkdir 容易和 systemd 打架。更稳妥的方式是让 systemd 来设:
1 | [Service] |
或者临时调整:
1 | systemctl set-property --runtime my_app.service AllowedCPUs=2-3 |
中断亲和性:让 IRQ 绕开你
1 | # 看中断分布,每一列是一个 CPU 的累计次数 |
这里把中断绑到 CPU 0-1,也就是”系统核”,给 CPU 2-3 清净。还有一个容易忽略的参数:
1 | [root@localhost ~]# cat /proc/irq/default_smp_affinity |
它决定新注册的中断默认去哪,修改已有中断不会影响它。
为什么有时候改了没用
- irqbalance 在后台”纠正”你。 你手动改完,它下个周期又给你调回去了;
- managed IRQ。 现代多队列网卡和 NVMe,内核按 CPU 拓扑自动管理它们的中断,用户态改不了,写入通常会报 I/O 错误;
- 部分中断有设备或平台约束。
对于 managed IRQ,要靠内核参数 isolcpus=managed_irq,... 让内核自己避开隔离核,这点下面讲 tuned 时会再提到。
irqbalance:自动平衡的”双刃剑”
irqbalance 的目标是把中断均匀撒在各个 CPU 上,这对通用服务器很好,但和”隔离”的目标正好相反。
1 | [root@localhost ~]# systemctl status irqbalance |
处理方式有两种。
方式一:直接停掉,完全手工管理
1 | systemctl disable --now irqbalance |
方式二:留着,但告诉它哪些 CPU 别碰
编辑 /etc/sysconfig/irqbalance:
1 | IRQBALANCE_BANNED_CPULIST=2-3 |
1 | systemctl restart irqbalance |
这里禁的是 CPU:irqbalance 不会再往 CPU 2-3 上放中断。如果你想禁的是某个中断,用另一个参数:
1 | IRQBALANCE_ARGS="--banirq=12" |
这两个别混。很多文章把”禁 CPU”说成”禁中断”,导致配了半天不生效。
另外,如果你用了下面要讲的 tuned cpu-partitioning,它会自己处理 irqbalance 的禁用 CPU 列表,不要再手工去改,也不用手动停掉,两边都改容易互相覆盖。
tuned cpu-partitioning:生产环境的推荐姿势
前面这些手工操作,零散、容易漏、重启后要重做。生产环境推荐用 tuned 的 cpu-partitioning profile 一次性做到位。
它做了什么
flowchart TB
T["tuned-adm profile cpu-partitioning"] --> K["内核启动参数<br/>isolcpus / nohz_full / rcu_nocbs"]
T --> S["systemd 层面<br/>系统服务默认只用 housekeeping CPU"]
T --> I["中断<br/>调整 irqbalance 禁用列表,迁移可迁移的 IRQ"]
T --> Y["一批 sysctl 调整<br/>减少干扰"]
K --> R["重启后生效"]操作步骤
先装包,注意 profile 在单独的子包里,具体以 tuned-adm list 为准:
1 | [root@localhost ~]# dnf install tuned tuned-profiles-cpu-partitioning -y |
先写变量,再启用 profile,编辑 /etc/tuned/cpu-partitioning-variables.conf:
1 | isolated_cores=2-3 |
1 | [root@localhost ~]# systemctl enable tuned --now |
验证
1 | [root@localhost ~]# tuned-adm active |
cmdline 里应该能看到类似下面的内容,具体 flag 随 tuned 版本略有差异:
1 | isolcpus=managed_irq,domain,2-3 nohz_full=2-3 rcu_nocbs=2-3 |
这几个内核参数到底干什么
| 参数 | 含义 |
|---|---|
isolcpus=domain | 把这些 CPU 从调度域中拿掉,调度器不再往上做负载均衡,普通任务不会被自动迁过来 |
isolcpus=managed_irq | 让内核尽量不把 managed IRQ 放到这些 CPU 上 |
nohz_full | 当 CPU 上只有一个可运行任务时,停掉周期性调度 tick(忙碌时的 tick 抑制,不是空闲时) |
rcu_nocbs | 把 RCU 回调处理从这些 CPU 卸载到别的 CPU 的内核线程上 |
nohz_full 有两个前提,不然不会生效:
- 该 CPU 上同时只能有一个可运行任务;
- 系统里必须留有 housekeeping CPU 来承接被卸载的工作(所以 CPU 0 通常不要隔离)。
一个很重要的认知:它不会帮你把业务绑过去
cpu-partitioning 只是清出了隔离核,不会把你的业务放进去。普通进程默认会被限制在 housekeeping CPU 上,你的关键业务必须显式绑核:
1 | taskset -c 2-3 ./my_app |
tuna:交互式调整
tuna 适合现场排查和临时调整,可以同时看线程、中断,并移动它们。
1 | [root@localhost ~]# dnf install tuna -y |
注意写法:先用 --threads / --irqs 选对象,再用 --move 执行动作。
1 | # 把线程 1026 移到 CPU 2 |
还有一个很实用的动作,一键”清场”:
1 | [root@localhost ~]# tuna isolate --cpus=2-3 |
它会把线程和中断尽量从 CPU 2-3 上挪走,挪不走的(比如每个 CPU 都有的内核线程)会保留。和 taskset 一样,tuna 的修改重启就丢,只用于测试,持久化交给 systemd 和 tuned。
内核线程到底赶不赶得走
这里要诚实地讲清楚一个很多资料不愿意说的事实:
隔离不等于清零。
内核里有些线程是每个 CPU 一个的,比如 migration/2、ksoftirqd/2、kworker/2:1。它们本来就是为这个 CPU 服务的,没法迁走。
所以在隔离核上执行 ps 看到这些线程是正常的。我们能做的是让它们少被触发:
- 中断移走 →
ksoftirqd就没活干; - 业务不频繁触发内核工作 →
kworker就闲着; nohz_full+rcu_nocbs→ 减少 tick 和 RCU 的打扰。
低延迟场景的推荐流程
flowchart TD
A["延迟抖动"] --> B{"确认是 CPU 竞争或中断造成的?<br/>先测量,不要盲目隔离"}
B -- 是 --> C["规划:系统核 vs 业务核<br/>注意 SMT 兄弟核和 NUMA"]
C --> D["tuned cpu-partitioning<br/>写变量 → 启用 → 重启"]
D --> E["业务显式绑到隔离核"]
E --> F["检查中断是否已避开"]
F --> G["实测:cyclictest / rtla"]
G --> H{"达标?"}
H -- 否 --> I["查被忽略的干扰源<br/>回到第二步分析"]
H -- 是 --> J["写进文档和配置管理"]规划示例
1 | CPU 0-1 系统核(housekeeping):系统服务、中断、监控 |
验证:不要只看配置,要看实际行为
看隔离核上有什么在跑:
1 | # psr 列是线程当前所在 CPU |
除了你的业务线程和每 CPU 的内核线程,不该有别的。看中断有没有还往隔离核上打:
1 | [root@localhost ~]# watch -d -n1 cat /proc/interrupts |
盯着 CPU 2、CPU 3 那两列,数字不该持续增长。LOC(本地定时器)一行在 nohz_full 生效后增长会非常慢,大约每秒一次,这是正常现象。
用工具量化干扰:
1 | [root@localhost ~]# dnf install realtime-tests rtla -y |
1 | # 测调度延迟 |
常见误区
误区一:绑了核就等于隔离
不是。taskset、CPUAffinity 只限制了自己。其他进程、中断、内核线程照样可以来。
误区二:隔离越多越好
隔离核上的东西都不能跑,系统管理、监控、中断处理全挤在剩下的 CPU 上,反而会拖垮整体。建议:housekeeping 核至少保留 1-2 个物理核,并且给它们留够处理中断的余量。
误区三:只改 isolcpus 就够了
isolcpus 只管调度器不往上放普通任务。完整隔离还要配合 nohz_full、rcu_nocbs、中断迁移,这也是推荐用 tuned 的原因。
误区四:隔离了就不管 SMT 兄弟核
隔离 CPU 2,它的兄弟核 CPU 6 上跑着别的,物理核的执行资源仍然被共享。要么成对隔离,要么关掉 SMT。
误区五:tuna / taskset 改了会保留
它们是临时的。要持久化就用 systemd、tuned。
误区六:改了配置就不用验证
隔离的效果必须靠 /proc/interrupts、ps、cyclictest、rtla 来验证。没有量化数据,就不要说”优化好了”。
命令速查
| 目的 | 命令 |
|---|---|
| 看 CPU 拓扑 | lscpu -e |
| 看 SMT 兄弟核 | cat /sys/devices/system/cpu/cpuN/topology/thread_siblings_list |
| 看 NUMA | numactl -H |
| 看网卡所在 NUMA 节点 | cat /sys/class/net/eth0/device/numa_node |
| 查看进程亲和性 | taskset -cp PID |
| 修改已有进程(所有线程) | taskset -acp 2 PID |
| 启动并绑核 | taskset -c 2 ./cmd |
| 看实际允许的 CPU | grep Cpus_allowed_list /proc/PID/status |
| 看中断分布 | cat /proc/interrupts |
| 设置中断亲和 | echo 0-1 > /proc/irq/N/smp_affinity_list |
| 启用 tuned 分区 | tuned-adm profile cpu-partitioning |
| 验证 tuned | tuned-adm verify |
| 动态移动线程 | tuna move --cpus=2 --threads=PID |
| 动态移动中断 | tuna move --cpus=2 --irqs=N |
| 测延迟 | cyclictest -m -p 95 -a 2-3 -t -D 10m -q |
课后思考
- 为什么
taskset -cp 2 PID对一个 16 线程的程序可能”没效果”?应该怎么改? - 一台 8 核 16 线程(开着 SMT)的机器,你想隔离 CPU 4-7。
thread_siblings_list显示 4 和 12 是一对,那应该隔离哪些 CPU 才合理? isolcpus和cpuset.cpus.partition=isolated都能关闭负载均衡,它们的主要区别是什么?什么场景下该用哪个?- 隔离核上
ps能看到ksoftirqd/3,这是不是说明隔离失败了?
参考手册
1 | man taskset |
