为什么需要读者写者问题?
在多线程编程中,同步 是一个永恒的话题。我们之前接触过生产者-消费者问题,它描述的是:生产者往缓冲区放数据,消费者从缓冲区取数据,两者需要互斥地访问缓冲区,同时还要在缓冲区空/满时进行等待和唤醒。
而读者写者问题是另一类经典的同步问题,它的场景是:
-
有一块共享数据(比如一个文件、一个数据库、一块内存)。
-
多个读者可以同时读取这块数据,因为读操作不会改变数据,所以读者之间不需要互斥。
-
写者在写入数据时,必须独占访问,因为写操作会改变数据,如果此时有其他读者或写者在访问,就会导致数据不一致。
-
写者与读者之间 、写者与写者之间必须互斥。
简单来说:读共享,写独占。
读者写者 vs 生产者消费者
| 对比项 | 生产者-消费者 | 读者-写者 |
|---|---|---|
| 核心矛盾 | 缓冲区空/满时的等待与唤醒 | 读与写、写与写之间的互斥 |
| 互斥关系 | 生产者与消费者互斥访问缓冲区 | 写者与所有人互斥,读者之间不互斥 |
| 同步关系 | 生产者等待缓冲区有空位,消费者等待缓冲区有数据 | 读者等待写者离开,写者等待所有读者离开 |
| 典型场景 | 消息队列、任务队列 | 文件读写、数据库读写、配置中心 |
重点理解 :读者写者问题的核心在于如何协调读者和写者之间的同步,使得读操作可以并发,写操作必须独占,同时还要避免某一方"饿死"。
读者写者问题的伪代码
公共部分
uint32_t reader_count = 0; // 当前正在读取的读者数量
lock_t count_lock; // 保护 reader_count 的锁
lock_t writer_lock; // 写者锁,读者和写者共享
-
reader_count:记录当前有多少个读者正在读。 -
count_lock:因为多个读者可能同时修改reader_count,所以需要一把锁来保护它。 -
writer_lock:写者需要持有的锁,同时第一个读者进入时也要持有它,最后一个读者离开时释放它。
Reader(读者)
cpp
// 加锁
lock(count_lock);
if (reader_count == 0)
lock(writer_lock); // 第一个读者,锁住写者锁
++reader_count;
unlock(count_lock);
// read;
// 解锁
lock(count_lock);
--reader_count;
if (reader_count == 0)
unlock(writer_lock); // 最后一个读者,释放写者锁
unlock(count_lock);
逻辑解读:
-
读者先锁住
count_lock,然后检查自己是不是第一个读者。 -
如果是第一个读者(
reader_count == 0),说明当前没有读者在读,但可能有写者在写,所以需要获取writer_lock。如果写者正在写,读者会在这里阻塞,直到写者释放writer_lock。 -
然后
reader_count++,表示自己开始读了,释放count_lock。 -
读取数据。
-
读完后,再次锁住
count_lock,reader_count--。 -
如果自己是最后一个读者(
reader_count == 0),说明所有读者都离开了,此时需要释放writer_lock,让等待的写者可以进入。 -
释放
count_lock。
关键点 :第一个读者负责"锁住"写者,最后一个读者负责"释放"写者。中间的读者只是简单地增加/减少计数,不会触碰 writer_lock。
Writer(写者)
cpp
lock(writer_lock);
// write
unlock(writer_lock);
逻辑解读:
-
写者直接尝试获取
writer_lock。 -
如果此时有读者正在读(
writer_lock被第一个读者持有),写者会阻塞。 -
如果此时有其他写者在写,写者也会阻塞。
-
获取到锁后,独占写入。
-
写完释放锁。
注意 :这个伪代码实现的是读者优先 策略。因为只要有一个读者持有 writer_lock,后续的读者都可以直接进入(它们只需要 count_lock),而写者必须等待所有读者离开。如果读者源源不断,写者可能永远等待,即写者饥饿。
读写锁(pthread_rwlock)
在实际编程中,我们不需要自己手动实现上述逻辑,POSIX 提供了读写锁 (pthread_rwlock_t),它封装了读者写者的同步机制。
读写锁的行为
| 当前锁状态 | 读锁请求 | 写锁请求 |
|---|---|---|
| 无锁 | 可以 | 可以 |
| 读锁 | 可以 | 阻塞 |
| 写锁 | 阻塞 | 阻塞 |
总结:写独占,读共享,读锁优先级高(默认)。
读写锁的接口
初始化与销毁
cpp
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,
const pthread_rwlockattr_t *restrict attr);
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
-
attr通常传NULL,表示使用默认属性。 -
使用前必须初始化,使用后必须销毁。
加锁与解锁
cpp
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock); // 读锁
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock); // 写锁
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock); // 解锁
-
读锁:多个线程可以同时持有读锁。
-
写锁:同一时刻只能有一个线程持有写锁,且此时不能有读锁。
-
解锁:无论是读锁还是写锁,都用同一个
unlock。
设置读写优先级
cpp
int pthread_rwlockattr_setkind_np(pthread_rwlockattr_t *attr, int pref);
pref 有三种选择:
| 选项 | 含义 |
|---|---|
PTHREAD_RWLOCK_PREFER_READER_NP |
读者优先(默认),可能导致写者饥饿 |
PTHREAD_RWLOCK_PREFER_WRITER_NP |
写者优先,但目前有 BUG,表现和读者优先一致 |
PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP |
写者优先,但写者不能递归加锁 |
注意:写者优先可以缓解写者饥饿,但可能导致读者饥饿。实际使用时需要根据场景权衡。
读者优先 vs 写者优先
-
读者优先:只要有读者在读,后续读者可以直接进入,写者必须等待所有读者离开。可能导致写者饥饿。
-
写者优先:一旦有写者到达,后续读者会被阻塞,直到写者完成。可能导致读者饥饿。
选择建议:
-
如果读操作非常频繁,写操作很少,且写者饥饿不是问题,可以用读者优先。
-
如果写操作也很重要,不能长时间等待,可以用写者优先(注意
PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP)。
自旋锁(Spinlock)
什么是自旋锁?
自旋锁是一种忙等待的锁。当一个线程尝试获取自旋锁但锁已被占用时,它不会进入休眠,而是在一个循环中不断检查锁是否可用。一旦锁被释放,线程立即获取。
类比:你去上厕所,发现门锁着,你不会回去睡觉,而是站在门口一直问"好了没?好了没?",直到里面的人出来。
自旋锁的原理
自旋锁通常使用一个原子标志位来表示锁的状态:
-
false:锁可用。 -
true:锁已被占用。
获取锁时,使用 CAS(Compare-And-Swap) 原子操作:
cpp
while (atomic_flag_test_and_set(&spinlock)) {
// 忙等待
}
-
atomic_flag_test_and_set:如果标志位为false,则设置为true并返回false(表示获取成功);如果为true,则返回true(表示获取失败)。 -
释放锁:
atomic_flag_clear(&spinlock);将标志位设为false。
自旋锁的优缺点
优点:
-
低延迟:不会让线程休眠,避免了线程切换的开销。
-
减少系统调度开销:等待锁的线程不会被阻塞,不需要上下文切换。
缺点:
-
CPU 资源浪费:如果锁持有时间较长,等待线程会一直自旋,浪费 CPU。
-
可能引起活锁:多个线程同时自旋,如果没有退避策略,可能都无法进入临界区。
使用场景
-
短暂等待:锁被占用的时间很短,比如简单的计数器操作。
-
多线程锁使用:通常用于系统底层,同步多个 CPU 对共享资源的访问。
-
不可睡眠的场景:比如中断处理程序中,不能休眠,只能用自旋锁。
Linux 提供的自旋锁系统调用
cpp
#include <pthread.h>
int pthread_spin_lock(pthread_spinlock_t *lock);
int pthread_spin_trylock(pthread_spinlock_t *lock);
int pthread_spin_unlock(pthread_spinlock_t *lock);
int pthread_spin_init(pthread_spinlock_t *lock, int pshared);
int pthread_spin_destroy(pthread_spinlock_t *lock);
pshared:PTHREAD_PROCESS_PRIVATE表示线程间共享,PTHREAD_PROCESS_SHARED表示进程间共享。
互斥锁 vs 自旋锁 vs 读写锁
| 锁类型 | 行为 | 适用场景 | 缺点 |
|---|---|---|---|
| 互斥锁(Mutex) | 获取失败时休眠,释放时唤醒 | 锁持有时间较长,竞争不激烈 | 上下文切换开销 |
| 自旋锁(Spinlock) | 获取失败时忙等待 | 锁持有时间极短,多核环境 | CPU 浪费,可能活锁 |
| 读写锁(Rwlock) | 读共享,写独占 | 多读少写 | 可能读者/写者饥饿 |
选择原则:
-
如果临界区执行时间短,且 CPU 多核,用自旋锁。
-
如果临界区执行时间长,或者单核,用互斥锁。
-
如果读多写少,用读写锁。
读者写者问题的变种
-
读者优先:只要有一个读者在读,后续读者直接进入,写者等待。
-
写者优先:一旦有写者等待,后续读者阻塞,写者优先进入。
-
公平竞争:读者和写者按到达顺序竞争,避免饥饿。
读写锁的实现细节
读写锁的实现通常需要一个计数器 和两个条件变量(或信号量):
-
reader_count:当前读者数量。 -
writer_count:当前写者数量(通常为 0 或 1)。 -
mutex:保护计数器。 -
read_cond:读者等待的条件变量。 -
write_cond:写者等待的条件变量。
读者进入时:
-
如果写者正在写,等待。
-
否则,
reader_count++,进入读。
写者进入时:
-
如果读者正在读或写者正在写,等待。
-
否则,进入写。
自旋锁的优化
-
退避策略:自旋失败后,暂停一段时间再重试,减少 CPU 浪费。
-
队列自旋锁:每个线程在一个队列中等待,避免所有线程同时自旋。
-
自适应自旋锁:根据历史成功率动态调整自旋时间。
实际应用中的建议
-
避免锁的嵌套:容易导致死锁。
-
锁的粒度要小:只保护必要的临界区。
-
读写锁并不是解决所有并发问题的万能工具:如果写操作也很频繁,读写锁可能不如互斥锁。
-
自旋锁不要用于单核:单核自旋没有意义,因为自旋的线程占着 CPU,持有锁的线程无法运行。