CPU隔离后RCU Stall告警频发?根因排查与解决思路 1. 告警先来一个隔离核跑满后RCU 的宽限期卡住了先说结论这不是“CPU 隔离”这件事本身错了而是 RCU 这个内核机制的运行前提恰好被隔离动作砍掉了一部分。很多第一次做低延迟优化的人都在这上面栽过跟头我也不例外。前不久线上有个批处理服务为了压尾延迟把一台 8 核机器的 CPU 2-7 全部隔离出来跑高优先级业务启动参数大概长这样isolcpus2-7 nohz_full2-7 rcu_nocbs2-7CPU 0/1 留作 housekeeping收中断、跑内核线程、管理定时器和 RCU。刚开始一切正常业务线程独占 CPU调度抖动几乎消失p99 数据非常漂亮。结果某天高峰期监控突然开始报延迟抖动业务侧看到的是几十毫秒到上百毫秒的停顿而不是平时的几十微秒。登录机器第一件事看 dmesg果然躺着一条刺眼的告警[ 3121.449192] rcu_preempt detected stalls on CPUs/tasks: [ 3121.449194] 5-...: (1 GPs behind) idle552/0/0x0 softirq1/1 fqs0 [ 3121.449196] (detected by CPU 0, t60000 jiffies, g1535, q1487) [ 3121.449198] Task backtrace: [ 3121.449200] [ffffffff810e3d2a] ...翻译成人话就是RCU 等待一个宽限期grace period等太久了默认超过 60 秒没有完成于是内核认为某个 CPU 或某个任务“卡死”了主动打印告警。这次点名的是 CPU 5——正好是我们隔离出来的核之一。当时我的第一反应是“业务线程是不是在内核态死循环了”。但用ps一看CPU 5 上跑的是一个普通的用户态计算任务状态 RCPU 占用 100%没有异常内核栈。这就很奇怪了一个纯用户态进程怎么会让 RCU 卡住后面排查才发现问题出在我对 RCU 和 CPU 隔离之间关系的理解上。隔离核并不只是“调度器不往上面放任务”这么简单它同时改变了 RCU 感知这个 CPU 是否存活的方式。想明白这一点才是解决这类告警的关键。2. RCU 为什么依赖时钟中断而 CPU 隔离为什么恰好砍断这根线2.1 RCU 的宽限期逻辑它如何知道一个 CPU“还活着”RCURead-Copy-Update是 Linux 内核里一套“读多写少”场景下的同步机制。读侧基本无锁写侧更新数据时先拷贝一份新数据发布出去然后等待所有可能还在读旧数据的读者离开临界区再安全释放旧数据。这个“等待读者离开临界区”的过程就是宽限期。RCU 必须确保在一个宽限期内每个 CPU 都至少经历一次 quiescent state静止状态简称 QS比如发生了上下文切换、进入了用户态、进入了 idle或者经过了调度时钟中断的处理点。一个 CPU 只要在宽限期内交过一次 QSRCU 就认为它不会再持有旧的引用。正常情况下每个 CPU 都会周期性收到本地时钟中断也就是 tick。RCU 在 tick 的处理路径rcu_sched_clock_irq()里检查当前 CPU 是否处于可确认 QS 的状态。只要有周期 tick 在RCU 就能在几十毫秒内拿到所有 CPU 的 QS宽限期很快结束。这也是为什么默认CONFIG_RCU_CPU_STALL_TIMEOUT60很少被触发——在标准内核里一个 CPU 连续 60 秒不交 QS 几乎是不可能的。可以把这个过程理解成一个部门点名周期 tick 就是“每隔一会儿喊一声让所有人都应答一下”RCU 就是靠这个应答来判断“人还在、没有锁死”。一旦有人长期不应答RCU 就会拉响警报。2.2 isolcpus / nohz_full / rcu_nocbs 分别干了什么很多教程把isolcpus、nohz_full、rcu_nocbs三个参数混在一起讲好像它们是一套组合拳。实际上它们各管一段但组合起来之后会形成一个对 RCU 不太友好的环境。先说isolcpus2-7。它把指定 CPU 从 CFS 调度器的普通任务分配池里拿出来普通进程默认不会调度到这些核上。但注意它不阻止内核线程、中断处理、软中断跑上去。换句话说它的作用是“不让普通任务过去”而不是“保证这些核完全干净”。然后是nohz_full2-7。这个参数更进一步它允许指定 CPU 进入完全动态 tick 模式。只要某个隔离核上只有一个可运行任务内核就把该 CPU 的周期性时钟中断关掉尽量不给这个任务制造节拍打扰。这样业务线程可以长时间在用户态跑中间完全没有周期性的调度器检查。最后是rcu_nocbs2-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 等了很久。fqs0表示强制 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 switchRCU 就无法获得 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_full2-7但rcu_nocbs3-7CPU 2 处于 nohz_full 却没有 offload 回调。结果 CPU 2 既要保持 tickless又要被 RCU 回调唤醒两边打架最终触发 stall。这个范围错位不仔细核对根本看不出来。用一张表梳理四种情况方便大家对照排查根因方向典型现象判断方法处理思路回调线程饿死CPU 5 被点名rcuop/5无法运行ps -eo psr查线程位置观察回调队列调整调度优先级、cpuset、rcu_nocbs范围内核态/关中断路径回调查栈停在驱动或自旋点分析 backtrace、检查业务代码修改业务路径或保留哨兵 tickhousekeeping 过载管理核 load 高rcu_preempt等线程延迟大看loadavg、运行队列、线程状态多留管理核分散软中断参数范围错位nohz_full与rcu_nocbs掩码不一致核对/proc/cmdline对齐掩码按版本文档配置5. 隔离核与 RCU 共存我目前在用的启动参数与验证流程踩过几次坑之后我的默认配置不再是一口气把所有核都隔离干净而是优先保证 RCU 有“后门可走”。以一台 8 核机器为例我现在倾向这样划分CPU 0/1 做 housekeepingCPU 2 作为一个“中间核”不完全关闭 tickCPU 3-7 才是真正进入 nohz_full rcu_nocbs 的业务核。启动参数大致是isolcpus2-7 nohz_full3-7 rcu_nocbs3-7 irqaffinity0-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 留一条能走通的路。