做实时系统的人,早晚会遇到这样一个问题:任务明明已经绑核了,Linux 调度器却依然会把各种中断、回调、内核线程安排到同一颗 CPU 上,延迟曲线冷不丁就冒出一根尖刺。我后来把 CPU 隔离这套方案从头到尾折腾了一遍,从 isolcpus 到 nohz_full,从 rcu_nocbs 到 cpuset,才真正把实时任务的尾部延迟压下来。这篇算是一次完整复盘,适合做工业控制、机器人、实时音视频、高频数据采集和 DPDK 这类对确定性要求极高的场景参考。
1. 实时任务为什么“怕”Linux 默认调度器:先把干扰源摸清楚
1.1 一个非常典型的抖动现场
先讲一个具体例子。当时我在做一套工业数据采集系统,一个控制线程周期固定为 1ms,每轮要做传感器数据读取、校验、写入共享内存,硬时限给到 2ms。刚开始我想得很简单:用 pthread_setaffinity_np 把线程绑到 CPU2,再提高进程优先级,不就稳了吗?
结果一跑压力测试,延迟尖峰依旧存在,最夸张的一次单轮耗时超过 3ms。也就是说,绑核并没有解决根本问题。后面我才想明白一个关键点:CPU 亲和性只是告诉调度器“这个线程只能去哪些 CPU”,它并没有阻止别的任务、中断、内核机制跑到同一个 CPU 上。绑定 CPU 只是半隔离,真正要做的,是把这颗 CPU 从整个系统的公共资源池里摘出去,让它变成实时任务的专属车间。
在默认配置下,哪怕 CPU2 上只有一个实时线程在跑,下面这些打断依然会周期性到来:
- 每 HZ 触发一次的调度器 tick(如果 HZ=1000,就是每 1ms 一次);
- 定时器中断、软中断处理,比如网络收包时 NET_RX_SOFTIRQ;
- RCU 回调在这个 CPU 上被追赶执行;
- 同 CPU 上偶尔被调度过来的普通任务;
- 其他 CPU 发来的 Reschedule IPI、函数调用 IPI;
- 看门狗、migration、ksoftirqd 等内核线程的唤醒。
对于非实时场景,这些打扰可以忽略不计,平均延迟可能只有几微秒,反正 CPU 通过队列缓冲可以扛过去。但对硬实时任务来说,真正致命的不是平均延迟,而是尾部延迟:一次几毫秒的卡顿,就可能让控制环路超时,让音视频丢帧,让交易信号错过窗口。
所以要解决抖动,就要把这些干扰源一个个从隔离 CPU 上“请出去”。我的做法分成三层:内核参数层隔离、cpuset/亲和性层加固、中断和内核线程层清理。下面按顺序展开。
1.2 先给干扰源做一个完整的清单
在动手配置之前,建议你先对系统里有哪些干扰源做到心里有数。排查阶段我用过一张自制的清单,每次调优都对着它检查:
| 干扰源 | 产生机制 | 对实时任务的影响 |
|---|---|---|
| 周期 tick | 每个 CPU 周期性触发调度器时钟中断 | 固定节奏打断任务执行,产生周期性延迟 |
| RCU 回调 | RCU 机制在 softirq 里执行 callback | 回调积压时会一次性处理大量工作,延迟尖刺明显 |
| 软中断 | 网络、块设备等 softirq 在中断返回后执行 | 可能抢占当前线程,延迟不可控 |
| 普通任务竞争 | CFS 调度器将其他进程放到同 CPU | 抢占 CPU,实时任务被迫等待 |
| 设备中断 | 网卡、磁盘等设备中断路由到该 CPU | 高频率中断会反复打断任务 |
| IPI | 跨 CPU 调用、reschedule、TLB shootdown | 突然插入的高优先级打断 |
| watchdog | 内核 watchdog 线程周期检测 | 低频但存在,也会带来扰动 |
| cpufreq 调频 | CPU 频率调整时的停顿 | 频率切换导致短时周期变长 |
有了这个清单,后面每一层配置都可以对应到具体干扰源:isolcpus 处理普通任务竞争,nohz_full 处理周期 tick,rcu_nocbs 处理 RCU 回调,smp_affinity 处理设备中断。这样配置的时候思路会非常清晰,不会漏项。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 第一层隔离:isolcpus、nohz_full、rcu_nocbs 怎么组合才完整
2.1 isolcpus:先把 CPU 从“公共资源池”里拿出来
isolcpus 是内核提供的最基础的 CPU 隔离参数。它的作用很简单:把指定 CPU 从通用调度器中移除,CFS 调度器默认不会把普通任务放到这些 CPU 上。也就是说,这些 CPU 变成了“需要显式指定任务才会使用”的专用 CPU。
配置方法是在内核启动参数里加:
bash复制isolcpus=2,3
也可以用范围写法:isolcpus=2-3。要注意,新版内核里 isolcpus 支持一些修饰符,比如 domain 表示从调度域中彻底摘除,managed_irq 表示受管中断不要自动路由到这些 CPU。不同内核版本语法略有差异,我平时会写:
bash复制isolcpus=domain,2,3
如果内核版本较老,直接 isolcpus=2,3 也能工作,但建议先 uname -r 确认一下。
这里有个很多人容易忽略的点:isolcpus 只影响调度器的默认行为,它不是一个安全隔离机制。只要某个任务显式 taskset -c 2,它依然可以运行在隔离 CPU 上。所以隔离之后,实时任务必须显式绑到这些 CPU,否则隔离 CPU 会一直空转,而实时任务还在普通 CPU 上受气。
另外要注意:不要随意隔离 CPU0。CPU0 在多数系统里承担了大量中断和内核管理任务,强行隔离会导致系统级响应变差,反而拖累整体稳定性。我一般把 CPU0-1 留给系统、中断和普通任务,把 CPU2、3 这样的核心隔离给实时任务。
2.2 nohz_full:关掉隔离 CPU 上的周期 tick
仅仅隔离了调度器还不够。默认情况下,每个 CPU 都会按照 HZ 频率周期性地触发 tick 中断,用于调度器记账、负载均衡、定时器处理。即使 CPU2 上只有一个实时任务,这个周期性 tick 依然会雷打不动地打断它。
nohz_full 就是解决这个问题的:它允许指定 CPU 在“只有一个可运行任务”时进入 adaptive ticks 模式,也就是关闭周期 tick,只在真正需要时再产生 tick。这样实时任务执行过程中,少了一个最稳定的周期性打扰源。
配置:
bash复制nohz_full=2,3
不过要确认内核打开了 CONFIG_NO_HZ_FULL=y 选项,否则这个参数会被忽略。大多数较新的发行版内核都默认打开了。
需要注意,nohz_full 虽然也带有一定的隔离效果,会让指定 CPU 从调度域中摘除,但我在实践中仍然会同时显式设置 isolcpus,原因有两点:一是意图明确,可读性好;二是不同内核版本对隐式行为处理不同,显式写出来更稳妥。
2.3 rcu_nocbs:别让 RCU 回调在隔离 CPU 上偷偷执行
第三件必须做的事是把 RCU 回调移走。
RCU(Read-Copy Update)是内核里非常核心的同步机制,文件系统、网络协议栈、内核模块加载等都会用到。RCU 回调通常会在 CPU 的 softirq 上下文中执行。如果隔离 CPU 长时间不处理这些回调,一旦积压,内核会在某个时机集中处理,这个集中处理的过程可能造成非常明显的延迟尖峰。
解决办法就是 rcu_nocbs:
bash复制rcu_nocbs=2,3
设置之后,这些 CPU 上的 RCU 回调不再本地处理,而是通过 IPI 转移到其他 CPU 上的 rcuos、rcuo 内核线程去执行。代价是增加了一些跨 CPU 唤醒开销,但对隔离 CPU 来说,等于把一大类不确定延迟移走了。
这里必须强调一点:nohz_full 和 rcu_nocbs 通常要配对出现。如果只设置 nohz_full 而不设置 rcu_nocbs,内核为了在该 CPU 上处理 RCU 回调,可能无法真正进入 adaptive ticks 模式,导致 nohz_full 的效果大打折扣。我见过不少人只加了 nohz_full,然后发现 /sys/devices/system/cpu/nohz_full 里明明有 CPU,但延迟改善很小,其实就是漏了 rcu_nocbs。
2.4 在 GRUB 里把三个参数组合落地
这三个参数需要同时写进内核启动命令。以 Ubuntu/Debian 为例,编辑 /etc/default/grub:
bash复制GRUB_CMDLINE_LINUX="quiet splash isolcpus=domain,2,3 nohz_full=2,3 rcu_nocbs=2,3"
执行:
bash复制sudo update-grub
如果是 RHEL/CentOS 系,用:
bash复制sudo grub2-mkconfig -o /boot/grub2/grub.cfg
重启后先做一次基本验证:
bash复制cat /sys/devices/system/cpu/isolated
cat /sys/devices/system/cpu/nohz_full
cat /proc/cmdline
第一行会输出被隔离的 CPU 编号,第二行会输出进入 nohz_full 的 CPU 编号。我个人的经验是:看到两个文件都输出 2-3,才算第一步配置成功。
这一层做完,周期 tick、RCU 回调、普通任务竞争这三大干扰源基本被挡住了,但还没完。设备中断、内核线程、cpuset 约束这些,还需要第二层加固。
3. 第二层加固:cpuset、CPU 亲和性与中断绑定
3.1 cpuset 分区:给实时任务造一个“独立车间”
内核参数隔离做好之后,实时任务理论上已经可以安稳地跑在隔离 CPU 上了。但生产环境中我习惯再加一层保险:用 cpuset 创建一个独立的调度分区,只包含隔离 CPU,然后把这个分区作为实时任务的“专属车间”。
cpuset 的好处在于,它不只是影响调度器,而是从 cgroup 层面限制了任务集合的 CPU 范围。即使某个进程忘了设置亲和性,只要被放进这个 cpuset,内核也会把它限制在指定 CPU 上,反过来也一样,其他 cpuset 里的任务无法进入这个 CPU 范围。
cgroup v1 下的操作方式:
bash复制sudo mkdir -p /sys/fs/cgroup/cpuset
sudo mount -t cgroup -o cpuset cpuset /sys/fs/cgroup/cpuset
sudo mkdir -p /sys/fs/cgroup/cpuset/rt
echo 2-3 > /sys/fs/cgroup/cpuset/rt/cpuset.cpus
echo 0 > /sys/fs/cgroup/cpuset/rt/cpuset.mems
echo $$ > /sys/fs/cgroup/cpuset/rt/tasks
把当前 shell 写进 tasks 后,后续启动的进程就都继承了这个 cpuset 限制。然后你再执行实时程序即可。
如果系统在使用 cgroup v2,路径和写法会不一样。以 systemd 管理的系统为例,可以通过 service 文件直接声明 CPU 限制:
ini复制[Service]
ExecStart=/opt/rtapp/realtime_app
CPUAffinity=2-3
AllowedCPUs=2-3
这样 systemd 会替你处理好 cpuset 关系。我建议读到这里的同学先用 cgroup v1 的命令行方式理解一遍逻辑,再落到 systemd 配置里,更容易排查问题。
3.2 线程绑定与实时调度策略:让任务真正落在隔离 CPU 上
隔离参数和 cpuset 都只在“CPU 范围”上做文章,任务本身还需要两个操作:绑定隔离 CPU,以及设置实时调度策略。
绑定 CPU 最直接的方式是启动时用 taskset:
bash复制sudo taskset -c 2,3 chrt -f 80 ./my_realtime_app
这行命令把程序的 CPU 亲和性设为 CPU2、3,同时用 chrt -f 80 把调度策略设为 SCHED_FIFO,优先级 80。在代码里,等价写法是:
c复制cpu_set_t set;
CPU_ZERO(&set);
CPU_SET(2, &set);
CPU_SET(3, &set);
pthread_setaffinity_np(pthread_self(), sizeof(set), &set);
struct sched_param param = { .sched_priority = 80 };
sched_setscheduler(0, SCHED_FIFO, ¶m);
关于优先级,我想多说一句。很多人会把优先级直接拉到 99,觉得越高越好。实际上 SCHED_FIFO 优先级 99 的线程如果写了一个死循环,整个系统都会被锁死,因为没有任何机制能抢占它。我通常选择 80 左右,给内核关键任务和中断处理留出余量。如果某个实时线程真的崩溃或失控,系统至少还能响应一点点排查指令。
如果你需要更强的时间确定性,还可以了解下 SCHED_DEADLINE 调度策略,它允许你明确声明运行周期、运行时间和 deadline,比 SCHED_FIFO 更适合周期性实时任务。不过它需要较新的内核支持,我目前只在部分项目里用到。
3.3 中断亲和性:把设备中断从隔离 CPU 上赶走
隔离 CPU 上跑着实时任务时,网卡中断、磁盘中断如果随机路由过来,照样会引起不小的延迟尖峰。默认情况下,很多发行版会运行 irqbalance 服务,它会根据负载自动把中断分配到不同 CPU,这在普通服务器上是好事,但对隔离 CPU 来说就是个隐患。
所以生产配置里,我会直接停掉 irqbalance:
bash复制sudo systemctl stop irqbalance
sudo systemctl disable irqbalance
然后把所有设备中断的亲和性手动绑定到非隔离 CPU,例如 CPU0-1 的掩码是十六进制 0x3:
bash复制for f in /proc/irq/*/smp_affinity; do
echo 3 > $f 2>/dev/null || true
done
注意有些中断向量是内核内部使用的,写入会报错,所以加了 || true 忽略。这个脚本最好做成开机启动服务,否则重启后中断亲和性会被重新初始化。
除了设备中断,还要留心几个内核线程。migration、watchdog、ksoftirqd 都可能出现在隔离 CPU 上。可以用下面的命令快速检查当前隔离 CPU 上到底跑了哪些任务:
bash复制ps -eLo pid,comm,psr | awk '$3==2 || $3==3'
看到不该出现的任务时,针对性地调整它们的 CPU 亲和性,或者通过内核参数避免它们跑到隔离 CPU。比如 watchdog 可以通过 nohz_full 和 rcu_nocbs 的组合减少很大一部分活动,剩下的用 taskset 强制绑到 CPU0-1 即可。
第二层做完,隔离 CPU 上“别人能进来的通道”基本都关了。接下来就是最关键的环节:怎么验证这套配置真的有效。
4. 隔离效果怎么验证:从系统状态数据到延迟分布
4.1 先快速确认隔离状态是否生效
配置半天,如果验证方法不对,很容易被假象骗过去。我的习惯是按下面的顺序逐项检查:
bash复制# 1. 查看实际隔离的 CPU 列表
cat /sys/devices/system/cpu/isolated
# 2. 查看 nohz_full 是否对指定 CPU 生效
cat /sys/devices/system/cpu/nohz_full
# 3. 查看中断分布,隔离 CPU 上的中断次数是否明显低于普通 CPU
cat /proc/interrupts
# 4. 实时观察每个 CPU 上的任务分布
mpstat -P ALL 1
/proc/interrupts 特别值得多看几眼。如果配置正确,经过一段时间运行后,隔离 CPU 上的 LOC(本地定时器中断)和 RES(重调度中断)次数应该显著低于普通 CPU。如果隔离 CPU 上 LOC 还在以很高的频率增长,说明 nohz_full 没有真正生效。
4.2 用 cyclictest 量化最大延迟与抖动
静态检查只能确认“配置进去了”,不能说明“效果到底好不好”。量化环节我推荐用 cyclictest,它是 rt-tests 套件里的延迟测量工具,专门用来测试系统在实时任务下的调度延迟。
安装方式:
bash复制sudo apt install rt-tests
然后在普通 CPU 上做一个对照测试:
bash复制sudo cyclictest -t1 -p 79 -n -i 1000 -l 100000 -h 1000 --mlockall -a 0
再在隔离 CPU 上做同样测试:
bash复制sudo cyclictest -t1 -p 79 -n -i 1000 -l 100000 -h 1000 --mlockall -a 2
参数说明:
-t1:一个测量线程;-p79:线程优先级 79;-n:使用 clock_nanosleep;-i1000:间隔 1000 微秒,也就是 1ms;-l100000:循环 100000 次;-h1000:生成直方图数据;--mlockall:锁定内存,避免页面换入换出影响;-a2:绑定到 CPU2。
我在一台 x86 服务器上跑出来的典型数据如下(数值因硬件和内核版本会有差异,但量级可以参考):
| 测试位置 | 最小延迟 | 平均延迟 | 最大延迟 |
|---|---|---|---|
| CPU0(未隔离) | 4 us | 9 us | 1138 us |
| CPU2(隔离) | 3 us | 5 us | 17 us |
最大延迟从 1ms 级别降到几十微秒以内,体感非常明显。如果隔离配置不到位,cyclictest 的 max 值会周期性冒出大的尖峰,比如每隔几毫秒一次几百微秒的跳变,这时候基本可以断定 tick、RCU 或中断还在打扰隔离 CPU。
4.3 用 ftrace/perf 定位残留抖动
如果隔离之后 max 仍然不理想,就需要抓现场看看是谁在打扰。我最常用的工具是 trace-cmd,内核配置了 ftrace 后就能用。
记录一段时间内隔离 CPU 上的中断、软中断和调度切换:
bash复制sudo trace-cmd record -e irq:irq_handler_entry \
-e irq:softirq_entry \
-e sched:sched_switch \
-a 2 -b 128 -- sleep 10
结束后用:
bash复制sudo trace-cmd report
重点看两个东西:一是隔离 CPU 上有没有出现频繁的 irq_handler_entry,二是 sched_switch 里是否切入了不该出现的任务。如果看到某个中断源高频出现,就往它的 smp_affinity 上再压一次;如果看到某个内核线程频繁进来,就用 taskset 把它绑走。
这一步虽然比较费时间,但在生产环境里非常值。我遇到过一种情况:cyclictest 平均和 max 都正常,但每 4 秒会出现一次 100us 级别的毛刺,最后用 trace-cmd 抓出来是某个驱动在周期性地做延迟工作,和 CPU 隔离本身没关系。
5. 实际项目踩过的坑,以及一套可以直接抄的配置模板
5.1 我踩过的几个坑
做 CPU 隔离这段时间,我总结了不少反面教材,写出来帮大家避坑:
| 坑 | 现象 | 原因与对策 |
|---|---|---|
只加 isolcpus |
延迟有改善但不彻底 | tick 和 RCU 回调还在,必须配合 nohz_full 和 rcu_nocbs |
| 隔离 CPU0 | 系统卡顿,整体响应变慢 | CPU0 承担大量系统管理事务,不建议隔离 |
| 在虚拟机上验证 | 实测数据漂移严重 | 宿主机没隔离,vCPU 依然被宿主调度,要在物理机上验证 |
忘了关 irqbalance |
中断随机跳到隔离 CPU | 关闭 irqbalance,手动固定 smp_affinity |
| 实时任务没显式绑核 | 隔离 CPU 空转,任务还在普通 CPU 上跑 | 用 taskset/chrt 明确绑定 |
nohz_full 没配 rcu_nocbs |
nohz_full 形同虚设 | 两者配对使用,RCU 回调转移后才能关闭 tick |
| 实时优先级拉满 | 死循环线程导致系统宕机 | 优先级留余量,不要把用户态线程设为 99 |
这里面我最想强调的还是那个最常见的误解:isolcpus 不等于“这颗 CPU 从此只有我的任务”。它只是把 CPU 从通用调度器的默认分配中拿走,但设置亲和性、内核线程、中断依然可能进入。所以完整方案必须组合拳。
5.2 一份可以直接复制的启动脚本
下面是我在项目里使用过的一套启动脚本,按顺序做完了“关闭 irqbalance、固定中断亲和性、创建 cpuset、启动实时任务”这几件事:
bash复制#!/bin/bash
set -e
RT_CPUS="2,3"
NON_RT_MASK="3" # CPU0-1 的十六进制掩码,等价于 0b0011
# 1. 关闭动态中断均衡
systemctl stop irqbalance || true
systemctl disable irqbalance || true
# 2. 把所有设备中断固定到非隔离 CPU
for f in /proc/irq/*/smp_affinity; do
echo "$NON_RT_MASK" > "$f" 2>/dev/null || true
done
# 3. 创建 cpuset 隔离分区
mount -t cgroup -o cpuset cpuset /sys/fs/cgroup/cpuset 2>/dev/null || true
mkdir -p /sys/fs/cgroup/cpuset/rt
echo "$RT_CPUS" > /sys/fs/cgroup/cpuset/rt/cpuset.cpus
echo 0 > /sys/fs/cgroup/cpuset/rt/cpuset.mems
# 4. 把当前进程放入隔离分区,并启动实时任务
echo $$ > /sys/fs/cgroup/cpuset/rt/tasks
exec chrt -f 80 taskset -c 2,3 /opt/rtapp/realtime_app
如果走 systemd 管理,配置文件可以这么写:
ini复制[Unit]
Description=Real-time isolated application
[Service]
ExecStart=/opt/rtapp/realtime_app
CPUAffinity=2-3
CPUSchedulingPolicy=fifo
CPUSchedulingPriority=80
LimitRTPRIO=80
MemoryLock=infinity
[Install]
WantedBy=multi-user.target
注意 CPUSchedulingPriority 和 LimitRTPRIO 需要一起设置,否则 systemd 可能因为资源限制拒绝设置实时优先级。
5.3 这套方案适合谁,不适合谁
最后说点大实话。CPU 隔离这套组合拳并不是银弹,它有明确的使用边界。
适合的场景包括:工业控制器、机器人运动控制、实时音视频处理、DPDK 网络转发、高频量化交易、数据库高优先级请求线程。共同特点是有硬性的延迟上限,且任务运行周期相对固定,需要系统提供稳定的执行环境。
不适合的场景包括:普通的 Web 服务、大数据离线计算、容器云平台里的随机负载。这些场景更看重整体吞吐和资源利用率,把 CPU 隔离出来只会造成资源浪费,收益也不明显。另外,如果只是偶尔有延迟抖动,但业务能容忍重试和缓冲,那也没必要上这么复杂的方案。
还有一点要提醒:CPU 隔离只是“软实时”层面的优化,它不能解决硬件级别的响应问题。比如中断控制器、网卡队列、NUMA 访存延迟、PCIe 链路延迟,这些在特定情况下同样会成为瓶颈。我自己的体会是,CPU 隔离要把软中断、tick、RCU、kernel thread、irqbalance 全部管住,之后再去检查固件、BIOS 和硬件拓扑,延迟还能再往下压一压。
在我实际使用下来,还有一个很小但很实用的建议:配合 CPU 频率调速器设为 performance 模式,也就是让 CPU 一直跑在最高频率,不动态升降频。具体操作是 cpupower frequency-set -g performance,这一下虽然不直接影响调度,但能避免频率切换带来的微秒级毛刺,延迟数据会更干净。
如果你正好在调一个实时相关的系统,建议先把文中的内核参数、cpuset、中断亲和性这三层逐一配好,再用 cyclictest 拉一条基线。每一步验证过关之后再上业务,出了问题也容易定位。这套方案我在几个项目里反复用过,最大的感受是:隔离不是把任务绑在一个空房间里,而是把整个系统对这间房的打扰全部降到最低。
