现在的CPU核心是越来越多了,在一个多核CPU的世界里,会遇到一个令人特别头疼的问题,怎么让一份数据在被无数个线程同时疯狂读取的时候,还能被我们安全的去修改它。今天,就来聊一个在Linux内核中解决这个问题的黑魔法,它叫做RCU,全称是Read,Copy.Update,也就是读,复制,更新。这是一种非常非常聪明的同步技术。尤其是对那种对读取速度要求高到变态的场景下,它简直能把传统的读写锁给甩出好几条街。
RCU代表读,复制,更新,听着名字,感觉是不是特别直白,好像三言两语就把自己的工作交代的一清二楚了,但问题是,如果事情真的就这么简单,它又凭什么被叫做LINUX内核里的魔法呢。所以,这个名字会不会是恰恰在迷惑我们,把它真正的秘密给藏起来了。那我们就先顺着这个名字往下挖一挖。看看到底是怎么回事。
如果只是从字面上去理解,RCU就好像是在说一个很普通的数据发布流程,这个流程本身是相当经典了:
- 第一步,读数据。
- 第二步,不在原地改,把它复制一份出来,在副本上改。
- 第三步,也是最关键的一步,用一个原子操作,把那个指向数据的指针“咔”一下瞬间指向新的,改好的副本。
这么以来,所有的读者,要么看到的是完整的旧数据,要么看到的是完整的新数据,绝对不可能看到改到一半,乱七八糟的,不完整的数据。下面是一个形象的比喻:
读写锁像图书馆的“阅览室规则”:所有人借书要登记,写书时清场。RCU 则像“活页本”:你要改一页时,复印那一页改好再放回去,等所有人读完旧页才扔掉旧页。读者永远不用排队。
这个方法非常棒,但是它并不新鲜,很多地方都在用,所以这不是RCU真正的魔法所在,虽然这三步操作是它的前提,但真正让RCU独一无二,与众不同的,其实和读取,复制或者更新关系不大,而是在这三步操作完成之后,接下来要做的那件事。真正的魔法要来了。
延迟销毁
RCU最核心,最天才的创新,是它把更新这件事,给巧妙的拆成了两步,移除和回收,这是一种延迟销毁的思想,就好比处理垃圾,你不是把旧东西扔进焚化炉,而是先把它从大家眼前拿走,藏起来,放到一个待处理的区域。
在移出阶段,更新者的任务很简单,就是把指向旧数据的指针给断开,让新来的读者找不到它就行了。然后,在未来的某个时间点,一个独立的回收阶段才会去真正释放这块旧数据的内存,但这里就引出了RCU最核心的那个难题,它到底怎么知道,什么时候去回收那块旧数据才是安全的呢?万一当时还有读者正在用它怎么办,要知道RCU为了追求极致的读取速度,它的读者在读取数据的时候是完全“隐身”的,它们不会加锁,不会登记,读完之后也不会通知任何人,所以,在这种情况下,怎么知道所有的读者都已经走光了?这才是RCU真正要解决的那个终极难题。
RCU如何感知读者离开临界区
为了解决这个大难题,RCU就引入了两个非常核心,而且特别绝妙的概念,一个叫做宽限期,英文是Grace Period,另一个是静止状态,Quiescent state,就是这套机制,让RCU能够在完全不打扰读者的情况下,优雅的感知到它们什么时候都已经离开了。
先说宽限期,你可以把它想象成一个安全等待窗口,当更新者把旧数据移除之后,它不会马上动手回收内存,而是会说,好了,现在开始,启动一个宽限期,RCU的整个机制会向我们保证,只要这个宽限期一结束,那么所有在宽限期开始前可能正在访问旧数据的读者,就一定一定都已经读完走人了。
那RCU是怎么做到这么神奇的保证的呢?答案就是静止状态,这个概念不复杂,说白了,只要一个CPU它当前没有正在执行被rcu_read_lock/rcu_read_unlock这两个标记包起来的代码,也就是没有在RCU的临界区内,我们就可以说,这个CPU正在处于一个静止状态。
和一般的定义方式不同,静止状态采用了负定义的方式,它的定义没有说明它是什么,而是说了它不是什么,利用了排除定义的方式,这种定义方法兼顾了完备性和扩展性,如果 CPU 不在 rcu_read_lock() 和 rcu_read_unlock() 之间,那它此刻一定不持有任何 RCU 保护的对象的引用,这个 CPU 不会阻止任何旧数据的回收。
静止状态 = { CPU的所有可能状态 } - { RCU读临界区内 }

其实在LINUX内核运行过程中,这种静止状态到处都是,非常自然。比如说,当一个CPU发生了上下文切换,那它肯定退出了当前的RCU读取区域了,或者CPU没事儿干,进入了idle循环,甚至从内核态切换到用户态去执行程序,这些时刻,都百分百是静止状态。RCU就是靠检测这些内核时刻都在发生的事件来工作的。只要一个 CPU 没有戴着 RCU 的“安全帽”(不在 rcu_read_lock/unlock 区域内),它就可以随时报告“我已静止”。哪怕它只是从内核返回用户态的那个瞬间,也算一次“QS”。LINUX系统中报告QS状态事件的上下文总结如下面文章所述:
我们把整个流程串起来走一遍,看看RCU到底是怎么运作的。第一步,更新者完成了指针的切换,从这一刻起,旧数据对于新来的读者来说,已经找不到了,我们就管它叫僵尸数据,紧接着,一个宽限期就正式拉开了帷幕,注意,任何在指针切换之前已经开始读取的哪些老读者,它们手里还攥着指向那个僵尸数据的引用,所以,它们可以继续慢悠悠的完成自己的工作,不受任何影响。在这个宽限期里面,RCU就只做一件事,等,等什么呢,它会静静的观察,直到它确认,系统里的每一个CPU都至少发生过一次静止状态。这就像是在确认,一栋大楼里的每个办公室灯都已经关过了,人肯定回家了,一旦RCU确认所有的CPU都已经打卡下班了,那么宽限期就正式结束了。在这一刻,RCU就能百分百确定,绝对没有任何人还在用那个旧的僵尸数据了。宽限期不是“主动等时间”,而是“被动等事件”, 等所有CPU都进入宽限期这一事件。最后,最关键的一点来了,当事件到达,现在释放内存,是绝对安全的。
这个等不是傻等,而是事先加入了唤醒策略的:
-
写者调用
synchronize_rcu()时,把自己加入等待队列然后睡眠 -
内核在每次时钟中断、进程切换、退出用户态等时机,检查当前 CPU 是否经历了 QS
-
当所有 CPU 都上报过 QS 后,唤醒等待的写者
所以宽限期的长短,取决于最慢的那个 CPU 什么时候发生一次调度/中断。如果一个 CPU 在死循环且禁止抢占,则它就不会报告静止状态, 宽限期会一直被这个慢读者拖住,宽限期就永远不会结束,这也是为什么 RCU 读临界区必须短暂且不能睡眠。
RCU 的核心就是:用一个“等所有人都离开”的宽限期,换取“读者永远不用等”的极致性能。
这个设计特别巧妙,但是作为LINUX内核开发者该怎么用它呢?我们看以下RCU的核心API,就这么几个,分工特别明确:
- rcu_read_lock()/rcu_read_unlock():标记读侧临界区的开始/结束。
- rcu_dereference():安全地获取一个用于读取的指针。
- rcu_assign_pointer():发布一个指针的新版本。
- synchronize_rcu():启动,阻塞并等待一个宽限期的结束。
- call_rcu/kfree_rcu:安全地延迟执行回调释放内存,直到所有可能正在访问该内存的读侧临界区完成。
用RCU和用熟悉的锁有什么区别呢?下图作了一个对比,这样就一目了然,左边是传统的互斥锁,你看它在释放锁之后,立刻就回收内存,非常直接,再看右边的RCU版本,他再移除数据和内存回收之间,调用synchronize_rcu默默的启动插入了一个完整的宽限期,启动并等待这个宽限期走完,实现了RCU的魔法。

在实现中,RCU 采用分层组合树(Combining Tree)架构,将 per-CPU 状态通过 rcu_node 树逐级汇聚,实现 O(log N) 的宽限期检测可扩展性。RCU 子系统由四个核心数据结构构成:rcu_head(回调头)、rcu_data(per-CPU 状态)、rcu_node(组合树节点)、rcu_state(全局状态)。rcu_node 组合树是 RCU 可扩展性的核心。叶节点覆盖最多 16 个 CPU (RCU_FANOUT_LEAF),非叶节点扇出为 64 (RCU_FANOUT)。QS 报告从叶向根逐级传播,所有CPU都完成静止状态后,根节点设置当前的宽限期结束,就可以安全调用当前宽限期开始前的释放回调清理内存了。

RCU的应用场景
说到这里,你可能会觉得,RCU简直是神奇,是不是以后就可以把锁全都仍了,改用RCU了。还真不是,RCU有适合它自己的应用场景。下面这句话完美的概括了RCU的理想国,什么时候用RCU最爽呢,就是当你绝大部分时间都是在读数据,更新操作非常非常少,并且你的读者能够容忍自己偶尔读到那么一点点旧数据的时候,这个时候,RCU就能发挥出它最大的威力。
"RCU的天堂是:更新几乎从不发生,而且你不在乎数据是否稍微有点儿不一致。"
所以总结一下适用RCU的条件:
- 你的功能实现读是占绝对主导地位的。
- 你对读的性能要求极高。
- 你能接受一点点的数据不一致,读到老数据。
- 你对更新的速度没那么敏感。
全局唯一的GP线程
全局唯一的GP线程(rcu_gp_kthread)为RCU带来了多个核心便利,使GP状态转换是天然串行化,不需要对GP状态本身加锁,便利还包括:
-
逐node持锁设置qsmask:每个leaf node的qsmask设置在
rnp->lock保护下原子完成,与该CPU上的QS报告和读者入队互斥 -
非PREEMPT_RCU:读者不可抢占,schedule就是QS。qsmask设置后的新读者在其时间片内完成或跨到下一个GP,不阻塞当前GP
-
GP初始化串行性:
rcu_gp_cleanup()完全完成后才开始rcu_gp_init(),确保不存在"上一个GP还没完全结束,下一个GP就开始"的竞争 -
内存屏障:
rcu_seq_start/end()中的smp_mb()和raw_spin_lock/unlock的隐式屏障保证操作的全局可见性顺序

RCU 语义易混淆点
当没有旧数据需要延迟回收时,例如添加新元素时,不需要 调用synchronize_rcu()
或 call_rcu(),下面是LINUX代码中的具体例子:

RCU 的静止状态只针对删除或替换操作,因为这两种操作会产生一个“旧的、可能仍被读者访问”的副本,而添加新元素:
-
你分配一个新节点,填充数据
-
通过
rcu_assign_pointer()把它挂到共享数据结构上(比如链表尾部、哈希桶中) -
这个新节点在挂上去之前没有任何读者能看到它
-
挂上去之后,新读者才会看到它
整个过程没有产生任何需要等待读者离开的内存块。因此不需要等待宽限期,也就不需要 synchronize_rcu 或 call_rcu。总结一下:

总结
所以,回到本文开始的那个问题,RCU最大的秘密,其实不是其名称代表的读,复制,更新的发布流程,那只是个幌子,它背后真正的魔法,是那个设计的及其优雅,效率极高,并且对读者几乎零干扰的延迟回收机制。
2981

被折叠的 条评论
为什么被折叠?



