Linux 内核 RCU 单处理器(UP)实现解析:为什么 call_rcu() 在单核系统上也绝不能立即执行回调
本文基于内核文档 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)上下文中删除同一个链表的元素。具体时序如下:
- 进程上下文的扫描正在引用元素 B;
- 此时软中断处理打断扫描,软中断代码删除了元素 B;
- 软中断随后调用
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() 推迟到释放锁之后再调用。但文档指出了两类让这种重构变得非常难看的场景:
- 同一临界区内需要登记多个
call_rcu():代码必须先把这些项串成一个链表,等锁释放后再遍历链表逐个登记; - 锁横跨某些内核 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。所以不要这么做!
展开来说,即便在单处理器系统上:
- RCU 基础设施必须尊重优雅周期(grace period);
- 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_ctrlblk 的 rcucblist 链尾,从头到尾没有任何调用 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.c 的 rcu_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 时应遵守:
- 永远不要假设
call_rcu()会立即执行回调,也不要在自己的代码里添加"单核优化"去提前释放数据;call_rcu()之后,数据项的有效性只由 RCU 保证,释放时点由静默状态决定; - 写侧回调中获取的锁,其他上下文的获取方必须升级到
_bh/_irq变体(如spin_lock_bh()、spin_lock_irqsave()),尤其要注意kfree()这类间接拿锁的路径; - 读侧临界区内禁止阻塞:不要在其中调用
synchronize_rcu()、分配GFP_KERNEL内存、睡眠等; - 在非可抢占的单核配置(
TINY_RCU默认生效,见 kernel/rcu/Kconfig)下,synchronize_rcu()是近乎零成本的——它仅推进gp_seq;但一旦构建启用了可抢占 RCU,即使在单核硬件上运行,也必须按完整的优雅周期语义对待它。
如需进一步阅读 RCU 的整体设计与使用规范,可参考同目录下的 Documentation/RCU/whatisRCU.rst、Documentation/RCU/checklist.rst 以及 Documentation/RCU/index.rst 目录页。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00