首页
/ Linux 内核 RCU 单处理器(UP)实现解析:为什么 call_rcu() 在单核系统上也绝不能立即执行回调

Linux 内核 RCU 单处理器(UP)实现解析:为什么 call_rcu() 在单核系统上也绝不能立即执行回调

2026-09-06 12:32:02作者:伍霜盼Ellen

本文基于内核文档 Documentation/RCU/UP.rst 展开,主题为 RCU(Read-Copy Update,读写复制更新)在单处理器(Uniprocessor, UP)系统上的实现约束:为什么 call_rcu() 即使在只有一个 CPU 的机器上也绝不允许直接调用其回调函数。读完本文,你将掌握 UP 系统下 RCU 的三条核心不变式(静默状态、优雅周期、无锁回调环境)、call_rcu()synchronize_rcu() 在单核上的不同待遇,并能在 kernel/rcu/tiny.c 的 Tiny RCU 实现中逐行验证这些设计决策。

一个流传甚广的误解:单核上 call_rcu() 可以直接执行回调

对 RCU 的一个常见误解是:在 UP 系统上,call_rcu() 原语可以立即调用其注册的回调函数。这个误解的根基看似合理——既然只有一片 CPU,就没有"等待其他 CPU 上的工作完成"的必要,因为根本没有别的 CPU 能让别的事情发生。

文档明确指出:这种"看起来能省一步"的做法在某些时候确实能"凑合"运行,甚至相当多的时候都能正常工作,但从工程角度看这是一个非常坏的主意。该文档随后给出了三个具体反例,逐一展示"立即执行回调"到底糟糕到什么程度。

反例一:软中断自杀(softirq Suicide)

假设一个基于 RCU 的算法在进程上下文中扫描一个包含 A、B、C 三个元素的链表,并且该算法可以在软中断(softirq)上下文中删除同一个链表的元素。具体时序如下:

  1. 进程上下文的扫描正在引用元素 B;
  2. 此时软中断处理打断扫描,软中断代码删除了元素 B;
  3. 软中断随后调用 call_rcu(),登记"在一个优雅周期(grace period)之后释放元素 B"。

如果 call_rcu() 直接执行其参数(即当场调用释放回调),那么当软中断返回、进程上下文继续扫描链表时,扫描代码将发现自己正引用着一个刚刚被释放的元素 B——一个典型的 use-after-free。文档的原话是:这种情形会"大幅缩短你内核的寿命"。

需要注意的是,同样的问题发生在 call_rcu()硬件中断处理程序(hardirq handler)中调用时也一样成立:中断返回后,被中断的代码路径同样会踩到已释放内存。

这个反例揭示了第一条不变式:RCU 回调的执行必须推迟到读侧临界区确定结束之后,而"单核"本身并不能保证这一点——因为软中断/硬中断可以在读侧临界区的中间"抢占"同一块 CPU。

反例二:函数调用致命伤(Function-Call Fatality)

有人或许会认为:那只要让 call_rcu() 仅当它从进程上下文被调用时才直接执行参数,就能规避反例一中的"自杀"。文档指出这同样会失败,而且失败得更隐蔽。

设想同一个场景:RCU 算法在进程上下文中扫描含 A、B、C 的链表,并且每扫描到一个元素就对该元素调用一个函数。进一步假设,扫描到元素 B 时,被调用的函数从链表中删除了 B,然后把 B 交给 call_rcu() 做延迟释放。文档强调:这虽然略显不寻常,但完全符合 RCU 的使用规范——因为 call_rcu() 本就要求等待一个优雅周期过去之后才执行回调,调用方有权假设"此刻回调还没跑,元素 B 仍然有效"。

因此,如果在这种情形下允许 call_rcu() 立即执行其参数,就等于破坏了 RCU 赖以存在的根本保证call_rcu() 必须推迟执行其参数,直到所有当前正在运行的 RCU 读侧临界区都已完成。此时外层扫描代码仍在读侧临界区内(它还要继续访问链表),立即执行回调同样会导致崩溃。

这里藏着一个 Quick Quiz #1:

Quick Quiz #1:在这种情形下,为什么不允许调用 synchronize_rcu()

答案(引自 UP.rst 的答题部分):因为调用方函数正在扫描一个 RCU 保护的链表,它本身就处于 RCU 读侧临界区之中;被调用的函数因此是在 RCU 读侧临界区内部被调用的,而读侧临界区不允许阻塞——synchronize_rcu() 恰恰是一个可能阻塞的接口,故不合法。

反例三:死于死锁(Death by Deadlock)

第三个反例聚焦锁。假设 call_rcu() 是在持有某把锁的状态下调用的,而该 RCU 回调函数又必须获取同一把锁。此时如果 call_rcu() 直接调用回调,结果就是自死锁(self-deadlock)——而且文档特别强调:即便这个立即调用来自一次晚了很多、相隔完整一个优雅周期之后的 call_rcu(),自死锁依然会发生。

有些场景下,确实可以重构代码,把 call_rcu() 推迟到释放锁之后再调用。但文档指出了两类让这种重构变得非常难看的场景:

  1. 同一临界区内需要登记多个 call_rcu():代码必须先把这些项串成一个链表,等锁释放后再遍历链表逐个登记;
  2. 锁横跨某些内核 API 持有:推迟 call_rcu() 就意味着数据项必须通过一个公共 API 逐层向上传递。相比之下,从机制上保证"回调执行时不持有任何锁",远比修改这些 API、让它们能携带任意数据项要好得多。

结论:一旦 call_rcu() 允许直接执行回调,就会引入痛苦的加锁限制或 API 改造。这引出 Quick Quiz #2:

Quick Quiz #2:RCU 回调必须遵守什么样的加锁限制?

答案是本文最重要的工程约束之一:

  • RCU 回调中获取的任何锁,在别处获取时都必须使用 _bh 变体的自旋锁原语。例如,若回调获取了 mylock,那么进程上下文的获取就必须使用类似 spin_lock_bh() 的方式;使用 _irq 变体(如 spin_lock_irqsave())也是合法的。
  • 如果进程上下文代码只用普通 spin_lock() 获取该锁,由于 RCU 回调可能从软中断上下文被调用,回调可能恰好在一个软中断中中断了进程上下文的临界区而执行——自死锁由此产生。
  • 这条限制看似"多此一举",因为直接获取锁的 RCU 回调很少;但大量 RCU 回调是间接获取锁的,最典型的就是通过 kfree() 原语——slab 分配器内部持有自己的锁。

Quick Quiz #3:为什么运行可抢占 RCU(preemptible RCU)的 UP 系统上,synchronize_rcu() 不能立即返回?

答案:因为其他任务可能正被抢占在某个 RCU 读侧临界区的中间。如果 synchronize_rcu() 直接返回,它就把"优雅周期结束"过早地广播出去了;当那个被抢占的任务再次运行起来时,它所依赖的旧数据可能已被回收——"那对另一个线程来说将是个巨大的惊吓"。

用户态 RCU 的例外:受控的立即执行

文档专门说明:用户态(userspace)的 RCU 实现确实允许 call_rcu() 直接调用回调,但条件是:自该回调入队以来,必须已经完整走过一个优雅周期。之所以存在这个例外,是因为某些用户态环境(例如内存、调度资源极度受限的嵌入式场景)约束非常苛刻,必须把延迟开销降到最低。即便如此,文档仍然强烈建议用户态 RCU 的实现者避免在 call_rcu() 中直接执行回调,以换取上文所述的全部死锁规避收益。

换言之,"单核上立即执行回调"在用户态是一个受优雅周期守卫的可选优化,在内核态则是一个无条件禁止的行为。

小结:UP 系统上 RCU 必须遵守的三条底线

文档的总结部分可以浓缩为一句命令式告诫:允许 call_rcu() 立即执行其参数,哪怕在 UP 系统上,也会破坏 RCU。所以不要这么做!

展开来说,即便在单处理器系统上:

  1. RCU 基础设施必须尊重优雅周期(grace period);
  2. RCU 基础设施必须从一个"不持有任何锁"的已知环境中调用回调(在内核中,这个环境就是软中断上下文的回调处理)。

与之形成鲜明对比的是:在 UP 系统上,synchronize_rcu() 立即返回是安全的,即使是在运行于 UP 硬件上的 PREEMPT SMP 构建也不例外。其原理(与 Tiny RCU 源码注释一致):synchronize_rcu() 不能在 RCU 读侧临界区内被合法调用,因此任何合法的 synchronize_rcu() 调用点本身就是一个静默状态(quiescent state);单核系统上唯一的"读者"就是调用者自身且它不在读侧,于是等待可以被完全省掉。

源码印证:Tiny RCU 如何实现上述设计

文档所述的设计决策,在内核的 Tiny RCU 实现 kernel/rcu/tiny.c 中可以得到逐行印证。Tiny RCU 是面向 UP 系统的精简实现,其选型由 kernel/rcu/Kconfig 中的 TINY_RCU 选项控制:default y if !PREEMPT_RCU && !SMP——即默认选择 Tiny RCU 的前提是非可抢占 RCU 且非 SMP,文档标题中"UP 系统"的默认语境正对应这一配置路径。

call_rcu() 只入队、从不执行

kernel/rcu/tiny.c 中的 call_rcu() 实现证实了"绝不立即执行":

void call_rcu(struct rcu_head *head, rcu_callback_t func)
{
    ...
    head->func = func;
    head->next = NULL;

    local_irq_save(flags);
    *rcu_ctrlblk.curtail = head;          /* 挂到全局回调链表尾部 */
    rcu_ctrlblk.curtail = &head->next;
    local_irq_restore(flags);

    if (unlikely(is_idle_task(current))) {
        /* force scheduling for rcu_qs() */
        resched_cpu(0);
    }
}

它只是关中断地把回调节点挂到全局控制块 rcu_ctrlblkrcucblist 链尾,从头到尾没有任何调用 head->func 的动作。若调用者恰是 idle 任务,则通过 resched_cpu(0) 强制调度,保证静默状态能被后续记录——这正对应文档"必须尊重优雅周期"的第一条底线。

静默状态与"done 指针"的前移

kernel/rcu/tiny.c 中的 rcu_qs() 记录静默状态:

void rcu_qs(void)
{
    unsigned long flags;

    local_irq_save(flags);
    if (rcu_ctrlblk.donetail != rcu_ctrlblk.curtail) {
        rcu_ctrlblk.donetail = rcu_ctrlblk.curtail;   /* 整条链表进入"可执行"状态 */
        raise_softirq_irqoff(RCU_SOFTIRQ);             /* 唤醒软中断执行回调 */
    }
    WRITE_ONCE(rcu_ctrlblk.gp_seq, rcu_ctrlblk.gp_seq + 2);
    local_irq_restore(flags);
}

rcu_ctrlblk 结构体(tiny.c#L31-L36)维护三条指针:rcucblist(待处理回调链表头)、donetail("已可执行"段的末尾)、curtail(整条链表的末尾)。只有当某个静默状态到来、donetail 前移到 curtail 之后,位于 donetail 之前的回调才被视为"优雅周期已过"。这一"双尾指针"机制从数据结构层面保证了:没有任何回调能被早于静默状态执行——反例一、反例二的崩溃路径在此被结构性地堵死。

回调统一在软中断环境中执行

rcu_qs() 抬升的 RCU_SOFTIRQ,其处理函数是在 tiny.crcu_init() 中注册的:

void __init rcu_init(void)
{
    open_softirq(RCU_SOFTIRQ, rcu_process_callbacks);
    ...
}

rcu_process_callbacks() 的工作分两步:先关中断把 donetail 之前"已就绪"的回调摘到本地链表,再在软中断上下文中逐个调用 rcu_reclaim_tiny() 执行回调(tiny.c#L83-L96 中可见 f(head) 的唯一调用点)。这精确实现了文档"必须从已知无锁环境调用回调"的第二条底线:执行回调的时刻,调用栈必然是软中断处理栈,任何进程上下文临界区都不可能正处于被"软中断抢先执行回调"所威胁的窗口之内——因为软中断只有在静默状态被确认(进程已离开临界区)之后才会被 rcu_qs() 唤醒。

synchronize_rcu() 为何可以"什么都不做"

最后印证 synchronize_rcu() 在 UP 上立即返回的安全性。kernel/rcu/tiny.c 的实现只做了两件事:

void synchronize_rcu(void)
{
    RCU_LOCKDEP_WARN(lock_is_held(&rcu_bh_lock_map) ||
                     lock_is_held(&rcu_lock_map) ||
                     lock_is_held(&rcu_sched_lock_map),
                     "Illegal synchronize_rcu() in RCU read-side critical section");
    preempt_disable();
    WRITE_ONCE(rcu_ctrlblk.gp_seq, rcu_ctrlblk.gp_seq + 2);
    preempt_enable();
}

源码上方的注释(tiny.c#L129-L140)几乎逐字复述了文档的论证:"调用 synchronize_rcu() 时处于 RCU 读侧临界区是非法的。因此,任何合法的 synchronize_rcu() 调用都是一个静默状态,所以在 UP 系统上,synchronize_rcu() 除了让轮询型 API 得知又一个优雅周期已经过去之外,无需做任何事"。RCU_LOCKDEP_WARN 则是在 lockdep 开启时用锁跟踪机制在运行时兜底检查这条"不得在读侧临界区内调用"的规则,与 Quick Quiz #1 的答案完全呼应。

而 Quick Quiz #3 所指的情形则落在另一条实现路径上:TINY_RCU 的默认条件是 !PREEMPT_RCU && !SMP,一旦启用了可抢占 RCU(PREEMPT_RCU/PREEMPT_DYNAMIC 会选中 TREE_RCU),单核硬件上运行的内核中"其他任务被抢占在读侧临界区中间"的状态就可能存在,synchronize_rcu()不能立即返回——这与文档"运行可抢占 RCU 的 UP 系统是例外"的论述一一对应。Tiny RCU 中的 gp_seq 计数器(每次静默状态加 2)也正是 poll_state_synchronize_rcu() 等轮询型 API 判断"优雅周期是否完成"的依据(tiny.c#L200-L231)。

给开发者的实践清单

结合文档与 Tiny RCU 源码,在单核内核中使用 RCU 时应遵守:

  1. 永远不要假设 call_rcu() 会立即执行回调,也不要在自己的代码里添加"单核优化"去提前释放数据;call_rcu() 之后,数据项的有效性只由 RCU 保证,释放时点由静默状态决定;
  2. 写侧回调中获取的锁,其他上下文的获取方必须升级到 _bh/_irq 变体(如 spin_lock_bh()spin_lock_irqsave()),尤其要注意 kfree() 这类间接拿锁的路径;
  3. 读侧临界区内禁止阻塞:不要在其中调用 synchronize_rcu()、分配 GFP_KERNEL 内存、睡眠等;
  4. 非可抢占的单核配置TINY_RCU 默认生效,见 kernel/rcu/Kconfig)下,synchronize_rcu() 是近乎零成本的——它仅推进 gp_seq;但一旦构建启用了可抢占 RCU,即使在单核硬件上运行,也必须按完整的优雅周期语义对待它。

如需进一步阅读 RCU 的整体设计与使用规范,可参考同目录下的 Documentation/RCU/whatisRCU.rstDocumentation/RCU/checklist.rst 以及 Documentation/RCU/index.rst 目录页。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
916
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388