news 2026/4/19 1:41:33

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
读者写者问题与读写锁 [ 系统加餐 ]

读者写者问题

读者写者问题

它和生产者消费者模型一样,都是多线程同步与互斥的经典策略,从名称就能看出,该模型核心围绕读者写者两种角色展开。生活中这类场景十分常见:比如我们发布博客,发布后很少修改,主要供大量用户阅读;印刷厂印制书籍、期刊,完成出版后主要供读者阅览;新闻客户端发布新闻,编辑完成后海量用户读取,这些都是典型的读者写者问题。在计算机层面,读者和写者的角色最终都由线程承担,二者通过共享的内存空间传递数据。

读者写者模型同样遵循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)

在这种策略中,系统会优先考虑写者。当写者请求写入权限时,系统会尽快地让写者进入写入区,即使此时有读者正在读取。这通常意味着一旦有写者到达,所有后续的读者都会被阻塞,直到写者完成写入并离开写入区。写者优先策略可以减少写者等待的时间,但可能会导致读者饥饿 (即读者长时间无法获得读取权限),特别是当写者频繁到达时。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/4/19 1:40:30

从PID调参到根轨迹:一个电机控制工程师的实战避坑笔记

从PID调参到根轨迹&#xff1a;一个电机控制工程师的实战避坑笔记 作为一名在工业自动化领域摸爬滚打多年的电机控制工程师&#xff0c;我深知PID参数调试的痛点和挑战。每当面对一个全新的电机控制系统&#xff0c;传统的试凑法不仅耗时耗力&#xff0c;还常常陷入"调好一…

作者头像 李华
网站建设 2026/4/19 1:39:30

LeetCode 2958. 最多 K 个重复元素的最长子数组【不定长滑窗】1535

本文属于「征服LeetCode」系列文章之一&#xff0c;这一系列正式开始于2021/08/12。由于LeetCode上部分题目有锁&#xff0c;本系列将至少持续到刷完所有无锁题之日为止&#xff1b;由于LeetCode还在不断地创建新题&#xff0c;本系列的终止日期可能是永远。在这一系列刷题文章…

作者头像 李华
网站建设 2026/4/19 1:37:18

告别HAL_Delay卡死:STM32中断服务函数里实现精准延时的3种替代方案

告别HAL_Delay卡死&#xff1a;STM32中断服务函数里实现精准延时的3种替代方案 在嵌入式开发中&#xff0c;中断服务函数(ISR)的设计直接影响系统的实时性和稳定性。许多开发者习惯性地在ISR中使用HAL_Delay这类阻塞式延时函数&#xff0c;结果发现系统莫名其妙地卡死。这背后隐…

作者头像 李华
网站建设 2026/4/19 1:37:17

洛阳静脉曲张自查:腿沉酸胀水肿,你需要注意的几大信号

大家好&#xff0c;今天咱们聊聊一个常见但又容易被忽视的问题——静脉曲张。特别是对于那些经常站立工作、久坐不动或者有家族史的朋友来说&#xff0c;了解静脉曲张的症状和预防措施尤为重要。下面&#xff0c;我们就一起来看看腿沉酸胀水肿等几个重要的信号&#xff0c;以及…

作者头像 李华