4. 读写锁API详解

读写锁(rwlock)是内核中解决“读多写少”场景下并发竞态的核心同步原语。与自旋锁不同,读写锁允许多个读者同时进入临界区,但写者必须独占访问。本章将深入剖析 rwlock_t 数据结构的内部布局,并逐一讲解 read_lock、write_lock、read_unlock、write_unlock 等 API 的语义、使用规范与底层实现机制。

4.1 rwlock_t 数据结构与初始化

rwlock_t 是读写锁的核心类型,定义在 <linux/rwlock_types.h> 中。其本质是一个基于 arch_spinlock_t 的封装,在大多数架构(如 x86、ARM64)上通过一个 32 位原子变量来维护锁状态。

字段/宏 类型 说明
raw_lock arch_spinlock_t 架构相关的底层自旋锁,用于保护读写锁内部计数器的原子操作
DEFINE_RWLOCK(name) 静态初始化宏 编译时定义并初始化一个全局读写锁,锁状态为未锁定
rwlock_init(lock) 动态初始化函数 运行时初始化动态分配的读写锁,将锁状态清零
关键点:读写锁的初始状态为“无读者、无写者”。在 x86 架构上,raw_lock 的低 24 位用于记录读者计数,高 8 位用于写者标志和等待位。这种位域设计使得读者加锁/解锁操作通常只需要一条原子指令(如 LOCK DEC 或 LOCK INC),性能极高。
// 静态初始化示例
DEFINE_RWLOCK(my_rwlock);

// 动态初始化示例
rwlock_t *lock;
lock = kmalloc(sizeof(rwlock_t), GFP_KERNEL);
rwlock_init(lock);

4.2 读者路径:read_lock / read_unlock

读者 API 允许多个执行上下文同时持有读锁。只要没有写者持有锁或正在等待,读者就可以无阻塞地进入临界区。

4.2.1 read_lock()

void read_lock(rwlock_t *lock); — 尝试获取读锁。如果当前没有写者持有锁,读者计数递增,调用者立即返回;否则,读者自旋等待写者释放锁。

4.2.2 read_unlock()

void read_unlock(rwlock_t *lock); — 释放读锁。原子地递减读者计数,并唤醒等待的写者(如果有)。

使用规范:读者临界区中禁止调用任何可能引起睡眠的函数(如 copy_from_user、kmalloc、mutex_lock)。因为读者持有锁时,写者会自旋等待,若读者睡眠,将导致写者长时间空转,严重降低系统吞吐量。
// 读者路径典型用法
read_lock(&my_rwlock);
/* 只读访问共享数据结构,例如遍历链表、读取计数器 */
if (list_empty(&head)) {
    pr_info("链表为空\n");
}
read_unlock(&my_rwlock);

4.3 写者路径:write_lock / write_unlock

写者 API 提供独占访问权限。写者必须等待所有读者释放锁,且后续读者必须等待写者完成。

4.3.1 write_lock()

void write_lock(rwlock_t *lock); — 尝试获取写锁。如果锁完全空闲(无读者、无写者),调用者获取锁并设置写者标志;否则,自旋等待直到锁被完全释放。

4.3.2 write_unlock()

void write_unlock(rwlock_t *lock); — 释放写锁。清除写者标志,并唤醒所有等待的读者和写者。

死锁风险:同一个执行上下文不能在持有读锁的情况下再申请写锁,也不能在持有写锁的情况下再次申请写锁(非递归)。读写锁不支持递归行为,违反此规则将导致死锁。
// 写者路径典型用法
write_lock(&my_rwlock);
/* 修改共享数据结构,例如插入/删除链表节点 */
list_add_tail(&new_node, &head);
write_unlock(&my_rwlock);

4.4 读写锁状态转换与竞争流程

下图展示了读写锁在读者、写者并发访问下的状态转换与等待关系。理解这个流程是进行性能调优的基础。

读写锁状态转换与竞争流程 锁空闲 读者=0, 写者=0 读者持有 读者≥1, 写者=0 写者持有 读者=0, 写者=1 读者等待 自旋等待写者释放 写者等待 自旋等待读者释放 read_lock() write_lock() read_unlock() (最后读者) write_unlock() write_lock() (有读者) read_lock() (有写者) read_unlock() (最后读者释放) write_unlock() (写者释放) 说明: 读者之间不互斥,可同时进入临界区;写者与所有读者、写者互斥。写者必须等待所有读者释放锁,后续读者也必须等待写者释放。

4.5 读者与写者 API 对比

API 语义 并发性 临界区限制 典型使用场景
read_lock() 获取读锁,允许多读者 高(多核并行读) 不可睡眠、不可调用写锁 遍历链表、读取统计计数、查询状态
read_unlock() 释放读锁 — 必须与 read_lock 配对 读者临界区出口
write_lock() 获取写锁,独占访问 低(串行化) 不可睡眠、不可递归 插入/删除节点、修改配置、更新指针
write_unlock() 释放写锁 — 必须与 write_lock 配对 写者临界区出口

4.6 中断上下文中的变体:_irq / _irqsave 后缀

读写锁 API 提供了中断安全变体,用于在中断处理程序或下半部(softirq/tasklet)中保护共享数据。

  • read_lock_irq() / read_unlock_irq():获取读锁的同时禁用本地 CPU 中断,释放时重新启用。
  • write_lock_irq() / write_unlock_irq():获取写锁的同时禁用本地 CPU 中断。
  • read_lock_irqsave() / read_unlock_irqrestore():保存当前中断状态,获取读锁;释放时恢复原中断状态。
  • write_lock_irqsave() / write_unlock_irqrestore():保存当前中断状态,获取写锁;释放时恢复原中断状态。
最佳实践:如果临界区可能被中断上下文访问,必须使用 _irqsave 变体。使用 _irq 变体时,调用者必须确保调用前本地中断处于开启状态,否则释放锁时会错误地开启中断,导致潜在的中断丢失。
// 中断上下文安全示例
unsigned long flags;

read_lock_irqsave(&my_rwlock, flags);
/* 在中断可能发生的情况下安全读取 */
if (device->status & DEVICE_READY) {
    process_data(device);
}
read_unlock_irqrestore(&my_rwlock, flags);

4.7 性能调优要点

  • 读者临界区应尽可能短:写者会自旋等待所有读者释放锁,过长的读者临界区会显著增加写者延迟。
  • 避免读者与写者频繁切换:如果读写操作交替非常频繁(如每 10 微秒切换一次),读写锁的开销可能超过自旋锁。此时应评估是否改用 spinlock 或 RCU。
  • 使用 rwlock_t 的原子性:在 x86 上,read_lock 通常编译为 LOCK DEC 指令,read_unlock 编译为 LOCK INC,缓存行乒乓(cache line bouncing)是主要性能瓶颈。在多 socket 系统中,应尽量将读者数据分散到不同 NUMA 节点。
  • 优先使用 _irqsave 而非 _irq:_irqsave 虽然多了一条保存/恢复指令,但避免了因中断状态假设错误导致的难以调试的 bug。
常见误区:不要将读写锁用于“写操作极少”但“写操作耗时较长”的场景。例如,写操作需要执行 msleep(10) 或 copy_from_user,此时所有读者将被长时间阻塞。这种场景应使用 RCU 或 mutex + 读者副本模式。

4.8 本章小结