跳转到内容

RCU 2001 — 让"读"的代价归零的并发数据结构

是什么

RCU(Read-Copy Update,读-复制-更新)是一套让”读取”几乎零开销,把代价全推给”修改”和”回收” 的并发同步方法。日常类比:图书馆里换书。

  • 普通做法(读写锁):每个读者读书前要先签到、读完签退;改书的人要等所有人都不在才能换。读多的时候,签到本本身成了瓶颈。
  • RCU 做法:旧版本书继续放在书架,读者直接取走读,不签到;要换新版本时,把新书放上去、改一下”现在指最新这本”的指针;旧书等”所有当时正在读的人都看完了”再扔掉。

读者完全不需要原子操作、不需要内存屏障、不需要写共享缓存行——这是 RCU 的杀手锏。

为什么重要

不理解 RCU,下面这些事说不清楚:

  • 为什么 Linux 内核的目录缓存(dcache)、路由表、网络协议栈在多核机器上能线性扩展
  • 为什么有的并发数据结构号称”读侧零开销”,是真的零,还是话术
  • 为什么内核里到处是 rcu_read_lock() / rcu_dereference(),但它们在常见配置下展开成空语句
  • 为什么 io_uring、folio、cgroup v2 这些近 10 年的内核新特性几乎都用 RCU

核心要点

RCU 的核心拆成 三件事

  1. 读侧不加锁:读者读取一个被 RCU 保护的指针时,只做一次普通的内存读,不写任何共享变量。所以多核 CPU 不需要互相通信,读侧延迟就是一次缓存命中。

  2. 更新走”复制 + 替换”:要修改一个节点,就分配一个新节点、把要改的字段填好、然后原子地替换指针。老节点暂时还在内存里,因为可能有读者正在用它。

  3. 延迟回收等”宽限期”:什么时候能安全释放老节点?答:等所有”在你替换指针之前已经开始的读”都结束。这段时间叫宽限期(grace period)。Linux 用一个聪明的判定——只要每个 CPU 都至少经过一次”上下文切换 / 进入空闲 / 进入用户态”,就证明它身上没有任何 RCU 读临界区在跑了。

四个常用 API:

  • rcu_read_lock() / rcu_read_unlock() —— 标记读临界区。在禁抢占的内核里它们就是空操作
  • rcu_dereference(p) —— 读一个 RCU 保护的指针(编译屏障 + DEC Alpha 上的依赖屏障)。
  • rcu_assign_pointer(p, v) —— 发布新版本(写之前加 release 屏障)。
  • synchronize_rcu() / call_rcu() —— 写者等宽限期 / 注册回调延迟释放。

实践案例

案例 1:Linux 路由表查找

每次发包都要查路由表,频率极高。表本身偶尔被更新(路由协议、管理员命令)。

读侧(每秒数百万次):

rcu_read_lock();
fib = rcu_dereference(net->ipv4.fib_main);
result = fib_lookup(fib, &flow);
rcu_read_unlock();

写侧(极低频):

new_fib = build_new_fib(...);
old_fib = net->ipv4.fib_main;
rcu_assign_pointer(net->ipv4.fib_main, new_fib);
synchronize_rcu(); // 等所有正在用 old_fib 的读者结束
free_fib(old_fib);

读侧零原子操作。多核机器上转发性能几乎线性扩展。

案例 2:dcache 路径查找

/usr/local/bin/python 这条路径,每次进程访问文件都要解析。Linux 内核用 RCU 让目录缓存的查找走”乐观无锁路径”——叫做 RCU-walk:

  • 沿路径一层一层往下查 dentry,全程 rcu_read_lock() 保护,不写任何引用计数。
  • 走到最后一级前做一次 seqcount 校验:如果路径中途被改过(rename / unlink),校验失败就退回到经典的”带锁 + 引用计数” ref-walk。
  • 大多数情况下校验通过,于是一次 open() 系统调用涉及的几十次 dentry 查找全是无锁的。

这是 RCU 与 seqcount 配合的经典案例。

案例 3:用户态 RCU(liburcu)

不是只能在内核用。Userspace RCU 库(McKenney 等人后续做的)让用户态程序也能享受同样模式——典型用法是 LTTng 跟踪器、QEMU 的内存映射表、以及一些高频读的内存数据库。用户态版本的难点在于”宽限期判定”——内核可以借调度上下文切换,用户态得自己设计一套(注册线程 + 周期性 quiescent state 上报)。

踩过的坑

  1. 读侧”零开销”是有前提的:读者必须保证临界区内不睡眠、不被迁移到别的 CPU(在经典 RCU 下)。如果你需要在读临界区内睡眠,得用 SRCU(Sleepable RCU),它换了一种宽限期判定方式。

  2. 写侧不便宜synchronize_rcu() 在重负载机器上可能等几十毫秒。所以高频更新场景要么用 call_rcu() 异步回收,要么干脆别用 RCU。

  3. 读到的可能是旧版本:RCU 不是事务。你可能正在读 v1,写者已经发布了 v2,你的读完成时拿到的还是 v1 的数据——这是设计如此,不是 bug。“最终一致”,不是”立刻一致”。

  4. DEC Alpha 的依赖排序例外:99.9% 的 CPU 上,“先读指针,再读指针指向的内容”自带顺序。Alpha 不保证。这就是 rcu_dereference() 在 Alpha 上要插入显式屏障、其他平台是空操作的原因。

  5. 写者之间还得自己加锁:RCU 只解决”读 vs 写”的同步。多个写者并发改同一个数据结构,必须自己上互斥锁/自旋锁。

  6. 不是所有数据结构都能 RCU 化:要求节点一旦发布就不再原地修改(不可变),所有更新都走”复制新节点 + 替换指针”。如果你的节点字段经常被原地写入(计数器、统计量),RCU 派不上用场。

适用 vs 不适用场景

适用

  • 读远多于写(10:1 以上常见,100:1 更典型)
  • 读侧延迟敏感(路由表、dcache、SELinux 策略查找)
  • 数据结构是”指针 + 不可变节点”形态(链表、树、哈希表桶)
  • 能容忍”短暂读到旧版本”

不适用

  • 读写比例接近 1:1 → 用读写锁或精细锁
  • 强一致性要求(金融、计数器精确值)→ 用原子计数 / seqlock
  • 数据结构频繁原地修改(数组、位图)→ RCU 的”复制”开销吃掉收益
  • 写侧延迟敏感 → 宽限期等待是硬伤

历史小故事(可跳过)

  • 1980 年代:IBM 的 VM/XA 系统里就有类似机制,叫”passive serialization”。
  • 1995 年:McKenney 在 Sequent 公司基于这个思路申请了美国专利(5,442,758),名字叫 Read-Copy Update。
  • 2001 年:McKenney 在 OLS(Ottawa Linux Symposium)把 RCU 介绍给 Linux 社区——就是这篇论文。
  • 2002 年:Linux 2.5.43 合入第一版 RCU 实现。
  • 2003-2010:经历多个变体——Classic RCU、Tree RCU、Preemptible RCU、SRCU 陆续合入,覆盖不同场景。
  • 此后 20 年:RCU 成为 Linux 内核扩展性的支柱之一。McKenney 2017 年从 IBM 退休,但仍在维护 RCU 子系统。今天内核里有上万处 RCU 用法。

学到什么

  1. 不对称同步:当一边的频率远高于另一边时,把代价全压给低频那一边能换巨大收益。RCU 是这个思想最成功的工程化。
  2. 延迟回收 = 用空间换时间:旧版本暂时不删,换来读者完全不需要协调。这跟 GC、MVCC、shadow paging 是同一个家族的思路。
  3. 宽限期的精妙:不维护”现在有谁在读”的列表(那会引入写共享),而是用”每个 CPU 都过一次静止点”做隐式证明。这把分布式问题变成了本地观察。
  4. API 设计的克制:四个原语,覆盖 99% 用法。复杂度藏在实现里,不暴露给调用者。

延伸阅读

  • 论文 PDF:McKenney & Slingwine, OLS 2001(13 页,相对易读)
  • 综述 + 教程:McKenney 的 perfbook(《Is Parallel Programming Hard?》第 9 章专讲 RCU)
  • LWN 系列文章:What is RCU, Fundamentally?(三部曲,最经典的入门)
  • 内核源码:kernel/rcu/tree.c(Tree RCU 主实现)
  • linux-kernel —— RCU 的最大宿主
  • mvcc —— 同样的”延迟回收老版本”思想,用在数据库

关联

  • mvcc —— 数据库版本的”读不阻塞写”,思想同源
  • seqlock —— 读优先的另一条路,但读者要重试
  • lock-free-queues —— 同属无锁数据结构家族,但要 CAS
  • memory-barriers —— RCU 的正确性建立在内存模型之上
  • linux-kernel —— RCU 的主要落地

反向链接

(暂无反向链接)