MIT 6.1810 · 第 20 课 · rtm

多核扩展性与 RCU

多核时代,内核性能的关键在于减少共享数据的竞争。RCU(Read-Copy Update)是 Linux 内核中广泛使用的无锁读取技术。

授课教师:rtm (Robert T. Morris)

年份:2025 秋季

难度:困难

视频:Lecture 20 Notes

背景:摩尔定律的终结

摩尔定律在 ~2000 年结束:单核性能停止提升。CPU 转向多核设计。内核必须利用并行性才能获得良好性能。

谜题

示例:单向链表

// 典型内核数据结构
struct list {
  struct elem *head;
};

struct elem {
  int n;
  int refcnt;
  struct elem *next;
};

// 大多数使用是读取(扫描链表)
// 偶尔写入(添加元素、删除元素、修改元素)

读写锁 (Read-Write Lock)

// API
void rwlock_r_acquire(l);   // 读锁获取
void rwlock_r_release(l);   // 读锁释放
void rwlock_w_lock(l);      // 写锁获取
void rwlock_w_unlock(l);    // 写锁释放

// 语义:要么一个写者(无读者),要么多个读者(无写者)

实现

// rwlk->n 计数读者数
// CAS 原子更新 rwlk->n 为 n+1
// 如果 0x80000000 被设置,rwlk->n 计数写者

惊喜:读写锁很慢!

性能数据

// 4 核,rwlock
ncore 4 ltype rwlock
  10000000 list_search took 2.86s
  10000000 list_search took 2.89s
  10000000 list_search took 3.35s
  10000000 list_search took 3.36s

// 4 核,spinlock
ncore 4 ltype spinlock
  10000000 list_search took 1.02s
  10000000 list_search took 3.22s
  10000000 list_search took 3.27s
  10000000 list_search took 3.29s

// 即使没有写者,rwlock 也更慢!

时间花在哪里?

$ perf record -g ./clist 4 1
$ perf report
   59.26%  rwlock_r_acquire
   33.48%  rwlock_r_release
    5.40%  run_core
    3.01%  list_search

两个效应

效应 1:访问竞争的 rwlk->n 很昂贵

核心在 L2 缓存中未命中,必须获取缓存行——数十个周期。

效应 2:可扩展性差

N 个核心调用 r_acquire(l),没有写者
所有 N 个核心获取 rwlk->n,看到 rwlk->n == 0
执行 atomic_compare_exchange
只有一个 CAS 成功,其余失败并重试
atomic compare_exchange 一次执行一个——不并行
因此一个 CPU 获取读锁需要 O(N) 时间
所有 N 个核心的总时间 = O(N²)

核心问题

r_acquire()/r_release 的问题:它们做昂贵的写入。

核心洞察:能否有纯只读的共享读写数据读取?即避免甚至锁定所需的写入?

Read-Copy Update (RCU)

性能预览

ncore 4 ltype rcu
  10000000 list_search took 0.09s
  10000000 list_search took 0.09s
  10000000 list_search took 0.09s
  10000000 list_search took 0.10s

比 rwlock 快 30 倍以上!

RCU 是一组规则和模式

RCU 思想 1:写者不原地修改数据,而是准备新副本

Head  ->  E1  ->  E2  ->  E3  ->  nil

// 假设写者想修改 E1 的 n 和 refcnt
1. 加锁
2. e = alloc()
3. e->next = E1->next
4. e->n = 10
5. e->refcnt = 1
6. E1->next = e    // "提交写入"
7. 解锁

关键洞察

E1->next 的更新是"提交写入":

读者不会看到部分完成的更新(即使读者没有加锁)。

RCU 最佳适用场景

RCU 最适合单次指针写入提交的数据结构,例如链表和树。

要求 64 位写入和读取是原子的

读者不会看到 E1->next 指针的旧位和新位的混合。这对所有已知的 64 位 CPU 的对齐加载/存储都成立。

RCU 思想 2:读者和写者必须使用内存屏障

// 写者必须在步骤 6 之前有屏障
// 读者可能需要在 Rx = E1->next 和 Rx 的解引用之间有屏障
// rcu_dereference() 确保这一点

RCU 思想 3:宽限期 (Grace Period)

问题:写者 free() 删除的链表元素会发生什么?

宽限期规则

  1. 规则:读者不能在上下文切换期间持有指向 RCU 数据的指针
  2. 写者:延迟 free 直到所有 CPU 都已上下文切换

写者必须等待,但假设写入很少。

Linux RCU 接口

读者接口

rcu_read_lock()
rcu_read_unlock()
rcu_dereference(p)

写者接口

synchronize_rcu()          // 等待宽限期
call_rcu(fn, x)             // 延迟执行 fn(x)
rcu_assign_pointer(pa, p)   // 带屏障的指针赋值

使用 RCU 的链表读者

rcu_read_lock()
e = rcu_dereference(head)
while(e){
    查看 e->n, e->refcnt, ...
    e = rcu_dereference(e->next)
}
rcu_read_unlock()

rcu_read_lock/unlock 几乎不做任何事

尽管它们的名字叫"锁",但它们不加锁:

注意:代码只能在 rcu_read_lock/unlock 内使用 e!不能返回 e,不能上下文切换。

写者替换第一个链表元素

acquire(lock)
e = alloc()
e->n = ...
e->next = head->next
old = head
rcu_assign_pointer(&head, e)  // 提交写入
release(lock)

synchronize_rcu()             // 等待宽限期
free(old)                     // 现在可以安全释放

各函数的作用

函数作用
rcu_dereference(e)告诉编译器精确计算指针一次,然后加屏障确保 CPU 看到写入
synchronize_rcu()实现宽限期:延迟直到所有 CPU 都已上下文切换。可能花很长时间。
call_rcu(free, old)将 <free, old> 添加到列表,立即返回
rcu_assign_pointer(p, val)内存屏障 + *p = val

RCU vs 读写锁性能对比

方面RCU读写锁
读取开销 几乎为零 可能数百/数千周期
写者开销 可能很长(synchronize_rcu) 中等
并发读写 可以! 不可以
死锁风险 无 有

RCU 示例

示例 1:移除 NMI 处理程序

NMI(不可屏蔽中断)用于 CPU 分析、关键硬件错误、看门狗定时器。

问题:如果某个核心在 NMI 代码中时调用 unregister_nmi_handler()?

解决方案:

  1. 删除 NMI 处理程序链表项
  2. synchronize_rcu() 等待所有当前中断完成
  3. 释放 NMI 处理程序代码的内存

亮点:NMI 不能禁用,所以 spinlock 不能阻止 NMI。RCU 在这里不仅更快,而且在锁无法工作的地方也能工作。

示例 2:IP 选项

IP 数据包头部可以包含选项。发送代码(udp_sendmsg())不加锁地复制套接字选项。选项读取远多于写入 → 好的 RCU 候选。

RCU 的局限性

共享写入密集型数据怎么办?

最佳方案

重新设计以避免共享

次优方案

以某种方式分区数据,每个分区加锁

要点总结

RCU 非常成功

在现代 Linux 中广泛用于加速对共享内核数据的读取访问。

完全消除读者锁定成本

读者几乎零开销,允许读者与写者并行执行。

核心思想是 GC 式宽限期

延迟释放对象直到宽限期结束——类似垃圾回收。

提交写入是关键

单次指针写入使所有更改同时可见,读者永远不会看到部分更新。

测验

Q1:为什么读写锁在多核上性能差?

Q2:RCU 的"提交写入"是什么意思?

Q3:RCU 的宽限期是如何工作的?

延伸阅读