当前位置: 首页 > news >正文

从 0 到 1 理解读者写者问题与读写锁:操作系统并发编程入门

前言:

在操作系统的并发世界里,多个线程或进程共享资源是常态,但无序的资源访问往往会引发数据混乱。读者写者问题作为并发编程的经典模型,精准地揭示了这类冲突的本质,而读写锁则是解决该问题的高效工具。本文将从基础概念出发,逐步拆解问题核心,详解解决方案,帮你彻底掌握这一重要知识点。

一、先搞懂:什么是读者写者问题?

1.1 问题的由来

想象这样一个场景:一个在线文档(共享资源),多人同时查看(读操作)不会有任何问题,但如果有人正在修改文档(写操作),此时其他人无论是查看还是修改,都可能看到错误的内容或导致修改丢失。

在计算机系统中,类似的场景无处不在:

  • 数据库中,多个查询语句(读者)和更新语句(写者)共享数据表;
  • 日志系统中,多个线程读取日志文件(读者)和写入日志(写者)共享日志资源;
  • 缓存系统中,缓存的读取(读者)与更新(写者)操作的冲突控制。

读者写者问题就是为解决这类 “读 - 写冲突” 和 “写 - 写冲突” 而提出的经典并发控制问题,由计算机科学家 Hansen 于 1971 年首次提出。

1.2 核心冲突与规则

要解决问题,首先得明确问题的边界。读者写者问题的核心在于区分 “读” 和 “写” 两种操作的特性:读操作不修改资源,写操作会改变资源状态。基于此,衍生出三条必须遵守的访问规则:

  1. 读 - 读共享:多个读者可以同时访问共享资源,互不干扰(比如 10 人同时看同一篇文章);
  2. 读 - 写互斥:只要有一个写者在操作,所有读者都不能读,反之亦然(有人改文章时,其他人不能看也不能改);
  3. 写 - 写互斥:同一时刻只能有一个写者操作资源(不能两个人同时改同一篇文章)。

如果不遵守这些规则,会发生什么?举个实际例子:某商品库存为 100 件(共享资源),两个用户同时下单(写操作),理论上库存应变为 98 件。但如果没有互斥控制,可能出现两个操作同时读取到 100,各自减 1 后都写入 99,导致库存少减了 1 件 —— 这就是典型的 “数据竞争” 问题。

1.3 为什么不用普通互斥锁?

看到这里你可能会问:“用普通的互斥锁(Mutex)不是能解决互斥问题吗?” 确实可以,但代价太大。

普通互斥锁会强制所有访问者(无论读者还是写者)排队依次访问,即使多个读者同时读也不允许。这就像一间只能容纳 1 人的阅览室,哪怕 10 个人只是来安静看书,也得一个个排队进入,严重浪费了并发效率。

而读者写者问题的核心优化点正是:利用 “读操作可共享” 的特性提升并发性能。这也是我们需要专门研究它的根本原因。

二、进阶:读者写者问题的两种经典解决方案

根据对读者和写者的优先级处理,经典解决方案分为 “读者优先” 和 “写者优先” 两种策略,它们各有适用场景,也各有优缺点。

2.1 读者优先策略:效率优先的选择

核心思想

==只要还有读者在读取资源,新到来的读者就可以直接加入读取队列,而写者必须等待所有读者都离开后才能操作。==就像阅览室里只要还有人在看书,新的读者就能进来,想写书的人只能在门口等所有人走光。

关键变量设计

要实现这个策略,需要三个核心变量协同工作:

  • readers_count:计数器,记录当前正在读取的读者数量,初始值为 0;
  • count_mutex:互斥锁,保护readers_count的修改(因为多个读者会同时更新这个计数器,必须保证修改的原子性);
  • resource_sem:二值信号量,控制对共享资源的访问,初始值为 1(1 表示资源可用,0 表示被占用)。

这里要特别区分互斥锁和二值信号量互斥锁强调 “谁持有谁释放”,就像钥匙只能由拿钥匙的人归还;而二值信号量没有所有者,任何线程都可以释放,更像公共门禁。

操作流程详解
读者线程流程
  1. 申请count_mutex锁,确保修改计数器时不被干扰;
  2. readers_count加 1,记录新增读者;
  3. 如果是第一个读者(readers_count == 1),申请resource_sem信号量,锁定资源阻止写者;
  4. 释放count_mutex锁,允许其他读者修改计数器;
  5. 执行读操作(此时若有其他读者,可同时执行);
  6. 再次申请count_mutex锁,准备修改计数器;
  7. readers_count减 1,记录读者离开;
  8. 如果是最后一个读者(readers_count == 0),释放resource_sem信号量,允许写者访问;
  9. 释放count_mutex锁,流程结束。
写者线程流程
  1. 申请resource_sem信号量(若有读者或其他写者,会在此阻塞);
  2. 执行写操作(此时独占资源,无其他线程干扰);
  3. 释放resource_sem信号量,允许其他线程访问;
  4. 流程结束。
代码实现(C++20)
#include <iostream>
#include <thread>
#include <semaphore>
#include <mutex>
#include <chrono>
#include <cstring>using namespace std;// 共享资源:1MB的内存块
uint8_t resource[1024 * 1024] = {0};
// 读者计数器:记录当前正在读的读者数量
int readers_count = 0;
// 保护读者计数器的互斥锁
mutex count_mutex;
// 保护共享资源的二值信号量
binary_semaphore resource_sem(1);// 读者线程函数
void reader_func(int reader_id) {cout << "读者" << reader_id << "创建,线程ID:" << this_thread::get_id() << endl;while (true) {// 1. 准备读取:更新读者计数count_mutex.lock();if (readers_count == 0) {// 第一个读者锁定资源,阻止写者resource_sem.acquire();}readers_count++;count_mutex.unlock();// 2. 执行读操作uint8_t* read_buf = new uint8_t[1024 * 1024];memcpy(read_buf, resource, sizeof(resource));cout << "读者" << reader_id << "读取成功,前8字节:";for (int i = 0; i < 8; i++) {printf("%#X ", read_buf[i]);}cout << endl;delete[] read_buf;this_thread::sleep_for(chrono::milliseconds(20)); // 模拟读操作耗时// 3. 读取完毕:更新读者计数count_mutex.lock();readers_count--;if (readers_count == 0) {// 最后一个读者释放资源,允许写者resource_sem.release();}count_mutex.unlock();this_thread::sleep_for(chrono::milliseconds(5)); // 模拟读者后续操作}
}// 写者线程函数
void writer_func(int writer_id) {cout << "写者" << writer_id << "创建,线程ID:" << this_thread::get_id() << endl;int data = 0;while (true) {// 1. 申请资源,独占访问resource_sem.acquire();// 2. 执行写操作memset(resource, data, sizeof(resource));cout << "写者" << writer_id << "写入数据:" << data << endl;data = (data + 1) % 256; // 数据循环递增this_thread::sleep_for(chrono::milliseconds(50)); // 模拟写操作耗时// 3. 释放资源resource_sem.release();this_thread::sleep_for(chrono::milliseconds(10)); // 模拟写者后续操作}
}int main() {// 创建3个读者线程和2个写者线程thread readers[3] = {thread(reader_func, 1), thread(reader_func, 2), thread(reader_func, 3)};thread writers[2] = {thread(writer_func, 1), thread(writer_func, 2)};// 等待线程结束(实际运行中可按Ctrl+C终止)for (auto& r : readers) r.join();for (auto& w : writers) w.join();return 0;
}
优缺点分析
  • 优点:读操作并发度高,适合 “多读少写” 的场景(如新闻网站、商品详情页),能最大化系统吞吐量;
  • 缺点:可能导致写者 “饥饿”—— 如果读者源源不断到来,写者会一直等待,永远无法获得资源访问权。

2.2 写者优先策略:公平性优先的选择

核心思想

当写者请求访问资源时,会优先获得权限:已经在读取的读者可以继续读完,但新到来的读者需要等待,直到写者完成操作。就像阅览室里,只要有写书的人排队,新的读者就不能再进入,只能等写者写完后才能进入。

关键改进

在读者优先的基础上,增加一个write_sem信号量(初始值为 1),用于控制读者的进入权限,实现写者优先:

  • 写者申请资源前,先锁定write_sem,阻止新读者进入;
  • 只有当所有已在读取的读者离开,且没有写者时,才释放write_sem,允许新读者进入。
操作流程差异
写者线程新增步骤

在申请resource_sem前,先申请write_sem,确保新读者无法进入;写操作完成后,再释放write_sem

读者线程新增步骤

在申请count_mutex前,先申请write_sem,若有写者等待,则读者阻塞;读取完成后,再释放write_sem

代码实现(C++20)
#include <iostream>
#include <thread>
#include <semaphore>
#include <mutex>
#include <chrono>
#include <cstring>using namespace std;// 共享资源:1MB的内存块
uint8_t resource[1024 * 1024] = {0};
// 读者计数器:记录当前正在读的读者数量
int readers_count = 0;
// 保护读者计数器的互斥锁
mutex count_mutex;
// 保护共享资源的二值信号量
binary_semaphore resource_sem(1);
// 控制写者优先的信号量
binary_semaphore write_sem(1);// 读者线程函数
void reader_func(int reader_id) {cout << "读者" << reader_id << "创建,线程ID:" << this_thread::get_id() << endl;while (true) {// 1. 申请写者信号量,判断是否有写者等待write_sem.acquire();// 2. 更新读者计数count_mutex.lock();if (readers_count == 0) {resource_sem.acquire();}readers_count++;count_mutex.unlock();// 3. 释放写者信号量,允许其他读者进入(已在等待的读者可继续)write_sem.release();// 4. 执行读操作uint8_t* read_buf = new uint8_t[1024 * 1024];memcpy(read_buf, resource, sizeof(resource));cout << "读者" << reader_id << "读取成功,前8字节:";for (int i = 0; i < 8; i++) {printf("%#X ", read_buf[i]);}cout << endl;delete[] read_buf;this_thread::sleep_for(chrono::milliseconds(20));// 5. 读取完毕,更新读者计数count_mutex.lock();readers_count--;if (readers_count == 0) {resource_sem.release();}count_mutex.unlock();this_thread::sleep_for(chrono::milliseconds(5));}
}// 写者线程函数
void writer_func(int writer_id) {cout << "写者" << writer_id << "创建,线程ID:" << this_thread::get_id() << endl;int data = 0;while (true) {// 1. 申请写者信号量,阻止新读者进入write_sem.acquire();// 2. 申请资源信号量,独占访问resource_sem.acquire();// 3. 执行写操作memset(resource, data, sizeof(resource));cout << "写者" << writer_id << "写入数据:" << data << endl;data = (data + 1) % 256;this_thread::sleep_for(chrono::milliseconds(50));// 4. 释放资源和写者信号量resource_sem.release();write_sem.release();this_thread::sleep_for(chrono::milliseconds(10));}
}int main() {thread readers[3] = {thread(reader_func, 1), thread(reader_func, 2), thread(reader_func, 3)};thread writers[2] = {thread(writer_func, 1), thread(writer_func, 2)};for (auto& r : readers) r.join();for (auto& w : writers) w.join();return 0;
}
优缺点分析
  • 优点:避免了写者饥饿问题,保证了写操作的及时性,适合 “写操作敏感” 的场景(如数据库更新、实时数据写入);
  • 缺点:读操作的并发度降低,在 “多读少写” 场景下,系统吞吐量会下降。

三、实战工具:读写锁(Read-Write Lock)

前面的解决方案需要我们手动实现信号量和计数器的协同,实际开发中,操作系统和编程语言已经为我们封装了现成的工具 —— 读写锁。

3.1 读写锁的本质

读写锁是一种可升级 / 降级的同步原语,它内置了对 “读共享、写独占” 特性的支持,本质上是读者写者问题解决方案的工程实现。它就像一个智能门禁,自动区分来访者是读者还是写者,然后执行对应的访问规则。

与普通互斥锁相比,读写锁的核心优势在于兼顾并发效率与数据安全

特性普通互斥锁(Mutex)读写锁(Read-Write Lock)
读操作并发不支持支持(多读者同时读)
写操作特性独占独占
适用场景读写均衡或多写场景多读少写场景
开销较低略高(需维护读写状态)

3.2 读写锁的核心原理

在 C++ 中,可通过 POSIX 线程库(pthread)提供的读写锁接口实现,其核心逻辑是通过内部状态维护读锁和写锁的持有情况:

  • 当线程申请读锁时,若当前无写锁持有,直接成功并增加读锁计数;若有写锁持有,则阻塞等待;
  • 当线程申请写锁时,若当前无读锁和写锁持有,直接成功;若有任何锁持有,则阻塞等待;
  • 读写锁还支持优先级设置,可通过接口指定读者优先或写者优先(需注意部分实现的兼容性问题)。

3.3 C++ 中读写锁的实现(基于 pthread)

1. 读写锁核心接口

POSIX 线程库提供了读写锁的初始化、销毁、加锁、解锁及优先级设置接口,具体如下:

  • 初始化int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock, const pthread_rwlockattr_t *restrict attr);,用于初始化读写锁对象,attr为属性对象(可指定优先级),传NULL则使用默认属性;
  • 销毁int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);,用于释放读写锁占用的资源,避免内存泄漏;
  • 读锁操作int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);,申请读锁,成功后可执行读操作;
  • 写锁操作int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);,申请写锁,成功后可执行写操作;
  • 解锁操作int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);,释放已持有的读锁或写锁;
  • 优先级设置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:写者优先且写者不能递归加锁,是较稳定的写者优先实现。
2. 完整代码示例(多读少写场景)
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <cstdlib>
#include <ctime>
#include <vector>using namespace std;// 共享资源:模拟数据存储
int shared_data = 0;
// 读写锁对象
pthread_rwlock_t rwlock;
// 线程数量配置:2个读者,2个写者(可调整观察不同场景)
const int READER_NUM = 2;
const int WRITER_NUM = 2;
const int TOTAL_THREAD = READER_NUM + WRITER_NUM;// 读者线程函数:读取共享资源并输出
void* reader_thread(void* arg) {int reader_id = *(int*)arg;delete (int*)arg; // 释放传入的ID内存while (true) {// 1. 申请读锁int ret = pthread_rwlock_rdlock(&rwlock);if (ret != 0) {cerr << "读者" << reader_id << "申请读锁失败,错误码:" << ret << endl;pthread_exit(NULL);}// 2. 执行读操作cout << "读者-" << reader_id << " 正在读取数据,当前数据:" << shared_data << endl;sleep(1); // 模拟读操作耗时(如数据库查询、文件读取)// 3. 释放读锁ret = pthread_rwlock_unlock(&rwlock);if (ret != 0) {cerr << "读者" << reader_id << "释放读锁失败,错误码:" << ret << endl;pthread_exit(NULL);}// 模拟读者后续操作(如数据处理),避免频繁加锁sleep(1);}pthread_exit(NULL);
}// 写者线程函数:修改共享资源并输出
void* writer_thread(void* arg) {int writer_id = *(int*)arg;delete (int*)arg; // 释放传入的ID内存while (true) {// 1. 申请写锁int ret = pthread_rwlock_wrlock(&rwlock);if (ret != 0) {cerr << "写者" << writer_id << "申请写锁失败,错误码:" << ret << endl;pthread_exit(NULL);}// 2. 执行写操作:生成0-99的随机数更新共享资源int new_data = rand() % 100;shared_data = new_data;cout << "写者-" << writer_id << " 正在写入数据,新数据:" << new_data << endl;sleep(2); // 模拟写操作耗时(如数据库更新、文件写入)// 3. 释放写锁ret = pthread_rwlock_unlock(&rwlock);if (ret != 0) {cerr << "写者" << writer_id << "释放写锁失败,错误码:" << ret << endl;pthread_exit(NULL);}// 模拟写者后续操作(如日志记录),避免频繁加锁sleep(1);}pthread_exit(NULL);
}int main() {// 1. 初始化随机数种子(确保每次运行写者生成的随机数不同)srand(time(nullptr) ^ getpid());// 2. 初始化读写锁:使用默认属性(读者优先)int ret = pthread_rwlock_init(&rwlock, NULL);if (ret != 0) {cerr << "读写锁初始化失败,错误码:" << ret << endl;return -1;}// 3. 创建线程数组:存储所有读者和写者线程IDpthread_t threads[TOTAL_THREAD];// 4. 创建读者线程for (int i = 0; i < READER_NUM; ++i) {int* reader_id = new int(i); // 动态分配ID(避免循环变量地址重复问题)ret = pthread_create(&threads[i], NULL, reader_thread, (void*)reader_id);if (ret != 0) {cerr << "创建读者线程" << i << "失败,错误码:" << ret << endl;delete reader_id;return -1;}}// 5. 创建写者线程for (int i = READER_NUM; i < TOTAL_THREAD; ++i) {int* writer_id = new int(i - READER_NUM); // 写者ID从0开始ret = pthread_create(&threads[i], NULL, writer_thread, (void*)writer_id);if (ret != 0) {cerr << "创建写者线程" << (i - READER_NUM) << "失败,错误码:" << ret << endl;delete writer_id;return -1;}}// 6. 等待所有线程结束(实际开发中可通过信号量控制线程退出)for (int i = 0; i < TOTAL_THREAD; ++i) {pthread_join(threads[i], NULL);}// 7. 销毁读写锁ret = pthread_rwlock_destroy(&rwlock);if (ret != 0) {cerr << "读写锁销毁失败,错误码:" << ret << endl;return -1;}return 0;
}
3. 编译与运行

需使用支持 POSIX 线程库的编译器(如 GCC),编译时需链接pthread库,命令如下:

g++ rwlock_demo.cpp -o rwlock_demo -lpthread
./rwlock_demo

运行后可观察到:多个读者可同时读取相同数据,写者写入时会独占资源,且默认情况下读者优先(写者可能等待多个读者完成后才执行)。

3.4 读写锁的使用场景

读写锁并非万能,需要根据业务场景选择,以下是典型适用场景:

  1. 缓存系统:缓存的读取频率远高于更新频率,用读写锁可提升缓存读取的并发性能;
  2. 配置中心:配置文件的读取操作频繁,而更新操作稀少,适合用读写锁保护配置资源;
  3. 日志分析系统:多个线程同时读取日志文件(读操作),单个线程负责写入日志(写操作);
  4. 数据库索引:数据库索引的查询(读)远多于更新(写),读写锁是常用的并发控制手段。

四、避坑指南:常见问题与解决方案

4.1 写者饥饿问题

  • 问题:默认的读者优先策略下,持续到来的读者会导致写者永远无法获得资源;
  • 解决方案:
    1. 通过pthread_rwlockattr_setkind_np接口设置PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP策略,改为写者优先;
    2. 引入 “超时机制”,使用pthread_rwlock_tryrdlock(尝试申请读锁)和pthread_rwlock_trywrlock(尝试申请写锁),超时后重新尝试,避免永久阻塞;
    3. 控制读者线程的并发数量,避免读者持续占用资源。

4.2 锁操作兼容性问题

  • 问题:部分系统对PTHREAD_RWLOCK_PREFER_WRITER_NP策略支持不完善,可能表现为与读者优先一致;
  • 解决方案:
    1. 优先使用PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP策略,兼容性更优;
    2. 若需保证写者优先且上述接口不生效,可手动结合信号量实现(如 2.2 节的写者优先策略),避免依赖系统接口的不确定性。

4.3 性能开销问题

  • 问题:读写锁的状态维护比普通互斥锁复杂,在 “多写少读” 场景下,频繁的锁状态切换会导致性能低于普通互斥锁;
  • 解决方案:
    1. 读写比例接近或写操作频繁时,直接使用普通互斥锁(pthread_mutex_t);
    2. 减少锁持有时间:将耗时操作(如 IO、复杂计算)移出锁保护范围,仅在访问共享资源时加锁;
    3. 避免递归加锁:读写锁不支持同一线程递归申请写锁,递归申请会导致死锁,需确保锁的申请与释放逻辑对称。

五、总结

读者写者问题不仅是操作系统的经典理论,更是并发编程的实践指南。它的核心思想 ——“根据操作类型区分访问权限”,贯穿了从基础信号量实现到高级读写锁工具的整个演进过程。在实际开发中,没有 “银弹” 级的解决方案。选择读者优先还是写者优先,使用手动实现还是现成的读写锁,都需要结合业务场景中的 “读写比例”、“公平性要求” 和 “性能需求” 综合判断。

http://www.dtcms.com/a/478834.html

相关文章:

  • 框架--Spring
  • 贵州网站建设营销公司免费推广网站入口202
  • 10.9 了解鸿蒙生态
  • 【win32】FFmpeg 硬件解码器
  • 网站怎么做留言板块阿玛尼手表官方网站查询正品
  • Flutter 跨平台文件上传 - GetX + ImagePicker + Dio 实现
  • 实现提供了完整的 Flutter Web 文件上传解决方案
  • 黑群晖建设个人网站免费制作链接的软件
  • 网站设计毕业设计任务书网站怎样做301跳转
  • 互联网从业者的数据能力突围:从焦虑到破局的能力成长路径
  • 2025三掌柜赠书活动第三十八期 EDR逃逸的艺术:终端防御规避技术全解
  • 怎么在工商网站做实名认证企业网站的搭建流程
  • 第14讲:深入理解指针(4)——函数指针与“程序跳转术”
  • 湖北省建设网站首页公众平台网站开发哪家好
  • 重庆最有效的网站推广腾讯云搭建wordpress
  • x86、arm、rsc-v指令集架构,指令集、OS、应用3者的关系
  • 中科米堆CASAIM自动化三维测量实现注塑模具快速尺寸测量
  • ES6是什么
  • 课程网站开发 预算温州网络公司哪家最好
  • WebSocket 与 SSE 的区别,实际项目中应该怎么使用
  • 网站建设推广行业网站制作 江西
  • GPU 嗡嗡作响! Elastic 推理服务( EIS ):为 Elasticsearch 提供 GPU 加速推理
  • 前端碎碎念笔记:JavaScript 对象的封装与抽象
  • Spring Boot 3零基础教程,条件注解,笔记09
  • 余杭区住房与建设局网站wordpress目录权限
  • 认知觉醒 (一) · 感性
  • 谷歌站长平台承德市宽城县建设局网站
  • 【论文阅读】Sparks of Science
  • 论文笔记:π0.5 (PI 0.5)KI改进版
  • 【005】人个日程安排管理系统