RCU 非常成功
在现代 Linux 中广泛用于加速对共享内核数据的读取访问。
MIT 6.1810 · 第 20 课 · rtm
多核时代,内核性能的关键在于减少共享数据的竞争。RCU(Read-Copy Update)是 Linux 内核中广泛使用的无锁读取技术。
摩尔定律在 ~2000 年结束:单核性能停止提升。CPU 转向多核设计。内核必须利用并行性才能获得良好性能。
// 典型内核数据结构
struct list {
struct elem *head;
};
struct elem {
int n;
int refcnt;
struct elem *next;
};
// 大多数使用是读取(扫描链表)
// 偶尔写入(添加元素、删除元素、修改元素)
// 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
核心在 L2 缓存中未命中,必须获取缓存行——数十个周期。
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 的问题:它们做昂贵的写入。
核心洞察:能否有纯只读的共享读写数据读取?即避免甚至锁定所需的写入?
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 倍以上!
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 最适合单次指针写入提交的数据结构,例如链表和树。
读者不会看到 E1->next 指针的旧位和新位的混合。这对所有已知的 64 位 CPU 的对齐加载/存储都成立。
// 写者必须在步骤 6 之前有屏障
// 读者可能需要在 Rx = E1->next 和 Rx 的解引用之间有屏障
// rcu_dereference() 确保这一点
问题:写者 free() 删除的链表元素会发生什么?
写者必须等待,但假设写入很少。
rcu_read_lock()
rcu_read_unlock()
rcu_dereference(p)
synchronize_rcu() // 等待宽限期
call_rcu(fn, x) // 延迟执行 fn(x)
rcu_assign_pointer(pa, p) // 带屏障的指针赋值
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 内使用 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 | 读写锁 |
|---|---|---|
| 读取开销 | 几乎为零 | 可能数百/数千周期 |
| 写者开销 | 可能很长(synchronize_rcu) | 中等 |
| 并发读写 | 可以! | 不可以 |
| 死锁风险 | 无 | 有 |
NMI(不可屏蔽中断)用于 CPU 分析、关键硬件错误、看门狗定时器。
问题:如果某个核心在 NMI 代码中时调用 unregister_nmi_handler()?
解决方案:
亮点:NMI 不能禁用,所以 spinlock 不能阻止 NMI。RCU 在这里不仅更快,而且在锁无法工作的地方也能工作。
IP 数据包头部可以包含选项。发送代码(udp_sendmsg())不加锁地复制套接字选项。选项读取远多于写入 → 好的 RCU 候选。
重新设计以避免共享
以某种方式分区数据,每个分区加锁
在现代 Linux 中广泛用于加速对共享内核数据的读取访问。
读者几乎零开销,允许读者与写者并行执行。
延迟释放对象直到宽限期结束——类似垃圾回收。
单次指针写入使所有更改同时可见,读者永远不会看到部分更新。
Q1:为什么读写锁在多核上性能差?
Q2:RCU 的"提交写入"是什么意思?
Q3:RCU 的宽限期是如何工作的?