读者写者问题
读者写者问题
它和生产者消费者模型一样,都是多线程同步与互斥的经典策略,从名称就能看出,该模型核心围绕读者与写者两种角色展开。生活中这类场景十分常见:比如我们发布博客,发布后很少修改,主要供大量用户阅读;印刷厂印制书籍、期刊,完成出版后主要供读者阅览;新闻客户端发布新闻,编辑完成后海量用户读取,这些都是典型的读者写者问题。在计算机层面,读者和写者的角色最终都由线程承担,二者通过共享的内存空间传递数据。
读者写者模型同样遵循321 原则:三种关系、两种角色、一个交易场所。两种角色很明确,就是读者和写者;一个交易场所是二者交互的共享内存空间,就像出黑板报时的黑板,写者通过黑板传递信息,读者通过黑板获取信息,这和生产者消费者模型的缓冲区本质一致。三种关系则需要重点理清:写者与写者、读者与写者、读者与读者。
首先,写者和写者是互斥关系。多个写者同时向临界资源写入数据,会造成数据错乱,因此同一时刻只能有一个写者执行写入操作,二者必须互斥。
其次,读者和写者之间既有互斥、也有同步关系:互斥是为了保证数据安全,写者写入时读者不能读,读者读取时写者不能写;同步是为了提升效率,写者完成写入后通知读者读取,读者读完后通知写者更新,二者协同工作。
最关键的区别在于读者和读者之间是并发关系,也就是二者没有互斥、同步约束,就像黑板报完成后,多名观众可以同时观看,一篇文章发布后多人可以同时阅读,读者读取数据不会将数据取走,不会影响其他读者的读取操作,这也是读者写者模型和生产者消费者模型的核心差异—— 消费者会取走数据,因此消费者之间必须竞争,而读者只读取不销毁数据,天然支持并发。
读者写者问题如何理解
基于这个核心逻辑,我们通过伪代码理解读者写者模型的工作原理。
公共部分
模型中需要定义读者计数器、保护计数器的互斥锁、写者独占锁三个公共部分。
uint32_t reader_count = 0; lock_t count_lock; lock_t writer_lock;Reader
读者线程执行时,先加计数器锁,若为第一个读者,就抢占写者锁,阻止写者进入,随后增加读者计数并解锁;读取完成后,再次加计数器锁,减少读者计数,若为最后一个读者,则释放写者锁,让写者可以写入。
// 加锁 lock(count_lock); if(reader_count == 0) lock(writer_lock); // lock(writer_lock)、unlock(writer_lock) //根本不是真正的 pthread_mutex_lock! //它只是画逻辑用的符号,意思是: //lock(writer_lock) = 把写通道关掉 //unlock(writer_lock) = 把写通道打开 //它假装成锁,实际是个开关、信号量、门栓,不遵守 “谁加锁谁解锁”。 ++reader_count; unlock(count_lock); // read; //解锁 lock(count_lock); --reader_count; if(reader_count == 0) unlock(writer_lock); unlock(count_lock);互斥锁是线程独占、必须谁加锁谁解锁的底层原语;读写锁是用互斥锁 + 计数器封装的上层策略,不绑定线程,只区分读写操作,读读共享、读写 / 写写互斥,writer_lock 只是控制读写互斥的公共门栓,由第一个读者上锁、最后一个读者解锁,允许跨线程操作,本质和互斥锁完全不同。
读写锁底层还是用 mutex,但它不是 “一把锁”,而是 “用 mutex 保护计数器” 来实现共享 / 独占策略,和普通 mutex 的语义完全不一样。
Writer
写者线程则直接加写者锁,执行写入操作后解锁即可。这样的设计能保证多个读者并发读取,读写、写写之间互斥,也适配了多读少写的常见应用场景。
// 写加锁 pthread_mutex_lock(&rw_mutex); // ========= 写操作 ========= // 写解锁 pthread_mutex_unlock(&rw_mutex);上面的是读者优先,写者优先只需要多做一件事:只要有写者在等待,后来的读者必须排队,不能插队,彻底避免写者饥饿。
改成写者优先其实也不难:
- 加一个
writer_wait:记录有没有写者在等 - 读者进来时先看有没有写者在等
- 有,读者就必须等,不能进
读写锁
在编写多线程的时候,有一种情况是十分常见的。那就是,有些公共数据修改的机会比较少。相比较改写,它们读的机会反而高的多。通常而言,在读的过程中,往往伴随着查找的操作,中间耗时很长。给这种代码段加锁,会极大地降低我们程序的效率。那么有没有一种方法,可以专门处理这种多读少写的情况呢?
有,那就是读写锁。
读写锁的行为
| 当前锁状态 | 读锁请求 | 写锁请求 |
|---|---|---|
| 无锁 | 可以 | 可以 |
| 读锁 | 可以 | 阻塞 |
| 写锁 | 阻塞 | 阻塞 |
注意:写独占,读共享,读锁优先级高
读写锁接口
需要包含头文件:
#include <pthread.h>读写锁属性设置(优先策略)[ pthread_rwlockattr_setkind_np ]
功能:设置读写锁优先级策略(读者优先 / 写者优先)函数原型
int pthread_rwlockattr_setkind_np(pthread_rwlockattr_t *attr, int pref);attr:读写锁属性对象指针pref:优先级策略(3 种)
策略常量
PTHREAD_RWLOCK_PREFER_READER_NP
- 默认值,读者优先
- 读者可插队,写者可能饥饿
PTHREAD_RWLOCK_PREFER_WRITER_NP
- 名义写者优先,存在 BUG
- 实际行为 = 读者优先,不可用
PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP
- 真正可用的写者优先
- 写者不可递归加锁,无写者饥饿
返回值
- 成功:0
- 失败:非 0 错误码
读写锁初始化[ pthread_rwlock_init ]
功能:初始化读写锁函数原型
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock, const pthread_rwlockattr_t *restrict attr);rwlock:待初始化的读写锁指针attr:属性指针(NULL = 使用默认属性)
返回值
- 成功:0
- 失败:非 0 错误码
读写锁销毁[ pthread_rwlock_destroy ]
功能:销毁读写锁,释放内核资源函数原型
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);rwlock:已初始化的读写锁指针
注意:销毁时不能有任何线程持有锁,销毁后不可再次使用!
返回值
- 成功:0
- 失败:非 0 错误码
读写锁加锁 / 解锁接口
1. 读加锁
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);- 功能:获取读锁,共享模式
- 规则:读读共享,读写阻塞,写写阻塞
- 阻塞:有写者持有锁时阻塞
2. 写加锁
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);- 功能:获取写锁,独占模式
- 规则:读写互斥,写写互斥
- 阻塞:有任何读者 / 写者时阻塞
3. 统一解锁
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);- 功能:释放读锁 OR 写锁
- 规则:谁加锁谁解锁,不可跨线程解锁
- 必须成对使用
读写锁案例:
#include <iostream> #include <pthread.h> #include <unistd.h> #include <vector> #include <cstdlib> #include <ctime> // 共享资源 int shared_data = 0; // 读写锁 pthread_rwlock_t rwlock; // 读者线程函数 void *Reader(void *arg) { //sleep(1); //读者优先,一旦读者进入&&读者很多,写者基本就很难进入了 int number = *(int *)arg; while (true) { pthread_rwlock_rdlock(&rwlock); // 读者加锁 std::cout << "读者-" << number << " 正在读取数据, 数据是: " << shared_data << std::endl; sleep(1); // 模拟读取操作 pthread_rwlock_unlock(&rwlock); // 解锁 } delete (int*)arg; return NULL; } // 写者线程函数 void *Writer(void *arg) { int number = *(int *)arg; while (true) { pthread_rwlock_wrlock(&rwlock); // 写者加锁 shared_data = rand() % 100; // 修改共享数据 std::cout << "写者- " << number << " 正在写入. 新的数据是: " << shared_data << std::endl; sleep(2); // 模拟写入操作 pthread_rwlock_unlock(&rwlock); // 解锁 } delete (int*)arg; return NULL; } int main() { srand(time(nullptr)^getpid()); pthread_rwlock_init(&rwlock, NULL); // 初始化读写锁 // 可以更高读写数量配比,观察现象 const int reader_num = 2; const int writer_num = 2; const int total = reader_num + writer_num; pthread_t threads[total]; // 假设读者和写者数量相等 // 创建读者线程 for (int i = 0; i < reader_num; ++i) { int *id = new int(i); pthread_create(&threads[i], NULL, Reader, id); } // 创建写者线程 for (int i = reader_num; i < total; ++i) { int *id = new int(i - reader_num); pthread_create(&threads[i], NULL, Writer, id); } // 等待所有线程完成 for (int i = 0; i < total; ++i) { pthread_join(threads[i], NULL); } pthread_rwlock_destroy(&rwlock); // 销毁读写锁 return 0; }Makefile:
reader_writer_lock_test:reader_writer_lock_test.cc g++ -o $@ $^ -lpthread .PHONY:clean clean: rm -f reader_writer_lock_test实际开发中无需手动实现读写锁,POSIX 线程库已提供现成的读写锁接口。核心接口包括:pthread_rwlock_init初始化读写锁、pthread_rwlock_destroy销毁读写锁、pthread_rwlock_rdlock加读锁、pthread_rwlock_wrlock加写锁、pthread_rwlock_unlock统一解锁。我们通过案例代码验证:定义共享全局变量作为临界资源,创建多个读者线程和写者线程,读者加读锁读取数据,写者加写锁修改数据,编译运行后能直观看到模型的运行效果。
测试中我们发现一个典型问题:写者饥饿。当读者线程数量较多、持续占用读锁时,写者始终无法获取写锁,无法执行写入操作。这是因为系统默认的读写锁策略是读者优先:只要有读者正在读取,新的读者可以直接进入,写者会被阻塞,直到所有读者退出。与之对应的是写者优先策略:当写者请求写入时,后续新读者会被阻塞,等待当前所有读者读完后,写者优先执行写入,写完后读者才能继续读取,但这种策略可能导致读者饥饿。
需要明确的是,饥饿问题并非读写锁的缺陷,而是其固有特性。实际业务场景中,读者线程不会一直占用临界区,读取数据后会有大量非临界区的处理逻辑,写者总有机会获取锁;同时我们可以根据业务需求选择策略:多读少写、允许读取旧数据的场景适合读者优先;需要保证数据实时更新、写入优先级更高的场景适合写者优先。
读者优先 (Reader-Preference)
在这种策略中,系统会尽可能多地允许多个读者同时访问资源 (比如共享文件或数据),而不会优先考虑写者。这意味着当有读者正在读取时,新到达的读者会立即被允许进入读取区,而写者则会被阻塞,直到所有读者都离开读取区。读者优先策略可能会导致写者饥饿 (即写者长时间无法获得写入权限),特别是当读者频繁到达时。
写者优先 (Writer-Preference)
在这种策略中,系统会优先考虑写者。当写者请求写入权限时,系统会尽快地让写者进入写入区,即使此时有读者正在读取。这通常意味着一旦有写者到达,所有后续的读者都会被阻塞,直到写者完成写入并离开写入区。写者优先策略可以减少写者等待的时间,但可能会导致读者饥饿 (即读者长时间无法获得读取权限),特别是当写者频繁到达时。