3. 读写锁原理
读写锁(Read-Write Lock)是并发编程中一种专门优化“读多写少”场景的同步原语。与互斥锁(Mutex)的“完全互斥”不同,读写锁区分了两种锁获取模式:读模式(共享)和写模式(独占)。这种设计允许更高的并发度,因为多个读者可以同时访问共享数据,而写者则必须独占访问权。
3.1 读者-写者模型
读者-写者模型是读写锁的理论基础。该模型将并发访问共享资源的线程分为两类:
- 读者(Reader):只读取共享数据,不修改。多个读者可以同时访问,不会产生数据竞争。
- 写者(Writer):修改共享数据。写者必须独占访问权,任何其他读者或写者都不能同时访问。
读写锁的核心约束如下表所示:
| 当前持有者 | 请求者类型 | 是否允许 | 说明 |
|---|---|---|---|
| 读者(任意数量) | 读者 | 允许 | 读锁共享,多个读者可同时持有 |
| 读者(任意数量) | 写者 | 阻塞 | 写者必须等待所有读者释放锁 |
| 写者(唯一) | 读者 | 阻塞 | 读者必须等待写者释放锁 |
| 写者(唯一) | 写者 | 阻塞 | 写锁互斥,只允许一个写者 |
3.2 读锁共享与写锁互斥
读写锁的核心机制体现在两个关键操作中:读锁获取和写锁获取。
3.2.1 读锁共享(Shared Mode)
当线程请求读锁时,只要当前没有写者持有锁(且没有写者在等待队列中,取决于公平性策略),该线程就可以立即获得读锁。多个读者可以同时持有读锁,每个读者都会增加一个内部计数器(read_count)。
典型的读锁获取伪代码如下:
// 读锁获取(简化实现)
void read_lock(rwlock_t *lock) {
spin_lock(&lock->wait_lock);
// 等待直到没有写者持有锁
while (lock->writer_held) {
// 将当前线程加入读者等待队列
wait_queue_add(&lock->read_wait, current);
spin_unlock(&lock->wait_lock);
schedule(); // 睡眠等待
spin_lock(&lock->wait_lock);
}
lock->read_count++; // 读者计数+1
spin_unlock(&lock->wait_lock);
}
// 读锁释放
void read_unlock(rwlock_t *lock) {
spin_lock(&lock->wait_lock);
lock->read_count--;
if (lock->read_count == 0 && lock->write_waiting) {
// 唤醒等待的写者
wake_up(&lock->write_wait);
}
spin_unlock(&lock->wait_lock);
}
3.2.2 写锁互斥(Exclusive Mode)
当线程请求写锁时,它必须等待所有读者和所有其他写者释放锁。写锁是严格互斥的,同一时刻只能有一个写者持有。
写锁获取伪代码:
// 写锁获取(简化实现)
void write_lock(rwlock_t *lock) {
spin_lock(&lock->wait_lock);
// 标记有写者在等待
lock->write_waiting = 1;
// 等待直到没有读者且没有其他写者
while (lock->read_count > 0 || lock->writer_held) {
wait_queue_add(&lock->write_wait, current);
spin_unlock(&lock->wait_lock);
schedule();
spin_lock(&lock->wait_lock);
}
lock->writer_held = 1; // 标记写者持有
lock->write_waiting = 0;
spin_unlock(&lock->wait_lock);
}
// 写锁释放
void write_unlock(rwlock_t *lock) {
spin_lock(&lock->wait_lock);
lock->writer_held = 0;
// 优先唤醒读者(读者优先策略)或写者(写者优先策略)
wake_up_all(&lock->read_wait);
wake_up(&lock->write_wait);
spin_unlock(&lock->wait_lock);
}
3.3 公平性策略
读写锁的公平性策略决定了当写者等待时,新到达的读者是否能够立即获取读锁。不同的策略对性能和公平性有显著影响。
3.3.1 读者优先(Reader-Preference)
读者优先策略允许新读者在写者等待时仍然获取读锁。这种策略最大化读并发性能,但可能导致写者饥饿(Starvation)——如果读者持续到达,写者可能永远无法获得锁。
- 优点:读操作延迟最低,吞吐量最高。
- 缺点:写者可能无限期等待,不适合写操作有实时性要求的场景。
- 适用场景:配置读取、统计信息收集等写操作极少且不紧急的场景。
3.3.2 写者优先(Writer-Preference)
写者优先策略在写者等待时,阻塞新读者的读锁请求。一旦有写者开始等待,后续读者必须排队,直到所有等待的写者完成。这保证了写者不会饥饿,但会降低读并发度。
- 优点:写者延迟可控,避免饥饿。
- 缺点:读操作可能被阻塞,读吞吐量下降。
- 适用场景:数据库事务日志、文件系统元数据更新等写操作需要及时响应的场景。
3.3.3 公平锁(Fair Locking / 排队策略)
公平锁使用一个等待队列,所有请求(读或写)按照到达顺序排队。当锁释放时,队首的请求获得锁。如果队首是读者,则允许所有连续的读者一起进入;如果队首是写者,则只允许该写者进入。
- 优点:读写公平,无饥饿,延迟可预测。
- 缺点:读并发度受限于写者到达频率,整体吞吐量可能低于读者优先。
- 适用场景:实时系统、需要严格公平性的通用场景。
rwlock_t 默认采用读者优先策略,因为内核中读操作远多于写操作,且写者通常能快速完成。而 POSIX 线程库的 pthread_rwlockattr_setkind_np() 允许用户选择 PTHREAD_RWLOCK_PREFER_READER_NP、PTHREAD_RWLOCK_PREFER_WRITER_NP 或 PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP 策略。
3.4 三种公平性策略对比
| 特性 | 读者优先 | 写者优先 | 公平锁(排队) |
|---|---|---|---|
| 写者饥饿风险 | 高 | 无 | 无 |
| 读并发度 | 最高 | 中等 | 中等 |
| 写者延迟 | 不可预测 | 低且可控 | 中等且可控 |
| 实现复杂度 | 低 | 中 | 高 |
| 典型应用 | 配置缓存、路由表 | 数据库日志、文件系统 | 通用中间件、实时系统 |
3.5 读写锁的性能权衡
选择读写锁策略时,需要根据实际工作负载进行权衡:
- 读/写比例:读操作占比越高,读者优先策略的优势越明显。当读占比超过 90% 时,读写锁相比互斥锁通常能带来 5-10 倍的性能提升。
- 临界区大小:临界区执行时间越长,锁竞争越激烈,公平性策略的影响越大。
- 写操作频率:写操作越频繁,写者优先或公平锁越重要,以避免写者饥饿。
- 实时性要求:如果写操作有 deadline 要求,必须使用写者优先或公平锁。
3.6 本章小结
读写锁通过区分读者和写者,实现了读锁共享和写锁互斥,是优化“读多写少”场景的核心工具。公平性策略(读者优先、写者优先、公平锁)决定了读写锁在不同负载下的行为特征。理解这些原理是后续学习 RCU(Read-Copy-Update)机制的基础——RCU 在读写锁的基础上进一步消除了读者端的锁开销,实现了真正的无等待读操作。