4. 读写锁实现机制:基于自旋锁的实现、读者计数、写者等待队列
读写锁(rwlock)是内核中一种细粒度的同步原语,它允许多个读者同时持有锁,但写者必须独占访问。本章深入剖析 Linux 内核中读写锁的经典实现,重点分析其基于自旋锁的底层构建、读者计数的原子管理以及写者等待队列的调度逻辑。
4.1 读写锁的核心数据结构
在 Linux 内核中,读写锁的核心数据结构定义在 <linux/rwlock.h> 中。其本质是一个 arch_spinlock_t 类型的字段,通过编码不同的位状态来区分读者和写者。
| 字段 | 类型 | 说明 |
|---|---|---|
lock |
arch_spinlock_t |
底层自旋锁,用于保护读写锁内部状态的原子操作 |
counter |
u32 |
32 位计数器,高位表示写者锁定标志,低位表示读者计数 |
wait_lock |
spinlock_t |
写者等待队列的自旋锁保护 |
wait_list |
list_head |
写者等待队列链表头 |
关键设计点:读写锁通过一个 32 位计数器同时编码读者数量和写者标志,避免了额外的内存开销。写者标志通常使用最高位(bit 31),读者计数使用低 31 位。
4.2 基于自旋锁的底层实现
读写锁的底层操作依赖于自旋锁来保证对 counter 字段的原子修改。以下是一个典型的读写锁获取与释放的伪代码实现:
// 读者获取锁
void read_lock(rwlock_t *lock) {
unsigned long flags;
spin_lock_irqsave(&lock->wait_lock, flags);
// 检查是否有写者持有锁或写者等待
if (lock->counter & WRITER_MASK) {
// 有写者,读者需要自旋等待
do {
spin_unlock_irqrestore(&lock->wait_lock, flags);
while (lock->counter & WRITER_MASK)
cpu_relax();
spin_lock_irqsave(&lock->wait_lock, flags);
} while (lock->counter & WRITER_MASK);
}
// 增加读者计数
lock->counter++;
spin_unlock_irqrestore(&lock->wait_lock, flags);
}
// 写者获取锁
void write_lock(rwlock_t *lock) {
unsigned long flags;
spin_lock_irqsave(&lock->wait_lock, flags);
// 等待所有读者释放,且没有其他写者
while (lock->counter != 0) {
// 将当前写者加入等待队列
list_add_tail(¤t->write_entry, &lock->wait_list);
spin_unlock_irqrestore(&lock->wait_lock, flags);
schedule(); // 让出 CPU
spin_lock_irqsave(&lock->wait_lock, flags);
list_del(¤t->write_entry);
}
// 设置写者标志
lock->counter = WRITER_MASK;
spin_unlock_irqrestore(&lock->wait_lock, flags);
}
性能陷阱:读者在自旋等待期间会持续占用 CPU,如果写者持有锁时间较长,会导致大量读者空转。现代内核引入了
qrwlock(排队读写锁)来缓解这一问题,通过 FIFO 队列避免读者饿死写者。
4.3 读者计数机制
读者计数是读写锁实现的核心。内核使用原子操作来管理计数器的增减,确保并发读者之间的正确性。
- 计数上限:低 31 位最多支持 2^31 - 1 个并发读者,实际场景中远小于此值。
- 溢出保护:当读者计数达到最大值时,新的读者获取锁会失败或阻塞,防止计数器回绕。
- 读者释放:释放锁时原子递减计数器,并检查是否有写者在等待队列中。
读者计数状态转换:
- 空闲状态:counter == 0
- 读者持有:counter > 0 且 WRITER_FLAG 未设置
- 写者持有:counter == WRITER_MASK
- 写者等待:counter 不为 0,且等待队列非空
4.4 写者等待队列
当写者无法立即获取锁时,它会被加入一个等待队列。这个队列保证了写者之间的公平性,并防止写者被读者无限期饿死。
| 队列操作 | 触发条件 | 行为 |
|---|---|---|
| 入队 | 写者获取锁时发现 counter != 0 | 将当前进程的等待节点添加到 wait_list 尾部 |
| 出队 | 写者成功获取锁或放弃等待 | 从 wait_list 中删除节点 |
| 唤醒 | 最后一个读者释放锁 | 唤醒等待队列头部的写者 |
| 超时 | 写者等待超过指定时间 | 从队列中移除并返回错误 |
4.5 读写锁状态机与流程
下面的 SVG 图展示了读写锁从空闲到读者/写者持有,再到写者等待的完整状态转换流程。
4.6 读者与写者的公平性权衡
经典的读写锁实现存在一个显著的公平性问题:写者可能被读者饿死。当读者持续持有锁时,写者将无限期等待。现代内核通过以下机制改善公平性:
- 写者优先策略:当有写者在等待队列中时,新来的读者将被阻塞,直到写者完成操作。
- 排队读写锁 (qrwlock):使用 FIFO 队列管理所有等待者,读者和写者按到达顺序获取锁。
- 自适应自旋:写者在自旋等待时,根据读者数量动态调整自旋次数,避免过度空转。
性能对比总结:
| 场景 | 经典 rwlock | qrwlock |
|---|---|---|
| 读者密集 | 高吞吐,低延迟 | 略低于经典实现 |
| 写者密集 | 写者可能饿死 | 公平,写者延迟可控 |
| 混合负载 | 公平性差 | 公平性好 |
| 内存开销 | 低 | 中等(需要队列节点) |
4.7 实现中的关键注意事项
- 中断上下文:在中断处理程序中使用读写锁时,必须使用
read_lock_irqsave()/write_lock_irqsave()变体,以避免死锁。 - 递归读者:同一个读者不能递归获取锁,否则会导致计数器错误或死锁。
- 写者优先级反转:当低优先级写者等待高优先级读者时,可能发生优先级反转,需结合 RT 调度策略处理。
- 内存屏障:读写锁的实现隐式包含了必要的内存屏障,确保读者看到的数据一致性。
常见错误:在读者上下文中调用
write_lock() 会导致死锁,因为写者会自旋等待读者释放,而读者自己就是读者。务必区分读/写路径。