2. 读写锁(RW Lock)原理
读写锁是内核中解决“读多写少”场景的核心同步原语。其设计哲学在于:允许多个读者同时进入临界区,但写者必须独占访问。本章深入剖析读写锁的设计哲学、公平性策略以及自旋与睡眠两种实现路径。
2.1 读写锁的设计哲学
读写锁的核心思想来源于对现实世界“读操作不修改数据”这一事实的观察。当多个线程同时读取共享数据时,它们之间不会产生竞态条件;只有写操作才会破坏数据一致性。因此,读写锁提供了两种不同的锁模式:
- 读模式(共享锁):任意数量的读者可以同时持有读锁。
- 写模式(独占锁):只有一个写者可以持有写锁,且写锁与所有读锁互斥。
设计哲学要点:读写锁通过分离“读”与“写”的语义,在保证数据一致性的前提下,最大化读操作的并发度。这是对传统互斥锁“一刀切”独占模式的重大改进。
下图展示了读写锁的基本状态转换与并发模型:
2.2 读者与写者的公平性
公平性是读写锁设计中最为棘手的挑战。不同的公平性策略会直接影响系统的吞吐量和延迟分布。内核中常见的公平性策略包括:
| 策略 | 描述 | 优点 | 缺点 |
|---|---|---|---|
| 读者优先 | 只要存在至少一个读者持有锁,后续读者可直接进入;写者必须等待所有读者释放。 | 读操作延迟低,读密集型场景吞吐量高。 | 写者可能被无限期阻塞(写者饥饿)。 |
| 写者优先 | 一旦有写者等待,后续读者必须排队等待,直到写者完成。 | 写者延迟有上限,避免写者饥饿。 | 读操作延迟增加,读并发度下降。 |
| 公平排队 | 按照请求到达顺序排队,读者和写者依次获得锁。 | 严格公平,无饥饿。 | 实现复杂,可能降低整体吞吐量。 |
内核实践注意:Linux 内核中的
rwlock_t 默认采用读者优先策略。这意味着在极端情况下,持续不断的读者流可能导致写者永远无法获得锁。对于需要写者及时响应的场景(如实时系统),应谨慎使用。
下图对比了读者优先与写者优先两种策略下的锁获取流程:
2.3 自旋与睡眠
读写锁的实现方式决定了等待线程的行为模式。内核提供了两种主要的实现变体:自旋读写锁和睡眠读写锁。
2.3.1 自旋读写锁(rwlock_t)
rwlock_t 是 Linux 内核中最基础的读写锁实现。当锁不可用时,等待的读者或写者会在 CPU 上忙等(自旋),不断检查锁状态。其核心 API 如下:
// 定义并初始化读写锁
rwlock_t my_rwlock = __RW_LOCK_UNLOCKED(my_rwlock);
// 读者路径
read_lock(&my_rwlock); // 获取读锁(自旋等待)
// 读取共享数据...
read_unlock(&my_rwlock); // 释放读锁
// 写者路径
write_lock(&my_rwlock); // 获取写锁(自旋等待)
// 修改共享数据...
write_unlock(&my_rwlock);// 释放写锁
自旋锁适用场景:临界区执行时间极短(通常只有几条指令),且持有锁的线程不会被调度出去。自旋锁避免了线程切换的开销,但会浪费 CPU 周期。
2.3.2 睡眠读写锁(rwsem)
当临界区执行时间较长或可能阻塞时,自旋等待会严重浪费 CPU 资源。此时应使用信号量风格的读写锁——struct rw_semaphore。等待的线程会睡眠并让出 CPU,直到锁可用时被唤醒。
// 定义并初始化读写信号量
struct rw_semaphore my_rwsem;
init_rwsem(&my_rwsem);
// 读者路径
down_read(&my_rwsem); // 获取读锁(可能睡眠)
// 读取共享数据...
up_read(&my_rwsem); // 释放读锁
// 写者路径
down_write(&my_rwsem); // 获取写锁(可能睡眠)
// 修改共享数据...
up_write(&my_rwsem); // 释放写锁
关键区别:自旋读写锁(
rwlock_t)不能在持有锁时睡眠或调用可能睡眠的函数;而睡眠读写锁(rwsem)允许持有者在临界区内阻塞。错误地在自旋锁临界区内睡眠会导致内核死锁。
2.3.3 自旋 vs 睡眠:选择决策树
下表总结了两种实现的核心差异,帮助开发者在实际场景中做出正确选择:
| 特性 | 自旋读写锁(rwlock_t) | 睡眠读写锁(rwsem) |
|---|---|---|
| 等待行为 | CPU 忙等(自旋) | 线程睡眠,让出 CPU |
| 临界区时长 | 极短(微秒级) | 较长(毫秒级或更长) |
| 上下文要求 | 可在中断上下文使用 | 只能在进程上下文使用 |
| CPU 开销 | 等待时占用 CPU | 等待时释放 CPU |
| 公平性 | 读者优先(默认) | 写者优先(默认) |
| 适用场景 | 快速路径、频繁访问的短临界区 | 文件系统、块设备等可能阻塞的路径 |
下图展示了自旋与睡眠两种等待模式在锁竞争时的行为差异:
2.4 读写锁的局限性
尽管读写锁在“读多写少”场景下表现出色,但它并非万能。在实际使用中,开发者需要警惕以下问题:
- 读者优先导致的写者饥饿:在高并发读场景下,写者可能长时间无法获得锁。
- 缓存行 bouncing:多个读者频繁修改锁的计数器,导致缓存一致性协议产生大量开销。
- 不可递归:标准的读写锁不支持递归获取,同一线程重复获取读锁可能导致死锁。
- 优先级反转:低优先级读者持有锁时,高优先级写者可能被阻塞。