读者写者问题与读写锁 [ 系统加餐 ]

张开发
2026/4/19 1:41:35 15 分钟阅读

分享文章

读者写者问题与读写锁 [ 系统加餐 ]
读者写者问题读者写者问题它和生产者消费者模型一样都是多线程同步与互斥的经典策略从名称就能看出该模型核心围绕读者与写者两种角色展开。生活中这类场景十分常见比如我们发布博客发布后很少修改主要供大量用户阅读印刷厂印制书籍、期刊完成出版后主要供读者阅览新闻客户端发布新闻编辑完成后海量用户读取这些都是典型的读者写者问题。在计算机层面读者和写者的角色最终都由线程承担二者通过共享的内存空间传递数据。读者写者模型同样遵循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; }Makefilereader_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)在这种策略中系统会优先考虑写者。当写者请求写入权限时系统会尽快地让写者进入写入区即使此时有读者正在读取。这通常意味着一旦有写者到达所有后续的读者都会被阻塞直到写者完成写入并离开写入区。写者优先策略可以减少写者等待的时间但可能会导致读者饥饿 (即读者长时间无法获得读取权限)特别是当写者频繁到达时。

更多文章