rcu: Make call_rcu() safe to call from any context
💡 一句话总结
RCU/SRCU 的回调链表的访问只能在关中断下进行,因此当 call_rcu()/call_srcu() 在中断已关闭的上下文中被调用时——典型如 NMI 处理器或BPF fentry 挂在内核入队路径上的重入——会打断正在进行的链表操作,造成链表破坏或自死锁。本系列用「llist 暂存 + irq_work 回投」的延迟机制:关中断时先把回调挂到一个无锁的每-CPU 链表,等中断恢复后由 hard irq_work 原样回投到正式入队路径;使回调入队在任意上下文(含 NMI)都安全,消除了一类自我死锁/内存破坏的 bug。
📋 补丁基本信息
| 项目 | 内容 |
|---|---|
| 补丁类型 | 正确性 / 健壮性(消除 NMI 与重入场景的链表破坏与自死锁) |
| 状态 | In Review(v2,已获 Paul 应用 -rcu 测试 + Kumar Acked) |
| 当前版本 | v2 · cover letter |
| 版本演进 | v1(07-29)→ 讨论(Paul/Zqiang/Kumar)→ v2(08-03,修复 Zqiang 指出的重入活锁) |
| 作者机构 | Puranjay Mohan(puranjay@kernel.org) |
| 提交日期 | 2026-08-03(v2) |
| 系列规模 | 6 补丁 · 12 文件 · +666/-41 行(覆盖 Tree/Tiny 的 RCU 与 SRCU + rcutorture + BPF 自测) |
| 核心函数 | __call_rcu_common() / call_rcu_defer() / rcu_defer_drain() / should_rcu_defer() |
📊 速览卡片
🎯 解决什么问题
call_rcu() 在 local_irq_save() 下执行(nocb 卸载时还会拿 nocb 锁),回调的批量执行与宽限期推进也一样。也就是说,call_rcu() 自身的安全前提是调用时中断已打开——只有在中断打开时入队,才不可能打断同 CPU 上另一个正在进行中的链表操作。
- NMI 处理器:NMI 可以在任意时刻打断正在入队/执行回调的代码,此时调用
call_rcu()会直接破坏链表或死锁。 - 插桩重入(instrumentation re-entry):kprobes / fentry / tracepoint 挂在内核入队路径上,回调里再触发
call_rcu()。
rcu_segcblist_enqueue() 上,程序内释放对象;释放动作走到 call_rcu_tasks_trace()——它底层就是 call_srcu()——在同 CPU、srcu_data 锁已被持有的情况下再次入队,在同一个锁上自死锁。无论哪种情况,直接入队要么破坏链表、要么死锁。
- BPF 生态:sleepable BPF 程序(如挂在 RCU 内部函数上的 fentry)需要释放对象/触发 RCU 延迟工作,它们天然可能重入 RCU 入队路径。让
call_rcu()/call_srcu()在任何上下文安全,等于移除了一整类「BPF 程序触碰内核内部路径即自死锁」的限制。 - NMI 场景:perf 溢出 NMI 等硬中断上下文可以安全提交回调,内核不必再假设「NMI 里不能碰 RCU」。
- 健壮性:插桩(ftrace/kprobe)是内核对内核自身的观测手段,其引入的重入不应成为内核崩溃/挂死的来源。
🧩 核心机制
设计取向(cover letter):不在入队路径里散落各种上下文判断,而是把判断集中到一处 should_rcu_defer(),命中就「延迟」,由统一的 llist + irq_work 机制回投。从系统层面看,改动分四块:
CONFIG_RCU_DEFER,在「可能发生重入入队」的配置下才打开:HAVE_NMI || KPROBES || FUNCTION_TRACER || TRACEPOINTS,并 select IRQ_WORK。没有它,call_rcu() 与以前完全一致(零行为变化)。should_rcu_defer() 是唯一判断点:中断已关 且 调度器已起来(调度器未启动时 irq_work 尚不可用,而 rcu_init() 早期就要调 call_rcu(),所以 boot 早期直接入队)。
call_rcu_defer() 把回调 stage 到每-CPU 的 defer_head(struct llist_head)上,用一个 IRQ_WORK_INIT_HARD 的 defer_work 唤醒。llist_add() 是无锁单链表头插,NMI 下也安全;只有 drain 一侧才拿锁。IRQ_WORK_INIT_HARD 不是正确性必需,而是为了保证在 PREEMPT_RT 上回投不被 kthread 延迟——非 HARD irq_work 在 RT 上跑在 kthread 里,负载下会被推迟,导致延迟回调堆积、最终 OOM。
rcu_defer_drain() 在 defer_lock(每-CPU raw 锁)保护下 llist_del_all(),然后把每个回调直接交给入队 helper rcu_do_enqueue()——注意是绕过了 defer 判断的 helper,所以「回投不会再延迟」。入队 helper 里接触的锁(nocb / rcu_node / srcu_data)本来就是 raw 锁,嵌套无问题。
do_idle() / cpuhp_ap_report_dead())被延迟,此时已过了 CPUHP_AP_SMPCFD_DYING 的 irq_work flush 点,irq_work 不会再跑。rcu_barrier()/srcu_barrier() 因此在扫描链表前先 rcu_defer_flush():在线 CPU 等它的 irq_work(irq_work_sync),离线 CPU 则直接 drain 它的列表(irq_work 可能永不再跑)。rcutree_migrate_callbacks() 在迁移回调时也先 drain 出局 CPU 的 defer_head,保证即使没人调 barrier,晚期延迟的回调也会落到正式链表上。三个 drainer 用 defer_lock 串行化,保证「从延迟链表取出但还没放上正式链表」的中间态不存在。
rcu_defer_drain() 内部再次调用 call_rcu()、再 stage、再拉高 irq_work,drain 永远不完——这是 Zqiang 在 v1 中指出的活锁。v2 用一个每-CPU 布尔 rcu_defer_draining 挡住:drain 进行中到达的延迟请求,若非来自 NMI,直接丢弃并 WARN_ONCE。丢弃会泄漏该回调,但替代方案是 CPU 永不离开 drain;且生产者是「每次入队产生一个回调」的 BPF 程序,没有有限的等待目标,只能丢弃。
🔬 关键代码
以下 diff 逐字取自补丁 1(kernel/rcu/tree.c,Tree RCU 主体)。
① 集中式判断:should_rcu_defer()
+/*
+ * Defer a call_rcu()/call_srcu() callback rather than enqueue it now? Defer
+ * whenever interrupts are disabled, since a callback-list operation may be in
+ * flight on this CPU. Not while the scheduler is down, though: irq_work is
+ * unusable before init_IRQ(), yet rcu_init() already calls call_rcu().
+ */
+static inline bool should_rcu_defer(void)
+{
+ if (!IS_ENABLED(CONFIG_RCU_DEFER))
+ return false;
+
+ return irqs_disabled() && rcu_scheduler_active != RCU_SCHEDULER_INACTIVE;
+}▲ 唯一入口判断:关中断即延迟;调度器未起来(boot 早期)不延迟。CONFIG_RCU_DEFER 关闭时恒 false,行为与旧代码完全一致。
② 延迟 stage:call_rcu_defer()
+/* Stage @head for this CPU's irq_work when call_rcu() cannot enqueue now. */
+static void call_rcu_defer(struct rcu_head *head, rcu_callback_t func)
+{
+ struct rcu_data *rdp = this_cpu_ptr(&rcu_data);
+
+ /*
+ * Instrumentation on the enqueue path can re-enter here from inside
+ * rcu_defer_drain(). Re-queuing would livelock the drain, so drop the
+ * callback; an NMI is one-shot and cannot loop, so let it through.
+ */
+ if (this_cpu_read(rcu_defer_draining) && !in_nmi()) {
+ WARN_ONCE(1, "call_rcu() re-entered during callback drain; leaking callback\n");
+ return;
+ }
+ head->func = func;
+ if (llist_add((struct llist_node *)head, &rdp->defer_head))
+ irq_work_queue(&rdp->defer_work);
+}▲ llist_add() 无锁头插,NMI 安全;重入活锁靠 rcu_defer_draining 丢弃(非 NMI)。这是 v1→v2 的核心新增。
③ 回投 drain:rcu_defer_drain() + rcu_do_enqueue()
+static void rcu_defer_drain(struct irq_work *iw)
+{
+ struct rcu_data *rdp = container_of(iw, struct rcu_data, defer_work);
+ struct llist_node *node, *next;
+ unsigned long flags;
+
+ raw_spin_lock_irqsave(&rdp->defer_lock, flags);
+ this_cpu_write(rcu_defer_draining, true);
+ llist_for_each_safe(node, next, llist_del_all(&rdp->defer_head)) {
+ struct rcu_head *head = (struct rcu_head *)node;
+
+ rcu_do_enqueue(head, head->func, false);
+ }
+ this_cpu_write(rcu_defer_draining, false);
+ raw_spin_unlock_irqrestore(&rdp->defer_lock, flags);
+}▲ 回投直连 rcu_do_enqueue()(绕过 defer 判断),锁粒度「取尽 + 回投」原子化,三个 drainer(irq_work / rcu_defer_flush / migrate_callbacks)串行。
④ 入队路径接线:__call_rcu_common()
+ if (should_rcu_defer()) {
+ call_rcu_defer(head, func);
+ return;
+ }
+
+ /* An NMI reaching here entered with irqs enabled, so the enqueue can race. */
+ WARN_ON_ONCE(IS_ENABLED(CONFIG_PROVE_RCU) && in_nmi());
+
+ rcu_do_enqueue(head, func, lazy_in);
+}▲ 命中延迟则走 call_rcu_defer();否则走原入队。PROVE_RCU 下若 NMI 直达此路径,说明 NMI 进入时中断是开的,属异常,warn。
⑤ 收尾:rcu_barrier() flush + CPU 下线 drain
@@ void rcu_barrier(void)
unsigned long s;
+ /* Register any deferred callbacks before snapshotting the sequence. */
+ rcu_defer_flush();
+
s = rcu_seq_snap(&rcu_state.barrier_sequence);
@@ void rcutree_migrate_callbacks(int cpu)
+ /*
+ * Callbacks the outgoing CPU deferred late in the offline path (past the
+ * point its irq_work can run) sit on ->defer_head, which the ->cblist
+ * migration below does not cover. Drain them here, before the early
+ * returns; the re-issue lands on this CPU.
+ */
+ rcu_defer_drain(&rdp->defer_work);
+
if (rcu_rdp_is_offloaded(rdp))
return;▲ rcu_defer_flush() 必须在序列号快照前执行,否则 barrier 可能漏掉刚回投的回调;migrate_callbacks 则兜住「晚期下线延迟」的回调。
补丁 2(Tiny kernel/rcu/tiny.c)是同样的模式,但 Tiny RCU 单处理器,用一个全局 LLIST_HEAD(rcu_defer_list) + 全局 rcu_defer_iw,且没有 CPU 下线 drain。补丁 3/4(Tree/Tiny SRCU)把同一套机制移植到 call_srcu():srcu_data 新增 defer_cbs + defer_link,多个 srcu_struct 通过 defer_link 链到每-CPU 的 srcu_defer.list 上由同一个 irq_work 统一 drain。补丁 5(rcutorture)用 per-CPU 硬件 perf 计数器溢出产生真实 NMI,从 NMI 里发 ->call() 并校验「NMI 发的回调全部被回调」(nmi-calls vs nmi-cbs 计数必须相等)。补丁 6(BPF selftest)即导火索的复现器:fentry 挂 rcu_segcblist_enqueue(),做 task-storage delete 触发 call_rcu_tasks_trace() 重入。
📈 性能影响
- 热路径几乎零成本:中断打开时调用
call_rcu(),只多一个should_rcu_defer()内联判断(CONFIG 检查 +irqs_disabled()+ 一次调度器状态比较),命中后走与旧代码完全相同的rcu_do_enqueue()。cover letter 明确承诺「没有 CONFIG_RCU_DEFER 时 call_rcu() 与以前完全一致」。 - 延迟路径:只有中断已关时才付
llist_add + irq_work_queue的代价——这类场景本身就是「此时直接入队必坏」的异常路径,付这点代价换安全是划算的。 - 延迟的副作用:回调被推迟到中断恢复后才真正入队,宽限期/执行会晚一个 irq_work 周期;对「必须尽快触发回调」的场景(如 kfree_rcu 风格的快速回收)理论上会引入微小延迟(解读(AI 分析),无实测)。
- IRQ_WORK_INIT_HARD 保证 RT 上不被 kthread 延迟,避免回调堆积导致 OOM——这是防退化设计而非性能优化。
🔄 方案演进 + 讨论焦点
- 07-29 v1:Puranjay 发 6 补丁系列(Tree/Tiny RCU + Tree/Tiny SRCU + rcutorture + BPF selftest)。
- 07-30:Paul E. McKenney(RCU 维护者)回复「Nice! 我把这系列应用到 -rcu 上测试评审」;并指出补丁 6(BPF selftest)可能走别的树,走 -rcu 需要相应 ack。
- 07-30:Zqiang 评审补丁 1,提出 nohz_full / isolcpus 关切:若当前 CPU 是隔离 CPU,把 irq_work 排到当前 CPU 会产生内核噪声,建议封装
rcu_irq_work_queue()把 irq_work 路由到 housekeeping CPU。Paul 反驳:「调用 call_rcu() 时必然已在内核上下文,用户态操作已被打断(很可能是未把中断引走的配置错误),此时再多一点 irq-work 干扰是否值得担心?我错过了什么吗?」 - 07-31:Zqiang 评审补丁 4(SRCU),提两个点:(a)
cleanup_srcu_struct()里srcu_defer_flush()是否应移到irq_work_sync(&sup->irq_work)之前?(b) 关键活锁场景:irq_work 上下文中srcu_defer_drain() → srcu_do_enqueue() → srcu_gp_start_if_needed() → rcu_segcblist_enqueue(),此处 fentry BPF 程序触发call_rcu_tasks_trace() → __call_srcu() → should_rcu_defer()=true → llist_add + irq_work_queue(),IPI 再次触发 drain,drain 永不完——我漏了什么吗? - 08-02:Kumar Kartikeya Dwivedi(BPF 维护者)Acked-by,同意走 Paul 的树;提示 BPF selftest 若不与修复同落地会死锁 CI,需要同步。
- 08-03:Paul 回复「Very good,我会在下次 rebase 时应用」。
- 08-03 v2:Puranjay 发 v2,按 changelog 修复了 Zqiang 指出的两点:(1) 重入活锁——每-CPU
rcu_defer_draining标志丢弃 drain 中途的重入(除非来自 NMI);(2) cleanup_srcu_struct()——先 drain 延迟回调再 sync->irq_work;另补 Tiny SRCU 的defer_iwsync、把 Kumar 的 ack 加进 BPF selftest 补丁。 - 08-03:sashiko-bot(AI 评审机器人)对 v2 提出新关切:
rcu_defer_flush()在取rcu_state.barrier_mutex之前执行,多个并发rcu_barrier()会并发对同一 irq_work 调irq_work_sync();在arch_irq_work_has_interrupt()为假的架构上irq_work_sync()用 rcuwait(单 waiter),并发 caller 会互相覆盖 task 指针,导致其中一个错过唤醒挂死。
- 焦点 1(已解决):重入活锁——v2 用 per-CPU 标志「丢回调 + WARN」换取有限推进,是「丢一个回调 vs CPU 永久卡死」的取舍。
- 焦点 2(已解决):cleanup_srcu_struct() 中 flush 与 irq_work_sync 的顺序。
- 焦点 3(讨论中,Paul 持保留):nohz_full 隔离 CPU 上 irq_work 的内核噪声是否值得路由到 housekeeping CPU。Paul 的立场是「多这点干扰不值得担心」,未见 v2 采纳。
- 焦点 4(新,待回应):sashiko-bot 指出的并发
rcu_barrier()在无 irq_work 中断架构上的irq_work_sync()单 waiter 竞态。这是 v2 发布后出现的新评审点,v3 或需把 flush 移到 barrier mutex 内(解读(AI 分析))。
⚠️ 风险与局限
- 回调丢弃(泄漏):drain 中途的重入请求会被丢弃并 WARN_ONCE——这是有意的取舍,但确实会泄漏回调、并伴随一次 WARN 噪音。若实际触发,需要 BPF 生产者层面配合(解读(AI 分析))。
- irq_work 自身被插桩仍会活锁:cover letter 承认「插桩 irq_work 机制本身仍可能循环,任何 irq_work 用户都如此,本系列修不了」。
- 并发 barrier 的 irq_work_sync 竞态:sashiko-bot 指出的并发
rcu_barrier()问题在无arch_irq_work_has_interrupt()架构上可能导致挂死,v2 尚未处理。 - nohz_full 噪声:Zqiang 的关切未在 v2 中处理,Paul 认为影响可忽略;若社区后续坚持,会再加 housekeeping 路由。
- BPF selftest 与内核必须同步落地:Kumar 明确提示,selftest 挂未修复内核会死锁 CI,必须与修复一起合入。
- 范围:仅覆盖 RCU/SRCU 的回调入队;
call_rcu_tasks()/ Tasks Rude 未在本系列中 NMI 安全化(rcutorture 明确把 Tasks / Tasks Rude 排除在nmi_capable之外)。
🔗 交叉引用
- RCU Tasks Trace:
call_rcu_tasks_trace()底层是call_srcu()于专用rcu_tasks_trace_srcu_struct——这是导火索中的关键,也是本系列把 SRCU 一并改造的原因。 - BPF 睡眠程序 / task storage:BPF 程序删除 task storage 会触发
call_rcu_tasks_trace(),是重入源。 - rcu_segcblist_enqueue():BPF fentry 的挂载点,位于入队热路径。
- rcutorture NMI 压力:补丁 5 用 perf 溢出 NMI 覆盖延迟路径,是本系列自验证手段;同类思路见既有
irq_capable(定时器中断场景)。
✅ 关键洞察
- 定性:这是正确性/健壮性改造,而非性能优化——它消灭的是一整类「NMI/插桩/BPF 重入 RCU 入队路径导致的自死锁与链表破坏」。
- 设计亮点:判断集中到
should_rcu_defer()一处,入队路径只加一次内联判断;延迟侧用「无锁 llist stage + 单锁 drain」,NMI 安全与互斥分得很干净。 - 证据:cover letter + 补丁说明详述了死锁场景与 CPU 下线难点;Paul 已应用 -rcu 测试、Kumar Acked-by、Zqiang 的活锁指摘在 v2 中落地修复。
- 边界:无实测性能数据;回调丢弃是刻意取舍;并发 barrier 的 irq_work_sync 竞态与 nohz_full 噪声是尚在讨论的开放点。
- 对日报的价值:这是 RCU 子系统「放宽调用上下文限制」的方向性变化,为 BPF/NMI 等场景扫除雷区,值得关注其后续 v3 如何回应 AI 评审与并发 barrier 关切。
本站内容均由 AI 基于公开知识辅助生成,仅供学习参考,请勿直接引用作为依据。作者不对信息的准确性、完整性及适用性作保证,亦不对因使用本站内容产生的任何损失承担责任。