1. 告警先来:一个隔离核跑满后,RCU 的宽限期卡住了
先说结论:这不是“CPU 隔离”这件事本身错了,而是 RCU 这个内核机制的运行前提,恰好被隔离动作砍掉了一部分。很多第一次做低延迟优化的人都在这上面栽过跟头,我也不例外。
前不久线上有个批处理服务为了压尾延迟,把一台 8 核机器的 CPU 2-7 全部隔离出来跑高优先级业务,启动参数大概长这样:
isolcpus=2-7 nohz_full=2-7 rcu_nocbs=2-7CPU 0/1 留作 housekeeping,收中断、跑内核线程、管理定时器和 RCU。刚开始一切正常,业务线程独占 CPU,调度抖动几乎消失,p99 数据非常漂亮。结果某天高峰期,监控突然开始报延迟抖动,业务侧看到的是几十毫秒到上百毫秒的停顿,而不是平时的几十微秒。
登录机器第一件事看 dmesg,果然躺着一条刺眼的告警:
[ 3121.449192] rcu_preempt detected stalls on CPUs/tasks: [ 3121.449194] 5-...: (1 GPs behind) idle=552/0/0x0 softirq=1/1 fqs=0 [ 3121.449196] (detected by CPU 0, t=60000 jiffies, g=1535, q=1487) [ 3121.449198] Task backtrace: [ 3121.449200] [<ffffffff810e3d2a>] ...翻译成人话就是:RCU 等待一个宽限期(grace period)等太久了,默认超过 60 秒没有完成,于是内核认为某个 CPU 或某个任务“卡死”了,主动打印告警。这次点名的是 CPU 5——正好是我们隔离出来的核之一。
当时我的第一反应是“业务线程是不是在内核态死循环了”。但用ps一看,CPU 5 上跑的是一个普通的用户态计算任务,状态 R,CPU 占用 100%,没有异常内核栈。这就很奇怪了:一个纯用户态进程,怎么会让 RCU 卡住?
后面排查才发现,问题出在我对 RCU 和 CPU 隔离之间关系的理解上。隔离核并不只是“调度器不往上面放任务”这么简单,它同时改变了 RCU 感知这个 CPU 是否存活的方式。想明白这一点,才是解决这类告警的关键。
2. RCU 为什么依赖时钟中断,而 CPU 隔离为什么恰好砍断这根线
2.1 RCU 的宽限期逻辑:它如何知道一个 CPU“还活着”
RCU(Read-Copy-Update)是 Linux 内核里一套“读多写少”场景下的同步机制。读侧基本无锁,写侧更新数据时,先拷贝一份新数据发布出去,然后等待所有可能还在读旧数据的读者离开临界区,再安全释放旧数据。
这个“等待读者离开临界区”的过程,就是宽限期。RCU 必须确保在一个宽限期内,每个 CPU 都至少经历一次 quiescent state(静止状态,简称 QS),比如发生了上下文切换、进入了用户态、进入了 idle,或者经过了调度时钟中断的处理点。一个 CPU 只要在宽限期内交过一次 QS,RCU 就认为它不会再持有旧的引用。
正常情况下,每个 CPU 都会周期性收到本地时钟中断,也就是 tick。RCU 在 tick 的处理路径rcu_sched_clock_irq()里检查当前 CPU 是否处于可确认 QS 的状态。只要有周期 tick 在,RCU 就能在几十毫秒内拿到所有 CPU 的 QS,宽限期很快结束。这也是为什么默认CONFIG_RCU_CPU_STALL_TIMEOUT=60很少被触发——在标准内核里,一个 CPU 连续 60 秒不交 QS 几乎是不可能的。
可以把这个过程理解成一个部门点名:周期 tick 就是“每隔一会儿喊一声,让所有人都应答一下”,RCU 就是靠这个应答来判断“人还在、没有锁死”。一旦有人长期不应答,RCU 就会拉响警报。
2.2 isolcpus / nohz_full / rcu_nocbs 分别干了什么
很多教程把isolcpus、nohz_full、rcu_nocbs三个参数混在一起讲,好像它们是一套组合拳。实际上它们各管一段,但组合起来之后,会形成一个对 RCU 不太友好的环境。
先说isolcpus=2-7。它把指定 CPU 从 CFS 调度器的普通任务分配池里拿出来,普通进程默认不会调度到这些核上。但注意,它不阻止内核线程、中断处理、软中断跑上去。换句话说,它的作用是“不让普通任务过去”,而不是“保证这些核完全干净”。
然后是nohz_full=2-7。这个参数更进一步,它允许指定 CPU 进入完全动态 tick 模式。只要某个隔离核上只有一个可运行任务,内核就把该 CPU 的周期性时钟中断关掉,尽量不给这个任务制造节拍打扰。这样业务线程可以长时间在用户态跑,中间完全没有周期性的调度器检查。
最后是rcu_nocbs=2-7。这个参数把指定 CPU 的 RCU 回调(callback)从原来的 softirq 上下文里摘出来,offload 到专门的内核线程去处理。目的是避免 RCU 的回调工作打断隔离核的用户态任务——毕竟如果没有这个参数,RCU 回调堆积后会通过 softirq 在本地 CPU 上执行,隔离效果就没了。
这三层叠加之后,隔离核上会发生什么?周期性 tick 被关掉,普通任务进不来,RCU 回调也被挪走。从业务视角看,CPU 变得非常干净。但从 RCU 视角看,这个 CPU 等于从“定期应答点名”变成了“失联人员”:它既不定期进入时钟中断,又不在本地处理 RCU 回调,RCU 只能靠非常规手段去探活。
2.3 冲突点:隔离核的宽限期检测和回调处理都变了
RCU 在长期实践中支持无 tick 的模式,比如 CPU 进入 idle 时,RCU 会标记一个扩展静止状态;CPU 进入用户态时,也会在退出内核的那一刻记账。所以“用户态长跑”本身不是 RCU 无法容忍的,前提是 CPU 至少要能从内核态回到用户态的过程里交一次 QS。
问题在于隔离场景经常出现更极端的情况:业务任务是 SCHED_FIFO 高优先级,或者驱动/轮询代码长期停留在内核态,又或者业务路径里用了local_irq_disable()这类关中断操作。此时隔离核既没有周期 tick,又没有及时被 IPI 打断,RCU 就失去了确认 QS 的窗口。
另一方面,rcu_nocbs看起来把回调挪走了,但这些回调要真正被处理,依赖每个 CPU 对应的 RCU 回调内核线程,比如rcuop/5、rcuos/5这类线程。如果隔离核上的高优先级任务长时间占着 CPU,这些内核线程可能根本得不到调度机会,回调就会越积越多。当有写者调用synchronize_rcu()等待回调完成时,就会一直等下去,直到触发 stall 告警。
一句话总结:CPU 隔离把 RCU 的两条腿都捆住了。一条腿是周期 tick 提供的 QS 检测,另一条腿是回调处理线程。两条腿同时失灵,超时只是时间问题。
3. 一次从 dmesg 到 ftrace 的 RCU stall 排查实录
3.1 先看告警本身,而不是先看负载
接到类似告警,我建议先别急着怀疑业务代码,而是把 dmesg 里那几行“stalls on CPUs/tasks”完整读一遍。里面有大量有用信息。
第一行会点名卡在哪些 CPU 上,比如5-...表示 CPU 5 有问题。(1 GPs behind)表示这个 CPU 已经落后一个甚至多个宽限期,也就是说 RCU 等它交 QS 等了很久。fqs=0表示强制 quiescent state 扫描一直没有进展,说明每次主动去探这个 CPU 都没有得到有效回应。detected by CPU 0说明是 housekeeping 核上的 RCU 机制发现了异常。
通过dmesg确认了 CPU 5 之后,我又查了隔离配置是否真的生效:
cat /proc/cmdline cat /sys/devices/system/cpu/isolated cat /sys/devices/system/cpu/nohz_full看到的结果和启动参数一致,CPU 2-7 都处于 isolated/nohz_full 状态。这就说明问题不是配置失效,而是这个配置本身在特定负载下把 RCU 逼到了墙角。
3.2 用 ftrace 把 RCU 宽限期的完整时间线拉出来
下一步是看 RCU 内部状态机到底卡在哪。ftrace 是最直接的观测手段:
cd /sys/kernel/debug/tracing echo 0 > tracing_on echo 'rcu:rcu_grace_period rcu:rcu_fqs rcu:rcu_callback' > set_event echo 1 > tracing_on # 复现一段时间 sleep 5 echo 0 > tracing_on grep rcu_ trace | tail -n 200从 trace 里能看到大量rcu_grace_period事件反复出现,但始终没有正常的结束事件。同时rcu_fqs事件里,涉及 CPU 5 的强制扫描几乎都失败。这就确认了问题的核心不在某个具体写者身上,而是宽限期长时间无法满足。
如果只是想快速确认“是不是 RCU 回调线程没跑”,可以看/proc/softirqs里 RCU 软中断在各核的分布。正常情况下,隔离核上的 RCU softirq 计数应该很低,因为回调已经被 offload;但如果rcuop线程被饿死,回调队列会涨,相关计数也异常。
3.3 检查 RCU 相关内核线程到底跑在哪
确认根因前,还要看一眼 RCU 线程的归宿。rcuop/5这类线程是给 CPU 5 处理回调的,它们通常绑定在对应 CPU 上。可以用这条命令快速观察:
ps -eo pid,comm,psr,pri | grep -E 'rcu(op|os|og|u|_preempt)'我当时看到的情况是:rcuop/5的 PS 在 CPU 5 上,优先级很低,而 CPU 5 被一个高优先级业务线程占满了。换句话说,回调线程想跑也跑不了,只能等着。
这一步非常关键。因为如果问题只是“QS 检测失败”,那么解决方案可能更偏向于保留一点周期 tick 或调整 RCU 的强制探活机制;但如果是“回调线程饿死”,那么核心矛盾就变成了业务线程和内核管理线程在隔离核上的调度竞争。两者对应的修复手段完全不同。
4. 四类根因:从调度饿死到中断屏蔽,逐个对号入座
4.1 根因一:RCU 回调线程被高优先级业务饿死
这是我在实际排查中最常见的一种。rcu_nocbs把所有回调 offload 到rcuop/N等内核线程,这些线程本身是普通优先级。只要隔离核上跑着一个 SCHED_FIFO 或者 SCHED_RR 的实时任务,而且它不主动让出 CPU,那么这些回调线程根本没有机会运行。
结果就是:写者把数据更新之后,回调一直堆积在队列里,synchronize_rcu()的调用者一直等,最终触发 stall。这种场景的典型特征是 dmesg 里既有宽限期告警,又有大量 RCU 回调堆积的迹象,而且业务线程确实有实时调度策略。
处理思路不是直接把rcu_nocbs去掉,而是要让 RCU 回调有出路。比如:
- 为 RCU 回调 kthread 单独提升优先级或者调整调度策略;
- 在 cpuset 划分时,把 RCU 回调线程放到 housekeeping 核;
- 如果版本支持,使用
rcu_nocb_poll让回调线程以轮询方式工作,减少被唤醒延迟。
要特别说明:rcuop/N线程的 affinity 通常受内核限制,不同版本表现不一样,不能指望无脑taskset就能解决。我遇到过的处理方式是在启动参数层面调整rcu_nocbs的 CPU 范围,让容易产生大量回调的 CPU 不进入 offload,或者干脆把回调线程所在 CPU 的调度策略调整到合理位置。
4.2 根因二:隔离核上长时间的内核态/关中断路径阻止了 QS 上报
很多人以为 RCU stall 只跟“用户态跑满”有关,其实纯用户态进程通常不会导致 stall,因为它在进入用户态和退出内核态时,RCU 会记账。真正危险的是长时间停留在内核态,而且该执行的调度点没有执行。
典型例子是某些用户态驱动、DPDK 轮询线程、或者一个在内核态自旋等待标志位的工作线程。如果业务路径里有关中断操作,或者在无 tick 状态下长时间不经过 context switch,RCU 就无法获得 QS。此时 dmesg 里的任务栈通常能直接看出来,backtrace 会落在某个驱动函数或者某个自旋等待函数上。
对这种问题,调整内核参数只能缓解,不能根治。要么修改业务代码,减少长时间关中断的自旋;要么给这个核保留一个最低限度的 tick,让它至少能周期性应答 RCU 的点名。很多实时场景会在几个隔离核里专门留一个“哨兵核”,不完全关闭 tick,专门用来承接 RCU 等内核机制的心跳。
4.3 根因三:housekeeping 核过载,RCU 管理线程全部迟到
有时候问题并不出在隔离核上,而是出在留作管理用途的那一两个核上。我们经常习惯性地把 8 核机器隔离出 6 个核,把所有中断、软中断、内核线程全部压到 CPU 0/1,结果这两个核成了新的瓶颈。
RCU 的宽限期 kthread、定时器、软中断处理都需要在 housekeeping 核上运行。一旦这些核被网络收发、文件系统缓存回收、各类内核线程挤爆,RCU 的管理线程就算不被饿死,也会被延迟到无法在规定时间内完成宽限期推进。低峰期没问题,高峰期一到,stall 告警就来了。
这种根因的判断方法是看 housekeeping 核的load average和运行队列等待时间,同时观察rcu_preempt、rcuos等线程的调度延迟。如果它们长时间处于 D 状态或者 R 状态但排队严重,说明管理侧资源已经不够了。
修复思路也很直接:要么少隔离几个核,多留管理核;要么把中断/软中断通过irqaffinity分散到更多核上,别把所有杂活都堆给某一两个核。隔离不是越多越好,留足 RCU 和管理线程的生存空间是前提。
4.4 根因四:RCU 参数与 nohz_full 组合方式不匹配
最后一种情况是配置本身造成的。nohz_full和rcu_nocbs虽然经常配套出现,但它们对 RCU 的影响是独立的。有些版本里,nohz_full会改变 RCU 对 dyntick 状态的判断逻辑,但没有rcu_nocbs配套时,RCU 回调仍然可能在隔离核以 softirq 方式执行,导致隔离核被周期性唤醒;而有rcu_nocbs但范围不对时,又可能出现部分 CPU 回调迁移到其他 CPU,反而加剧宽限期等待。
这类问题很难一眼看出来,建议对照内核文档确认当前版本对这三个参数的具体语义,尤其是rcu_nocbs的 CPU 掩码和nohz_full的 CPU 掩码是否一致。如果两者范围不一致,RCU 的假设就会被打破。
我见过一个比较隐蔽的例子:启动参数里nohz_full=2-7,但rcu_nocbs=3-7,CPU 2 处于 nohz_full 却没有 offload 回调。结果 CPU 2 既要保持 tickless,又要被 RCU 回调唤醒,两边打架,最终触发 stall。这个范围错位不仔细核对根本看不出来。
用一张表梳理四种情况,方便大家对照排查:
| 根因方向 | 典型现象 | 判断方法 | 处理思路 |
|---|---|---|---|
| 回调线程饿死 | CPU 5 被点名,rcuop/5无法运行 | ps -eo psr查线程位置,观察回调队列 | 调整调度优先级、cpuset、rcu_nocbs范围 |
| 内核态/关中断路径 | 回调查栈停在驱动或自旋点 | 分析 backtrace、检查业务代码 | 修改业务路径,或保留哨兵 tick |
| housekeeping 过载 | 管理核 load 高,rcu_preempt等线程延迟大 | 看loadavg、运行队列、线程状态 | 多留管理核,分散软中断 |
| 参数范围错位 | nohz_full与rcu_nocbs掩码不一致 | 核对/proc/cmdline | 对齐掩码,按版本文档配置 |
5. 隔离核与 RCU 共存:我目前在用的启动参数与验证流程
踩过几次坑之后,我的默认配置不再是一口气把所有核都隔离干净,而是优先保证 RCU 有“后门可走”。
以一台 8 核机器为例,我现在倾向这样划分:CPU 0/1 做 housekeeping,CPU 2 作为一个“中间核”,不完全关闭 tick,CPU 3-7 才是真正进入 nohz_full + rcu_nocbs 的业务核。启动参数大致是:
isolcpus=2-7 nohz_full=3-7 rcu_nocbs=3-7 irqaffinity=0-1这样做的原因是:CPU 2 保留周期 tick,虽然没有普通业务,但它可以作为 RCU 宽限期检测的可靠应答者,同时也能承接一些被irqaffinity漏掉的零星中断。真正追求低延迟的业务线程放在 CPU 3-7,它们之间相互隔离。
如果业务线程必须使用 SCHED_FIFO 这类实时调度策略,我还会额外做几件事:
- 在业务代码里避免长时间关中断的内核路径;
- 定期检查
dmesg -T | grep -i rcu,把 stall 当严重事件处理; - 结合 ftrace 的
rcu:*事件做回归对比,跑一次压测后确认宽限期都能在预期时间内结束。
验证流程我会这样走:先确认隔离状态,再压测,最后看 RCU 统计。压测期间开启 ftrace,观察rcu_grace_period是否连续出现长时间的wait;压测后对比dmesg和/proc/softirqs,确认没有新的 stall 告警。如果压测里再次出现,我会优先怀疑是我又把“业务隔离”和“内核管理隔离”搞混了。
最后聊一个经验技巧:很多人遇到 RCU stall 时会直接拉大CONFIG_RCU_CPU_STALL_TIMEOUT,比如改成 120 秒甚至更长。这个参数只能掩盖告警,不能解决宽限期一直被卡住的事实。真正正确的做法是先搞清楚 RCU 是在等 QS,还是在等回调线程,然后分别处理。隔离核本身不可怕,可怕的是隔离之后没有给 RCU 留一条能走通的路。