在 Linux 内核数十年的演进史中,RCU(Read-Copy-Update,读-复制-更新) 绝对是最具传奇色彩的同步机制之一。自上世纪 90 年代被引入以来,RCU 以其近乎零开销的读取性能,撑起了高性能服务器和海量并发处理的半壁江山。
然而,没有任何一种并发原语是万能的。随着现代数据中心硬件架构的演进以及对内存使用率要求的日益苛刻,RCU 机制中一个隐含多年的痛点——“清理不再使用的内核对象时带来显著的延迟”——逐渐成为了高频更新场景下的性能瓶颈。为了应对这一挑战,内核社区开始将目光投向了另一个无锁同步利器:Hazard Pointers。
本文将从 RCU 的历史沿革、实现原理、使用范式、痛点分析,一路延伸至 Hazard Pointer 对这一难题的破局。
一、 RCU 的前世今生
1. 历史沿革:从排他锁到极致读写分离
在早期单核或少核时代,内核多使用简单的自旋锁(Spinlock)或读写锁(RWLock)。读写锁虽然允许多个读者并发,但读锁本身依然需要修改共享的引用计数或锁状态。在现代多核 CPU 架构下,多核频繁写同一个锁变量会导致严重的 Cache Line 撞击(Cache Line Bouncing),性能随着 CPU 核心数的增加呈断崖式下跌。
为了解决这一问题,Paul E. McKenney 等人在 1990 年代深入研究并提出了 RCU 机制。在 2002 年(Linux 2.5.43 内核版本),RCU 被正式合入 Linux 内核主线。它的核心思想极其惊艳:将保护数据结构的重任,从“阻止读者访问”转移为“延迟旧数据的释放”。
2. 未来展望:多核扩展性与实时性的持续博弈
当今的 Linux 内核拥有成百上千个 CPU 核心,RCU 依然是内核中最不可或缺的底层基础设施(如 VFS 文件路径查找、网络协议栈路由表等)。RCU 的未来演进主要围绕两个方向:
-
更低的延迟与抢占支持: 在 RT(Real-Time)实时内核中,进一步缩短 RCU 的等待时间,减少对系统响应时间的扰动(如 Tree RCU / Expeditious RCU 的持续优化)。
-
场景化互补: 承认 RCU 在高频写操作和内存敏感场景下的局限性,与其他无锁机制(如 Hazard Pointers、Lock-free Queue)协同工作,形成更完善的并发原语矩阵。
二、 RCU 的工作原理与使用范式
RCU 的核心哲学是 “读端极致轻量,写端承担所有复杂度”。
写端修改指针
│
▼
写端:[旧数据] ───┼───> [新数据]
│
读端 A: [───────── 读旧数据 ─────────] (进入临界区)
读端 B: [────── 读新数据 ──────]
│ │
▼ ▼
写端开始等待 Grace Period (优雅周期) 结束
Grace Period ───────────> 彻底安全,kfree(旧数据)
RCU 将对共享数据的更新拆分为三个阶段:
-
Read(读取): 读者无需获取任何排他锁,直接读取指针并访问数据。
-
Copy(复制与更新): 写者不直接修改原数据,而是先复制一份副本,在副本上进行修改,然后通过原子操作将全局指针指向新数据。
-
Update(延时清理): 旧数据不能立刻释放,写者必须等待所有正在访问旧数据的读者全部离开,这段等待时间被称为 Grace Period(优雅周期)。当 Grace Period 结束后,写者才能安全地回收旧数据内存。
如何使用 RCU?
1. 读端操作(Read-side)
读端的代码极其简洁,仅需将读取逻辑包裹在 RCU 读临界区内:
struct my_data *ptr;
rcu_read_lock(); /* 1. 进入 RCU 读临界区(禁止内核抢占) */
/* 2. 安全地解引用 RCU 保护的指针 */
ptr = rcu_dereference(global_pointer);
if (ptr) {
/* 访问 ptr 内部的数据,此时保证 ptr 不会被释放 */
pr_info("Value: %d\n", ptr->value);
}
rcu_read_unlock(); /* 3. 退出 RCU 读临界区 */
注意: 在
rcu_read_lock()和rcu_read_unlock()之间,代码不能发生睡眠或阻塞。
2. 写端操作(Write-side)
写端负责替换指针并等待清理旧对象:
struct my_data *new_node, *old_node;
/* 1. 分配并初始化新数据 */
new_node = kmalloc(sizeof(*new_node), GFP_KERNEL);
new_node->value = 42;
/* 2. 获取写锁(RCU 仅保护读写并发,写与写之间仍需互斥锁) */
spin_lock(&my_lock);
old_node = global_pointer;
/* 3. 原子地将全局指针指向新节点(内部包含内存屏障) */
rcu_assign_pointer(global_pointer, new_node);
spin_unlock(&my_lock);
/* 4. 等待 Grace Period 结束,确保没有读端还在访问 old_node */
synchronize_rcu(); /* 同步等待;或使用 call_rcu() 异步回调 */
/* 5. 安全地释放旧内存 */
kfree(old_node);
三、 RCU 的阿喀琉斯之踵:对象清理的显著延迟
尽管 RCU 赋予了读端近乎零开销的极致性能,但它也付出了沉重的代价——清理不再使用的内核对象时带来显著的延迟。
为什么会有这种延迟?
因为 RCU 的读端太“懒”了:读端在访问数据时,根本不会向系统注册“我正在访问对象 X”,它仅仅是向 CPU 声明“我进入了 RCU 临界区”。
由于写端无法知道究竟哪个 CPU 在读哪个具体对象,为了确保万无一失,写端在释放旧对象之前,必须等待系统中所有的 CPU 都完成至少一次上下文切换(Context Switch)。
延迟带来的严重后果
-
内存暴涨(Memory Bloat):
在网络路由表高频更新、连接跟踪表(Conntrack)剧烈波动等写密集的场景下,系统单位时间内会产生海量的旧对象。由于每一个旧对象都要等待几毫秒甚至数十毫秒的 Grace Period 才能被真正
kfree,这些“逻辑上已被删除”的垃圾对象会大量积压在内存中,导致内核内存占用骤增,甚至诱发 OOM(Out of Memory)。 -
实时性受损:
synchronize_rcu()的同步等待毫秒级延迟,对于要求微秒级响应的实时内核或高频交易系统来说,是无法承受的性能阻碍。
四、 破局者:Hazard Pointer
为了解决 RCU“对象清理延迟高、内存占用暴涨”这一致命缺陷,内核社区目前正在积极评估由 Mathieu Desnoyers 和 Paul McKenney 提出的 Hazard Pointer 实现方案。
1. 核心思路的转变:从“粗粒度等待”到“精准登记”
Hazard Pointer 的基本思想十分直接:让读端在读取对象时,显式地在一个轻量级的 Per-CPU 数组(槽位 Slot)中“登记”自己正在访问的对象内存地址。
-
RCU 模式: 读端说:“我在看书,但别问我是哪一本。” ➔ 写端只能等全书店所有人离开才能清理旧书。
-
Hazard Pointer 模式: 读端说:“我正在看《0x1234》这本书。” ➔ 写端想销毁《0x1234》时,只需要检索登记表:若没人登记《0x1234》,当场即可释放内存,无需任何 Grace Period 等待!
2. API 与具体实现
读端 API(获取与释放)
使用 Hazard Pointer 时,读端在栈上分配一个上下文 struct hazptr_ctx:
struct hazptr_ctx ctx;
void *obj;
/* 获取指针 protection */
obj = hazptr_acquire(&ctx, &resource_address);
if (obj) {
/* 安全地访问 obj */
}
/* 使用完毕,立即释放防护 */
hazptr_release(&ctx, obj);
写端 API(同步与释放)
写端替换指针后,调用同步函数:
/* 替换指针后调用 */
hazptr_synchronize(old_address);
/* 扫描 Per-CPU 槽位,确认无人在用 old_address 后立即返回 */
kfree(old_address);
关键的并发设计与优化
为了在保证精准保护的同时不牺牲性能,Hazard Pointer 在内核实现中引入了极其精妙的设计:
-
三状态防乱序(HAZPTR_WILDCARD):
在
hazptr_acquire()中,为了防止“先读取指针,再写入槽位”期间写端刚好进行检查的内存乱序问题,槽位引入了HAZPTR_WILDCARD(0x1UL)中间状态。写端一旦看到槽位为 Wildcard,便会视同“正在被当前寻靶的指针使用”而主动等待,从而彻底消除竞态。 -
Per-CPU 4 槽位与溢出链表(Overflow List):
每个 CPU 硬件上限额 4 个快速槽位。当调用链较深、槽位耗尽时,上下文
struct hazptr_ctx内部的备用槽位会被激活,并链接到 Per-CPU 的溢出链表中,保证了 API 的绝对可靠性。 -
上下文切换与抢占支持(hazptr_detach):
相比 RCU 读临界区严禁抢占,Hazard Pointer 原生支持抢占。当发生上下文切换时,调度器回调会将该 CPU 槽位中的指针自动转移到溢出链表中,将极速的 Per-CPU 槽位腾给下一个运行线程,极大提升了系统的整体并发弹性。
五、 总结
| 特性 | RCU (Read-Copy-Update) | Hazard Pointer (险象指针) |
| 读端开销 | 极致低(无内存写入,仅禁止抢占/屏障) | 轻微开销(需写 Per-CPU 槽位及内存屏障) |
| 对象清理延迟 | 高(毫秒级 Grace Period) | 极低(微秒/纳秒级,扫描完槽位立即释放) |
| 内存占用 | 高频更新时易发生内存暴涨 | 随用随释放,内存利用率极高 |
| 抢占支持 | 传统 RCU 临界区禁止抢占 | 原生支持抢占 |
| 最佳适用场景 | 读极多、写极少(如文件系统、路由表) | 高频更新、内存敏感、大对象频繁释放 |
RCU 与 Hazard Pointer 绝非简单的替代关系,而是无锁同步技术在不同维度上的权衡与互补。RCU 用写端的清理延迟换取了读端近乎完美的零开销;而 Hazard Pointer 则通过读端微小的登记代价,完美破局了 RCU 在清理延迟和内存暴涨上的顽疾。随着 Hazard Pointer 补丁集的不断完善与主线合入,Linux 内核在应对极致高并发与高频更新的场景时,必将拥有一把更加锋利的利刃。


398

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



