Linux线程同步与互斥:彻底理解读者写者问题、读写锁原理、读者优先与写者优先(附pthread完整实验)

Linux线程同步与互斥:彻底理解读者写者问题、读写锁原理、读者优先与写者优先(附pthread完整实验)


🔥 星恒随风: 个人主页 ❄️ 个人专栏: 《指针合集》 | 《C语言基础》 | 《数据结构》 | 《机器学习导论》 | 《前端基础》 | 《python基础》 | 《C++从入门到入土》 | 《Linux的学习之旅》 ✨ 数据即知识,压缩即智能


文章目录

  • Linux线程同步与互斥:彻底理解读者写者问题、读写锁原理、读者优先与写者优先(附pthread完整实验)
  • 一、什么是读者---写者问题?
    • [1.1 两种角色](#1.1 两种角色)
    • [1.2 读者与读者](#1.2 读者与读者)
    • [1.3 读者与写者](#1.3 读者与写者)
    • [1.4 写者与写者](#1.4 写者与写者)
    • [1.5 读者写者问题的三种关系](#1.5 读者写者问题的三种关系)
  • 二、读者写者问题与生产者消费者问题有什么区别?
    • [2.1 生产者消费者问题](#2.1 生产者消费者问题)
    • [2.2 读者写者问题](#2.2 读者写者问题)
    • [2.3 二者的关键区别](#2.3 二者的关键区别)
    • [2.4 为什么不能把读者看成消费者?](#2.4 为什么不能把读者看成消费者?)
  • 三、为什么普通互斥锁不能很好地解决多读少写问题?
    • [3.1 使用Mutex保护所有访问](#3.1 使用Mutex保护所有访问)
    • [3.2 假设有10个读者](#3.2 假设有10个读者)
    • [3.3 但这些读操作本来可以并发](#3.3 但这些读操作本来可以并发)
    • [3.4 我们真正需要的不是完全互斥](#3.4 我们真正需要的不是完全互斥)
  • 四、怎样自己实现读者写者的同步逻辑?
    • [4.1 公共数据结构](#4.1 公共数据结构)
  • 五、读者怎样进入临界区?
    • [5.1 第一步:先保护reader_count](#5.1 第一步:先保护reader_count)
    • [5.2 第二步:判断是不是第一个读者](#5.2 第二步:判断是不是第一个读者)
    • [5.3 第三步:增加读者数量](#5.3 第三步:增加读者数量)
    • [5.4 第四步:释放计数器保护锁](#5.4 第四步:释放计数器保护锁)
    • [5.5 第五步:执行读取操作](#5.5 第五步:执行读取操作)
  • 六、第二个读者到来时会发生什么?
    • [6.1 第三个读者呢?](#6.1 第三个读者呢?)
  • 七、读者怎样退出临界区?
    • [7.1 第一步:加锁保护计数器](#7.1 第一步:加锁保护计数器)
    • [7.2 第二步:减少读者数量](#7.2 第二步:减少读者数量)
    • [7.3 第三个步骤为什么检查是否为0?](#7.3 第三个步骤为什么检查是否为0?)
    • [7.4 读者组的完整过程](#7.4 读者组的完整过程)
  • 八、写者怎样进入和退出?
    • [8.1 写者进入之前](#8.1 写者进入之前)
    • [8.2 写者开始执行](#8.2 写者开始执行)
    • [8.3 写者执行期间](#8.3 写者执行期间)
    • [8.4 写者退出以后](#8.4 写者退出以后)
    • [8.5 为什么写者不需要reader_count?](#8.5 为什么写者不需要reader_count?)
  • 九、将完整算法整理到一起
    • [9.1 公共部分](#9.1 公共部分)
    • [9.2 Reader](#9.2 Reader)
    • [9.3 Writer](#9.3 Writer)
    • [9.4 两个同步对象分别解决什么问题?](#9.4 两个同步对象分别解决什么问题?)
    • [9.5 为什么第一个读者要在count_lock保护下等待writer_lock?](#9.5 为什么第一个读者要在count_lock保护下等待writer_lock?)
    • [9.6 会不会因此让count_lock等待较长时间?](#9.6 会不会因此让count_lock等待较长时间?)
  • 十、为什么不能直接用两个pthread_mutex_t照搬上述伪代码?
    • [10.1 应该怎样正确实现?](#10.1 应该怎样正确实现?)
    • [10.2 正确的POSIX实现思路](#10.2 正确的POSIX实现思路)
    • [10.3 为什么实际开发中不必总是自己实现?](#10.3 为什么实际开发中不必总是自己实现?)
  • 十一、读者优先算法存在什么问题?
    • [11.1 假设Reader-1先进入](#11.1 假设Reader-1先进入)
    • [11.2 Writer-1随后到来](#11.2 Writer-1随后到来)
    • [11.3 Reader-2又到来](#11.3 Reader-2又到来)
    • [11.4 Reader-1退出](#11.4 Reader-1退出)
    • [11.5 Reader-3又到来](#11.5 Reader-3又到来)
    • [11.6 什么是饥饿?](#11.6 什么是饥饿?)
    • [11.7 读者优先不是逻辑错误](#11.7 读者优先不是逻辑错误)
  • 十二、读写锁是什么?
    • [12.1 读锁](#12.1 读锁)
    • [12.2 写锁](#12.2 写锁)
    • [12.3 读写锁的行为表](#12.3 读写锁的行为表)
    • [12.4 为什么读写锁适合多读少写?](#12.4 为什么读写锁适合多读少写?)
  • 十三、POSIX读写锁的基本类型
  • 十四、pthread_rwlock_init():初始化读写锁
    • [14.1 第一个参数](#14.1 第一个参数)
    • [14.2 第二个参数](#14.2 第二个参数)
    • [14.3 返回值](#14.3 返回值)
    • [14.4 静态初始化](#14.4 静态初始化)
  • 十五、pthread_rwlock_destroy():销毁读写锁
  • 十六、pthread_rwlock_rdlock():读加锁
    • [16.1 当前没有线程持锁](#16.1 当前没有线程持锁)
    • [16.2 当前已有其他读者持锁](#16.2 当前已有其他读者持锁)
    • [16.3 当前有写者持锁](#16.3 当前有写者持锁)
    • [16.4 为什么读锁仍然需要加锁?](#16.4 为什么读锁仍然需要加锁?)
  • 十七、pthread_rwlock_wrlock():写加锁
    • [17.1 当前有读者](#17.1 当前有读者)
    • [17.2 当前有其他写者](#17.2 当前有其他写者)
    • [17.3 当前没有任何持锁线程](#17.3 当前没有任何持锁线程)
  • 十八、pthread_rwlock_unlock():解锁
  • 十九、读者优先与写者优先
  • 二十、读者优先(Reader-Preference)
    • [20.1 什么是读者优先?](#20.1 什么是读者优先?)
    • [20.2 读者优先的优势](#20.2 读者优先的优势)
    • [20.3 读者优先的缺点](#20.3 读者优先的缺点)
  • 二十一、写者优先(Writer-Preference)
    • [21.1 什么是写者优先?](#21.1 什么是写者优先?)
    • [21.2 写者优先是否意味着可以打断正在读取的线程?](#21.2 写者优先是否意味着可以打断正在读取的线程?)
    • [21.3 写者优先的优势](#21.3 写者优先的优势)
    • [21.4 写者优先的缺点](#21.4 写者优先的缺点)
    • [21.5 两种策略对比](#21.5 两种策略对比)
  • 二十二、Linux怎样设置读写锁优先属性?
  • 二十三、完整实验:使用pthread实现多读者、多写者
    • [23.1 共享数据](#23.1 共享数据)
    • [23.2 读者线程](#23.2 读者线程)
    • [23.3 写者线程](#23.3 写者线程)
    • [23.4 为什么读取和写入时要sleep?](#23.4 为什么读取和写入时要sleep?)
  • 二十四、完整C++代码
    • [24.1 为什么使用vector保存线程ID?](#24.1 为什么使用vector保存线程ID?)
    • [24.2 为什么线程参数不使用局部循环变量地址?](#24.2 为什么线程参数不使用局部循环变量地址?)
    • [24.3 为什么使用printf而不是多次cout?](#24.3 为什么使用printf而不是多次cout?)
  • 二十五、编写Makefile
    • [25.1 编译](#25.1 编译)
    • [25.2 读者优先模式](#25.2 读者优先模式)
    • [25.3 写者优先模式](#25.3 写者优先模式)
    • [25.4 清理编译结果](#25.4 清理编译结果)
  • 二十六、分析运行结果
    • [26.1 读者优先的运行现象](#26.1 读者优先的运行现象)
    • [26.2 怎样证明两个读者可以同时读取?](#26.2 怎样证明两个读者可以同时读取?)
    • [26.3 怎样证明写者独占?](#26.3 怎样证明写者独占?)
    • [26.4 为什么实验输出不是固定的?](#26.4 为什么实验输出不是固定的?)
  • 二十七、怎样观察读者优先与写者优先的区别?
    • [27.1 实验一:读者较多](#27.1 实验一:读者较多)
    • [27.2 实验二:更换优先策略](#27.2 实验二:更换优先策略)
    • [27.3 实验三:修改读者进入间隔](#27.3 实验三:修改读者进入间隔)
    • [27.4 为什么不能通过一次运行证明没有饥饿?](#27.4 为什么不能通过一次运行证明没有饥饿?)
  • 二十八、为什么读写锁还需要考虑公平性?
    • [28.1 读者优先](#28.1 读者优先)
    • [28.2 写者优先](#28.2 写者优先)
    • [28.3 是否存在完全公平的策略?](#28.3 是否存在完全公平的策略?)
  • 二十九、读写锁与普通互斥锁的区别
    • [29.1 什么时候使用Mutex?](#29.1 什么时候使用Mutex?)
    • [29.2 什么时候考虑读写锁?](#29.2 什么时候考虑读写锁?)
  • 三十、读写锁使用中的常见错误
    • [30.1 写者错误地使用读锁](#30.1 写者错误地使用读锁)
    • [30.2 读者没有加锁](#30.2 读者没有加锁)
    • [30.3 提前解锁后继续访问共享数据](#30.3 提前解锁后继续访问共享数据)
    • [30.4 持有读锁时直接申请写锁](#30.4 持有读锁时直接申请写锁)
    • [30.5 在工作线程还没结束时销毁读写锁](#30.5 在工作线程还没结束时销毁读写锁)
    • [30.6 在写者优先模式中递归申请读锁](#30.6 在写者优先模式中递归申请读锁)
    • [30.7 认为读写锁一定没有饥饿问题](#30.7 认为读写锁一定没有饥饿问题)
  • 三十三、最终总结

前言:既然已经有互斥锁,为什么还需要读写锁?

在前面的 Linux 多线程编程中,我们已经学习了互斥锁:

cpp 复制代码
pthread_mutex_lock()
pthread_mutex_unlock()

通过互斥锁,可以确保多个线程不会同时进入需要排他访问的临界区。

例如:

cpp 复制代码
int shared_data = 100;

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

如果多个线程需要修改 shared_data,就可以使用互斥锁保护:

cpp 复制代码
pthread_mutex_lock(&mutex);

++shared_data;

pthread_mutex_unlock(&mutex);

这种做法解决了多线程并发修改共享数据的安全问题。

但是,现在考虑一种新的情况:

假设一个共享数据被读取的次数非常多,而修改次数非常少。

例如:

  • 多个线程查询系统配置信息。
  • 多个线程读取一份共享的数据表。
  • 多个线程查询某个缓存中的数据。
  • 偶尔有线程负责更新这些数据。

我们已经知道:

多个线程同时修改共享数据可能发生数据竞争。

但如果多个线程只读取同一份数据,并且没有线程正在修改它,还需要禁止这些读线程同时执行吗?

显然不一定。

例如:

cpp 复制代码
int shared_data = 100;

线程A读取:

cpp 复制代码
std::cout << shared_data << std::endl;

线程B也读取:

cpp 复制代码
std::cout << shared_data << std::endl;

只要相关数据在读取期间不会被不安全地修改,那么这两个读取操作本身并不冲突。

因此,我们真正希望实现的是:

允许多个读者并发读取,但写者必须独占共享资源。

这就是经典的:

读者---写者问题(Readers--Writers Problem)。

本文将从读者写者模型开始,对比生产者消费者模型,详细拆解两把锁和读者计数器的同步逻辑,最后使用 POSIX pthread_rwlock_t 实现完整实验,并分析读者优先、写者优先及线程饥饿问题。


一、什么是读者---写者问题?

1.1 两种角色

假设有一份共享数据:

cpp 复制代码
int shared_data = 0;

现在有两类线程需要访问它。

第一类:

读者(Reader)

它们只读取共享数据,不对数据进行修改。

例如:

cpp 复制代码
void Read()
{
    std::cout << shared_data << std::endl;
}

第二类:

写者(Writer)

它们需要修改共享数据。

例如:

cpp 复制代码
void Write()
{
    shared_data = 100;
}

那么问题来了:

读者与写者应该遵守怎样的并发规则?

1.2 读者与读者

假设:

text 复制代码
Reader-1
Reader-2
Reader-3

都只是读取:

cpp 复制代码
shared_data

只要没有其他线程正在修改相关数据,就没有必要让三个读者完全串行执行。

因此:

读者和读者之间允许并发。

也就是:

text 复制代码
读者 + 读者 → 可以共存

1.3 读者与写者

假设:

text 复制代码
Reader-1正在读取

同时:

text 复制代码
Writer-1正在修改

那么读者可能在写者修改共享状态的过程中进行访问。

例如,共享对象包含:

cpp 复制代码
struct Data
{
    int a;
    int b;
};

写者执行:

cpp 复制代码
data.a = 100;
data.b = 200;

读者却可能在两次修改之间读取相关字段。

这样就可能得到不符合业务预期的组合。

如果没有正确同步,还可能直接构成 C++ 语言层面的数据竞争。

因此:

读者和写者之间必须互斥。

text 复制代码
读者 + 写者 → 不允许同时进入受保护区域

1.4 写者与写者

假设两个写者同时修改:

cpp 复制代码
shared_data

同样可能产生数据竞争。

因此:

写者和写者之间必须互斥。

text 复制代码
写者 + 写者 → 不允许同时进入受保护区域

1.5 读者写者问题的三种关系

最终得到:

并发关系 是否允许同时访问 原因
读者---读者 允许 只读操作可以共享
读者---写者 不允许 读写同时进行可能产生不一致和数据竞争
写者---写者 不允许 并发修改可能破坏共享状态

因此可以将整个问题概括为:

读读共享,读写互斥,写写互斥。

这是理解读者写者问题最重要的结论。


二、读者写者问题与生产者消费者问题有什么区别?

在学习线程同步时,我们已经实现过生产者消费者模型:

text 复制代码
Producer
    |
    v
BlockingQueue
    |
    v
Consumer

那么读者写者模型和生产者消费者模型是不是相同的?

答案是:

不是。

两者虽然都需要线程同步,但研究的问题不同。

2.1 生产者消费者问题

生产者的主要职责是:

text 复制代码
生产数据

消费者的主要职责是:

text 复制代码
消费数据

例如:

cpp 复制代码
queue.push(task);

生产者向队列添加任务。

cpp 复制代码
task = queue.front();
queue.pop();

消费者从队列取出任务。

生产者消费者模型通常需要满足:

text 复制代码
队列为空 → 消费者等待

队列已满 → 生产者等待

因此,它的重点是:

不同角色根据资源数量和数据状态进行同步协作。

例如:

text 复制代码
生产一个任务
        |
        v
队列非空
        |
        v
消费者可以取走任务

2.2 读者写者问题

读者写者模型并不要求:

text 复制代码
读者读取一次,数据就减少一个

也不要求:

text 复制代码
写者必须在每个读者执行前产生一份新数据

例如:

cpp 复制代码
int shared_data = 100;

一个读者读取:

cpp 复制代码
100

另一个读者同样可以读取:

cpp 复制代码
100

读取操作并不会自然地把共享变量中的值取走。

因此,读者写者问题关注的主要是:

不同访问方式之间的并发兼容性。

2.3 二者的关键区别

对比维度 生产者消费者问题 读者写者问题
主要角色 生产者、消费者 读者、写者
共享资源 通常是缓冲区或任务队列 通常是共享数据
生产者/写者 增加待消费数据 修改共享数据
消费者/读者 取出或消费数据 读取数据,不改变其内容
主要同步条件 队列空、队列满 当前是否存在读者或写者
并发重点 协调生产与消费 允许并发读、保证写独占

例如:

text 复制代码
BlockingQueue

更适合从生产者消费者模型理解。

而:

text 复制代码
共享配置表

共享数据缓存

共享查询数据

则经常适合从读者写者模型理解。

2.4 为什么不能把读者看成消费者?

因为消费者通常会改变缓冲区的状态。

例如:

cpp 复制代码
queue.pop();

会从队列中删除一个元素。

而读者只是:

cpp 复制代码
value = shared_data;

不会因读取而删除共享数据。

所以:

读者和消费者不是同一个概念。

同样,写者也不一定是在向队列中生产新元素。


三、为什么普通互斥锁不能很好地解决多读少写问题?

严格来说:

普通互斥锁可以正确保护读者写者共享的数据。

但在某些场景下,性能可能不够理想。

3.1 使用Mutex保护所有访问

例如:

cpp 复制代码
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

int shared_data = 100;

读者:

cpp 复制代码
void Read()
{
    pthread_mutex_lock(&mutex);

    std::cout << shared_data << std::endl;

    pthread_mutex_unlock(&mutex);
}

写者:

cpp 复制代码
void Write()
{
    pthread_mutex_lock(&mutex);

    shared_data = 200;

    pthread_mutex_unlock(&mutex);
}

这种实现能够避免不安全的并发读写。

但它存在一个问题:

两个读者也不能同时进入临界区。

3.2 假设有10个读者

例如:

text 复制代码
Reader-1
Reader-2
Reader-3
...
Reader-10

每个读者的读取操作都需要:

text 复制代码
1秒

如果使用同一把普通互斥锁,那么:

text 复制代码
Reader-1读取1秒
        |
        v
Reader-2读取1秒
        |
        v
Reader-3读取1秒
        |
        v
...

在这个简化实验中,10次读取会因为互斥锁而完全串行。

总共需要大约:

text 复制代码
10秒

才能完成全部读取。

这里还没有计算线程调度等额外开销。

3.3 但这些读操作本来可以并发

因为:

text 复制代码
Reader-1没有修改共享数据

Reader-2也没有修改共享数据

所以在没有写者的情况下,多个读者本来可以同时进行读取。

如果10个读者具备并行执行条件,并且读取任务之间不存在其他串行约束,那么总耗时就可能明显降低。

当然,实际性能仍然受到 CPU 数量、缓存、内存带宽和任务规模等因素影响。

3.4 我们真正需要的不是完全互斥

普通互斥锁提供:

text 复制代码
任何时刻最多一个线程获得锁

但读者写者问题需要:

text 复制代码
多个读者可以共同获得读取权限

写者必须排他获得写入权限

于是便产生了:

读写锁(Read-Write Lock)。

但在介绍现成的读写锁接口之前,我们先自己推导一种实现思路。


四、怎样自己实现读者写者的同步逻辑?

现在尝试不直接使用:

cpp 复制代码
pthread_rwlock_t

而是通过普通同步机制,实现以下要求:

text 复制代码
读读共享
读写互斥
写写互斥

我们先思考一个问题:

多个读者共同读取的时候,怎样阻止写者进入?

可以考虑使用一个控制访问权的同步对象。

例如:

text 复制代码
resource_lock

当读者正在使用共享数据时,让写者无法取得写入权限。

但是,如果存在多个读者,我们又不能让它们彼此互斥。

所以还需要记录:

当前究竟有多少个读者正在读取?

因此可以引入:

cpp 复制代码
uint32_t reader_count = 0;

它用于保存当前已经进入读区的读者数量。

4.1 公共数据结构

先看抽象伪代码:

cpp 复制代码
uint32_t reader_count = 0;

lock_t count_lock;

lock_t writer_lock;

其中:

cpp 复制代码
reader_count

表示当前正在读取的读者数量。

cpp 复制代码
count_lock

用于保护 reader_count。

cpp 复制代码
writer_lock

用于控制共享数据的读写访问权。

注意:

这里的 lock_t 是抽象同步伪代码,并不直接等同于 POSIX pthread_mutex_t。

因为这个算法会让第一个读者获取 writer_lock,最后一个读者释放它,两者可能不是同一个线程。

而 POSIX 普通互斥锁要求遵守所有权规则,不能由不持有该锁的线程随意解锁。

后面会解释如何使用信号量正确表达这一算法。


五、读者怎样进入临界区?

首先看读者进入临界区的伪代码:

cpp 复制代码
lock(count_lock);

if (reader_count == 0)
{
    lock(writer_lock);
}

++reader_count;

unlock(count_lock);

// read;

这几行代码非常关键。

下面逐行分析。

5.1 第一步:先保护reader_count

cpp 复制代码
lock(count_lock);

为什么不能直接:

cpp 复制代码
++reader_count;

因为多个读者可能同时进入。

例如:

text 复制代码
reader_count = 0

两个读者同时执行:

cpp 复制代码
++reader_count;

普通整数递增操作不一定具有原子性。

因此可能发生数据竞争或者丢失更新。

所以:

对 reader_count 的检查与修改必须受到同步保护。

5.2 第二步:判断是不是第一个读者

cpp 复制代码
if (reader_count == 0)
{
    lock(writer_lock);
}

为什么只让第一个读者获取 writer_lock?

我们先假设:

text 复制代码
当前没有任何读者

即:

text 复制代码
reader_count = 0

这时第一个读者准备进入。

它必须确保:

text 复制代码
当前没有写者正在修改共享数据

因此它需要获取控制共享资源访问权的同步对象。

可以理解为:

text 复制代码
第一个读者负责关闭写者的进入通道

只要这一组读者还没有全部离开,写者就不能进入。

5.3 第三步:增加读者数量

cpp 复制代码
++reader_count;

第一个读者进入以后:

text 复制代码
reader_count = 1

表示当前有一个读者正在读取。

5.4 第四步:释放计数器保护锁

cpp 复制代码
unlock(count_lock);

为什么要释放?

因为 count_lock 只负责保护:

cpp 复制代码
reader_count

它不需要在整个读取过程中一直由同一个读者持有。

否则其他读者就无法修改计数器,也就无法并发进入读区。

所以读者需要尽快释放计数器锁。

5.5 第五步:执行读取操作

cpp 复制代码
// read;

此时读者已经成功进入读区。

例如:

cpp 复制代码
std::cout << shared_data << std::endl;

六、第二个读者到来时会发生什么?

假设:

text 复制代码
Reader-1已经进入

此时:

text 复制代码
reader_count = 1

然后:

text 复制代码
Reader-2准备进入

它同样先执行:

cpp 复制代码
lock(count_lock);

接下来检查:

cpp 复制代码
if (reader_count == 0)

但是:

text 复制代码
reader_count = 1

所以条件不成立。

因此 Reader-2 不需要再次获取 writer_lock。

随后执行:

cpp 复制代码
++reader_count;

得到:

text 复制代码
reader_count = 2

最后释放:

cpp 复制代码
count_lock

并开始读取。

于是:

text 复制代码
Reader-1正在读取

Reader-2也正在读取

两者能够并发执行。

这就实现了:

读者与读者共享。

6.1 第三个读者呢?

同样如此。

如果此时:

text 复制代码
reader_count = 2

那么第三个读者进入以后:

text 复制代码
reader_count = 3

它也不需要重新获取 writer_lock。

因此我们可以得出:

只要已有读者正在读取,后续读者在取得计数器锁并完成检查后,就可以加入当前读者组。

这正是读者优先方案的基本思想。


七、读者怎样退出临界区?

读取完成后,还需要正确退出。

伪代码:

cpp 复制代码
lock(count_lock);

--reader_count;

if (reader_count == 0)
{
    unlock(writer_lock);
}

unlock(count_lock);

继续逐行分析。

7.1 第一步:加锁保护计数器

cpp 复制代码
lock(count_lock);

因为多个读者可能同时结束。

所以对:

cpp 复制代码
reader_count

的修改仍然需要保护。

7.2 第二步:减少读者数量

cpp 复制代码
--reader_count;

例如:

text 复制代码
reader_count = 3

一个读者退出以后:

text 复制代码
reader_count = 2

表示还有两个读者正在读取。

7.3 第三个步骤为什么检查是否为0?

cpp 复制代码
if (reader_count == 0)
{
    unlock(writer_lock);
}

因为只有在:

text 复制代码
所有读者全部退出

之后,才能允许写者进入。

例如:

text 复制代码
reader_count = 3

第一个读者退出:

text 复制代码
reader_count = 2

此时不能释放写者访问权。

第二个读者退出:

text 复制代码
reader_count = 1

依然不能释放。

第三个读者退出:

text 复制代码
reader_count = 0

这时说明:

text 复制代码
已经没有任何读者正在读取

所以:

cpp 复制代码
unlock(writer_lock);

允许等待中的写者获得访问权。

因此:

最后一个读者负责释放整个读者组占用的共享资源访问权。

7.4 读者组的完整过程

假设三个读者依次进入:

text 复制代码
初始:
reader_count = 0

Reader-1进入:

text 复制代码
获取writer_lock

reader_count = 1

Reader-2进入:

text 复制代码
reader_count = 2

Reader-3进入:

text 复制代码
reader_count = 3

然后三个读者逐渐退出:

text 复制代码
Reader-2退出:
reader_count = 2
text 复制代码
Reader-1退出:
reader_count = 1
text 复制代码
Reader-3退出:
reader_count = 0

释放writer_lock

请注意:

最后退出的读者,不一定是最先进入的读者。

这正是我们必须谨慎区分抽象同步锁与 POSIX 互斥锁的原因。


八、写者怎样进入和退出?

与读者相比,写者的逻辑反而非常简单。

伪代码:

cpp 复制代码
lock(writer_lock);

// write;

unlock(writer_lock);

8.1 写者进入之前

写者必须先获取:

cpp 复制代码
writer_lock

如果已经存在读者:

text 复制代码
reader_count > 0

那么读者组仍然持有共享资源的访问权。

所以写者需要等待。

8.2 写者开始执行

只有当写者成功取得访问权以后,才能进入写区。

例如:

cpp 复制代码
shared_data = 200;

8.3 写者执行期间

由于写者独占该访问权:

text 复制代码
其他写者不能进入

同时:

text 复制代码
第一个准备进入的读者也无法取得访问权

因此:

写者执行时,既排斥其他写者,也排斥读者。

8.4 写者退出以后

写者释放:

cpp 复制代码
writer_lock

之后,等待中的读者或其他写者才有机会获得访问权。

8.5 为什么写者不需要reader_count?

因为写者只允许一个线程独占共享资源。

不像读者需要组织一个可以共同进入的读者组。

所以写者只需要取得和释放对应的独占访问权。


九、将完整算法整理到一起

9.1 公共部分

cpp 复制代码
uint32_t reader_count = 0;

lock_t count_lock;

lock_t writer_lock;

9.2 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);

9.3 Writer

cpp 复制代码
lock(writer_lock);

// 临界区:修改共享资源

write();

unlock(writer_lock);

9.4 两个同步对象分别解决什么问题?

同步对象 主要职责
count_lock 保护 reader_count 的检查与更新
writer_lock 协调读者组和写者对共享数据的互斥访问

这里特别容易混淆的是:

count_lock 并不负责在整个读取期间保护共享数据。

它仅保护计数器操作。

真正阻止写者进入的是读者组持有的访问许可。

9.5 为什么第一个读者要在count_lock保护下等待writer_lock?

考虑以下情况。

如果两个读者都发现:

text 复制代码
reader_count = 0

然后同时尝试代表读者组获得访问权,就可能破坏整个算法的状态管理。

因此:

cpp 复制代码
if (reader_count == 0)

与:

cpp 复制代码
++reader_count;

必须处于协调好的临界区之中。

当第一个读者正在等待共享资源许可时,其他读者不能绕过它直接把计数器增加并进入读区。

所以这里将计数器锁与访问权获取过程组合起来,保证读者组的进入状态正确。

9.6 会不会因此让count_lock等待较长时间?

有可能。

例如:

text 复制代码
当前一个写者正在写入

第一个读者执行:

cpp 复制代码
lock(count_lock);

以后,尝试获得:

cpp 复制代码
writer_lock

却发现写者仍在使用共享资源。

因此第一个读者需要等待。

在此期间,它仍然占有:

cpp 复制代码
count_lock

后续读者也会被暂时阻塞在计数器保护锁上。

这是这一简化算法为了保证状态正确而采用的同步方式。


十、为什么不能直接用两个pthread_mutex_t照搬上述伪代码?

这个问题非常关键。

观察:

cpp 复制代码
if (reader_count == 0)
{
    lock(writer_lock);
}

第一个读者获得 writer_lock。

但退出时:

cpp 复制代码
if (reader_count == 0)
{
    unlock(writer_lock);
}

最后一个读者释放 writer_lock。

问题是:

第一个读者和最后一个读者可能是不同线程。

例如:

text 复制代码
Reader-1获取writer_lock
        |
        v
Reader-2进入
        |
        v
Reader-1先退出
        |
        v
Reader-2最后退出并释放writer_lock

如果这里使用的是 POSIX:

cpp 复制代码
pthread_mutex_t writer_lock;

那么 Reader-2 就可能试图释放 Reader-1 持有的互斥锁。

这违反了 POSIX 互斥锁的所有权规则。

因此:

不能将这里的抽象 lock_t 机械替换成 pthread_mutex_t。

10.1 应该怎样正确实现?

一种方法是使用 POSIX 信号量。

前面已经学习过:

cpp 复制代码
sem_wait()
sem_post()

而信号量可以用来表示访问许可。

初始化:

cpp 复制代码
sem_t resource_sem;

sem_init(&resource_sem, 0, 1);

其中初始值:

text 复制代码
1

表示当前有一个可以获得的资源访问许可。

当第一个读者进入时:

cpp 复制代码
sem_wait(&resource_sem);

读者组占用这份许可。

当最后一个读者退出时:

cpp 复制代码
sem_post(&resource_sem);

归还许可。

与 POSIX 普通互斥锁不同,未命名信号量的 sem_post() 并不要求必须由当初执行 sem_wait() 的同一线程调用。

因此可以正确表达:

text 复制代码
第一个读者取得许可

最后一个读者归还许可

10.2 正确的POSIX实现思路

下面使用:

cpp 复制代码
pthread_mutex_t count_mutex;

保护读者计数器。

使用:

cpp 复制代码
sem_t resource_sem;

协调读者组与写者。

核心代码:

cpp 复制代码
#include <cstdint>
#include <pthread.h>
#include <semaphore.h>

uint32_t reader_count = 0;

pthread_mutex_t count_mutex =
    PTHREAD_MUTEX_INITIALIZER;

sem_t resource_sem;

在开始创建线程之前初始化:

cpp 复制代码
sem_init(&resource_sem, 0, 1);

读者进入:

cpp 复制代码
pthread_mutex_lock(&count_mutex);

if (reader_count == 0)
{
    // 简化示意:实际代码需处理EINTR等错误
    sem_wait(&resource_sem);
}

++reader_count;

pthread_mutex_unlock(&count_mutex);

读者退出:

cpp 复制代码
pthread_mutex_lock(&count_mutex);

--reader_count;

if (reader_count == 0)
{
    sem_post(&resource_sem);
}

pthread_mutex_unlock(&count_mutex);

写者:

cpp 复制代码
sem_wait(&resource_sem);

// write();

sem_post(&resource_sem);

这样就能用 POSIX 同步原语表达前面推导的算法。

完整程序中还需要处理:

text 复制代码
sem_wait被信号中断

系统调用失败

线程退出与资源销毁

等情况。

但这些不会改变读者写者算法的核心思想。

10.3 为什么实际开发中不必总是自己实现?

因为 POSIX 已经提供了现成的:

cpp 复制代码
pthread_rwlock_t

可以直接实现读者共享、写者独占的语义。

自己推导两把锁和计数器,主要是为了理解读写锁背后的同步思想。

真正编写应用程序时,通常可以优先考虑成熟的读写锁接口。


十一、读者优先算法存在什么问题?

前面的算法能够保证:

text 复制代码
读读共享
读写互斥
写写互斥

但它还涉及一个新的问题:

如果读者一直不断到来,写者什么时候才能执行?

11.1 假设Reader-1先进入

初始:

text 复制代码
reader_count = 0

Reader-1进入:

text 复制代码
reader_count = 1

读者组取得共享资源访问权。

11.2 Writer-1随后到来

Writer-1需要:

cpp 复制代码
lock(writer_lock);

但此时读者组仍然正在读取。

所以 Writer-1等待。

11.3 Reader-2又到来

此时:

text 复制代码
reader_count = 1

Reader-2不需要重新申请读者组的共享资源许可。

于是:

text 复制代码
reader_count = 2

Reader-2也开始读取。

11.4 Reader-1退出

计数器变为:

text 复制代码
reader_count = 1

因为还有 Reader-2,所以读者组仍然保留访问权。

Writer-1继续等待。

11.5 Reader-3又到来

Reader-3进入以后:

text 复制代码
reader_count = 2

然后 Reader-2退出:

text 复制代码
reader_count = 1

接着 Reader-4到来:

text 复制代码
reader_count = 2

如果读者持续不断地到达,就可能一直保持:

text 复制代码
reader_count > 0

这意味着:

text 复制代码
读者组一直不释放共享资源访问权

于是 Writer-1可能长时间得不到执行机会。

11.6 什么是饥饿?

在线程同步中:

如果某个线程长时间无法获得继续执行所需的资源,称为饥饿(Starvation)。

这里的 Writer-1 就可能发生:

text 复制代码
写者饥饿

因为读者能够不断加入当前读者组,而写者需要等到所有读者全部离开。

11.7 读者优先不是逻辑错误

需要注意:

前面算法的互斥关系依然正确。

问题不在于:

text 复制代码
多个读者和写者同时修改数据

而在于:

text 复制代码
写者等待时间可能没有合理上界

因此:

并发正确性与调度公平性是两个不同问题。


十二、读写锁是什么?

现在正式认识 POSIX 读写锁。

读写锁:

text 复制代码
Read-Write Lock

可以理解为一种区分读取权限和写入权限的同步机制。

与普通互斥锁不同,它具有两种主要加锁方式:

text 复制代码
读加锁

和:

text 复制代码
写加锁

12.1 读锁

多个线程可以在没有写者持锁的情况下,共同获得读锁。

例如:

text 复制代码
Reader-1获得读锁

Reader-2获得读锁

Reader-3获得读锁

三个线程可以同时进行读取。

12.2 写锁

写锁要求独占访问权。

例如:

text 复制代码
Writer-1获得写锁

那么:

text 复制代码
其他写者不能获得写锁

并且:

text 复制代码
读者也不能获得读锁

只有等写者释放写锁以后,其他线程才能根据对应规则继续获取锁。

12.3 读写锁的行为表

当前锁状态 新读锁请求 新写锁请求
无锁 可以获得 可以获得
已有读锁 通常允许并发读,具体受优先策略影响 需要等待
已有写锁 需要等待 需要等待

这里特别需要注意第二行:

已有读锁时,新读者能否立即加入,可能受到写者是否已经等待以及具体读写锁优先策略的影响。

从基本互斥关系看:

text 复制代码
多个读者可以共存

但从调度策略看:

text 复制代码
有等待写者时,新的读者是否可以插队

就属于需要进一步设计的问题。

12.4 为什么读写锁适合多读少写?

假设:

text 复制代码
90%的访问是读取

10%的访问是写入

普通互斥锁可能让大量独立的读取操作全部串行执行。

而读写锁可以允许多个读者共同获取读取权限。

因此,在读取操作较多、临界区有一定执行时间的场景中,读写锁可能提高并发效率。

但如果临界区非常短,或者写入非常频繁,读写锁不一定比普通互斥锁快。

因为读写锁自身也需要管理读者数量、等待写者和其他内部同步状态。

所以:

读写锁更适合多读少写,但不意味着在所有场景下都具有更高性能。


十三、POSIX读写锁的基本类型

POSIX 提供:

cpp 复制代码
pthread_rwlock_t

用于表示读写锁。

例如:

cpp 复制代码
pthread_rwlock_t rwlock;

在使用它之前,需要初始化。


十四、pthread_rwlock_init():初始化读写锁

函数原型:

cpp 复制代码
#include <pthread.h>

int pthread_rwlock_init(
    pthread_rwlock_t* rwlock,
    const pthread_rwlockattr_t* attr
);

14.1 第一个参数

cpp 复制代码
pthread_rwlock_t* rwlock

表示需要初始化的读写锁对象。

例如:

cpp 复制代码
pthread_rwlock_t rwlock;

调用:

cpp 复制代码
pthread_rwlock_init(&rwlock, nullptr);

14.2 第二个参数

cpp 复制代码
const pthread_rwlockattr_t* attr

用于设置读写锁属性。

如果传入:

cpp 复制代码
nullptr

表示使用默认属性。

例如:

cpp 复制代码
pthread_rwlock_init(&rwlock, nullptr);

14.3 返回值

成功时返回:

text 复制代码
0

失败时返回错误码。

与之前学习的 pthread_create() 类似,很多 pthread 接口通过返回值提供错误码。

因此可以使用:

cpp 复制代码
int ret = pthread_rwlock_init(&rwlock, nullptr);

if (ret != 0)
{
    std::cerr << strerror(ret) << std::endl;
}

检查初始化是否成功。

14.4 静态初始化

对于适合静态初始化的读写锁,也可以使用:

cpp 复制代码
pthread_rwlock_t rwlock =
    PTHREAD_RWLOCK_INITIALIZER;

但如果需要专门设置读写优先属性,通常应使用属性对象进行初始化。


十五、pthread_rwlock_destroy():销毁读写锁

函数原型:

cpp 复制代码
int pthread_rwlock_destroy(
    pthread_rwlock_t* rwlock
);

例如:

cpp 复制代码
pthread_rwlock_destroy(&rwlock);

为什么要销毁读写锁?

因为同步对象也需要正确管理生命周期。

但需要满足:

销毁时,已经没有线程继续使用或者等待这把读写锁。

例如:

cpp 复制代码
pthread_join(tid, nullptr);

等待相关工作线程结束以后,再销毁对应读写锁。

不应该在线程仍然持锁或正在等待时,直接销毁锁对象。


十六、pthread_rwlock_rdlock():读加锁

函数原型:

cpp 复制代码
int pthread_rwlock_rdlock(
    pthread_rwlock_t* rwlock
);

例如:

cpp 复制代码
pthread_rwlock_rdlock(&rwlock);

表示当前线程请求获得:

读锁。

16.1 当前没有线程持锁

读者可以获取读锁。

16.2 当前已有其他读者持锁

如果优先策略允许,新读者可以继续获得读锁。

这就支持:

text 复制代码
Reader-1读取

Reader-2读取

Reader-3读取

并发执行。

16.3 当前有写者持锁

读者不能同时进入。

因此需要等待写者释放写锁。

16.4 为什么读锁仍然需要加锁?

因为我们需要阻止:

text 复制代码
读者读取的同时,写者修改数据

所以:

允许读者之间共享,不意味着读取过程完全不需要同步。


十七、pthread_rwlock_wrlock():写加锁

函数原型:

cpp 复制代码
int pthread_rwlock_wrlock(
    pthread_rwlock_t* rwlock
);

例如:

cpp 复制代码
pthread_rwlock_wrlock(&rwlock);

表示当前线程请求:

独占写锁。

只有在满足锁的获取条件时,写者才能继续执行。

17.1 当前有读者

写者必须等待相应读锁释放。

17.2 当前有其他写者

新写者也必须等待。

17.3 当前没有任何持锁线程

写者可以获得写锁。

所以:

写锁同时排斥读者和其他写者。


十八、pthread_rwlock_unlock():解锁

函数原型:

cpp 复制代码
int pthread_rwlock_unlock(
    pthread_rwlock_t* rwlock
);

它既可以用于释放读锁,也可以用于释放写锁。

例如:

读者:

cpp 复制代码
pthread_rwlock_rdlock(&rwlock);

// read

pthread_rwlock_unlock(&rwlock);

写者:

cpp 复制代码
pthread_rwlock_wrlock(&rwlock);

// write

pthread_rwlock_unlock(&rwlock);

这里有一个容易产生疑问的地方:

为什么读锁和写锁都使用同一个unlock函数?

因为锁的内部状态与线程所持有的锁模式,已经由对应实现进行管理。

调用者只需要在合法的持锁状态下释放相应锁即可。

不能在未获得锁的情况下随意调用 pthread_rwlock_unlock()。


十九、读者优先与写者优先

已经了解基本读写锁接口以后,还有一个需要解决的问题:

当读者和写者都希望获得锁时,应该优先满足哪一方?

这就引出了两种典型调度策略:

text 复制代码
Reader-Preference

和:

text 复制代码
Writer-Preference

即:

读者优先和写者优先。


二十、读者优先(Reader-Preference)

20.1 什么是读者优先?

读者优先强调:

在符合相应锁获取规则的情况下,尽量允许读者进入共享读区。

尤其是在已有读者持有读锁时,新的读者仍可能被允许加入。

例如:

text 复制代码
Reader-1正在读取
        |
        v
Writer-1等待
        |
        v
Reader-2到达
        |
        v
Reader-2也获得读锁

这样能够提高读者的并发程度。

20.2 读者优先的优势

因为多个读者可以同时执行:

text 复制代码
读取等待时间可能更短

在读操作频繁的系统中,有利于提高读请求的处理效率。

20.3 读者优先的缺点

如果读者不断到达:

text 复制代码
Reader-1
Reader-2
Reader-3
Reader-4
...

而读锁一直被至少一个线程持有,写者就可能长时间无法获得独占锁。

因此可能出现:

写者饥饿。


二十一、写者优先(Writer-Preference)

21.1 什么是写者优先?

写者优先的基本思想是:

当有写者正在等待获得写锁时,应限制后续读者继续无限制地加入读区,使写者有机会尽快获得独占访问权。

例如:

text 复制代码
Reader-1正在读取
        |
        v
Writer-1请求写锁
        |
        v
Reader-2到达

在写者优先策略下:

text 复制代码
Reader-2可能需要等待

而不是继续加入 Reader-1 所在的读者组。

21.2 写者优先是否意味着可以打断正在读取的线程?

不是。

假设:

text 复制代码
Reader-1已经获得读锁

那么 Writer-1 后来请求写锁时,并不能直接强制 Reader-1 终止读取。

正确的基本顺序仍然是:

text 复制代码
Reader-1完成读取
        |
        v
释放读锁
        |
        v
Writer-1获得写锁
        |
        v
开始写入

写者优先主要影响:

后续竞争者获得锁的顺序。

而不是强制剥夺已经合法获得的读锁。

21.3 写者优先的优势

可以减少写者被源源不断的新读者插队的情况。

因此有利于控制写者等待时间。

21.4 写者优先的缺点

如果不断有写者到达:

text 复制代码
Writer-1
Writer-2
Writer-3
...

某些严格的写者优先策略可能让读者长时间等待。

因此也可能产生:

读者饥饿。

21.5 两种策略对比

维度 读者优先 写者优先
优先考虑 读请求 等待中的写请求
多读者并发 支持 支持,但可能限制新读者进入
对写者的影响 可能长期等待 通常更有利于写者取得锁
潜在饥饿问题 写者饥饿 读者饥饿
主要特点 强调读取并发性 强调写入及时性

需要注意,具体线程唤醒和调度顺序仍然受到锁实现及系统调度的影响。


二十二、Linux怎样设置读写锁优先属性?

在使用 glibc 的 Linux 环境中,可以通过 GNU 扩展接口:

cpp 复制代码
pthread_rwlockattr_setkind_np()

设置读写锁的相关优先策略。

函数原型:

cpp 复制代码
int pthread_rwlockattr_setkind_np(
    pthread_rwlockattr_t* attr,
    int pref
);

其中:

cpp 复制代码
attr

表示读写锁属性对象。

cpp 复制代码
pref

表示选择的读写锁类型。

22.1 三种常见设置

第一种:PTHREAD_RWLOCK_PREFER_READER_NP

cpp 复制代码
PTHREAD_RWLOCK_PREFER_READER_NP

表示读者优先。

它是 glibc 中常见的默认设置。

特点:

text 复制代码
优先考虑读者

可能导致写者饥饿

第二种:PTHREAD_RWLOCK_PREFER_WRITER_NP

cpp 复制代码
PTHREAD_RWLOCK_PREFER_WRITER_NP

从名字来看,似乎表示写者优先。

但需要特别注意:

glibc 实际上忽略了这一选项的写者优先意图,其实际表现与默认读者优先模式相近。

主要原因与支持递归获取读锁时可能产生的死锁问题有关。

因此,在 glibc 中,不能只因为设置了这个枚举值,就认为已经获得有效的写者优先行为。

第三种:PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP

cpp 复制代码
PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP

这是 glibc 提供的另一种写者优先设置。

它能够在规定的使用约束下减少写者饥饿问题。

但是名称中的:

text 复制代码
NONRECURSIVE

需要正确理解。

它要求调用者不要递归获取读锁。

不是简单地说"写者不能递归加锁"。

例如,在使用这种模式时,不应该让同一个线程已经持有读锁后,再次申请同一把读写锁的读锁。

因为这种重复获取方式可能与等待写者的优先处理规则发生冲突。

22.2 为什么递归读锁可能造成问题?

假设:

text 复制代码
Reader-1已经持有读锁

然后:

text 复制代码
Writer-1开始等待写锁

如果采用写者优先策略,就可能阻止后续读锁请求继续插队。

但 Reader-1 又尝试:

cpp 复制代码
pthread_rwlock_rdlock(&rwlock);

第二次获取同一把读锁。

假设它因为写者等待而被阻塞。

那么出现:

text 复制代码
Reader-1等待再次获得读锁

但:

text 复制代码
Writer-1需要Reader-1释放原来的读锁

Reader-1 又因为无法完成第二次读锁请求而不能继续执行到释放原读锁的位置。

于是可能形成死锁。

这就是为什么 glibc 的写者优先模式需要明确的非递归读锁约束。

22.3 函数名称里的_np是什么意思?

text 复制代码
_np = non-portable

表示这是一类非可移植扩展接口。

因此:

cpp 复制代码
pthread_rwlockattr_setkind_np()

不是所有 POSIX 系统都必须支持的标准函数。

在 Linux/glibc 环境中可以使用,但跨平台编程时需要注意。

22.4 读写锁属性的完整设置步骤

首先:

cpp 复制代码
pthread_rwlockattr_t attr;

初始化属性对象:

cpp 复制代码
pthread_rwlockattr_init(&attr);

设置写者优先:

cpp 复制代码
pthread_rwlockattr_setkind_np(
    &attr,
    PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP
);

使用属性初始化读写锁:

cpp 复制代码
pthread_rwlock_init(&rwlock, &attr);

最后销毁属性对象:

cpp 复制代码
pthread_rwlockattr_destroy(&attr);

注意:

销毁属性对象不等于销毁已经初始化完成的读写锁。

读写锁仍然需要在适当时机使用:

cpp 复制代码
pthread_rwlock_destroy(&rwlock);

单独销毁。


二十三、完整实验:使用pthread实现多读者、多写者

现在开始编写一个可以直接运行的读写锁实验。

实验目标:

  1. 创建多个读者线程。
  2. 创建多个写者线程。
  3. 读者通过读锁读取共享数据。
  4. 写者通过写锁修改共享数据。
  5. 观察多个读者是否能够同时进入读区。
  6. 观察写者是否独占共享数据。
  7. 切换读者优先和写者优先模式,比较运行现象。

23.1 共享数据

使用:

cpp 复制代码
int shared_data = 0;

作为共享资源。

再定义:

cpp 复制代码
pthread_rwlock_t rwlock;

保护它。

23.2 读者线程

每个读者循环:

text 复制代码
申请读锁
    |
    v
打印当前共享数据
    |
    v
模拟耗时的读取操作
    |
    v
释放读锁

23.3 写者线程

每个写者循环:

text 复制代码
申请写锁
    |
    v
修改共享数据
    |
    v
模拟耗时的写入操作
    |
    v
释放写锁

23.4 为什么读取和写入时要sleep?

在普通实验中:

cpp 复制代码
shared_data = 100;

或者:

cpp 复制代码
int value = shared_data;

执行时间可能非常短。

为了更方便地观察锁之间的竞争关系,我们在持有锁期间加入:

cpp 复制代码
sleep(1);

和:

cpp 复制代码
sleep(2);

其中:

text 复制代码
读者模拟读取1秒

写者模拟写入2秒

这只是为了放大实验中的时间差异。

正常业务中不应该为了演示线程并发而无故长时间持有锁。


二十四、完整C++代码

为了便于正常编译、运行和结束,下面使用固定轮数代替无限循环。

同时保留最核心的:

cpp 复制代码
Reader()
Writer()
pthread_rwlock_rdlock()
pthread_rwlock_wrlock()
pthread_rwlock_unlock()

实现方式。

文件名:

text 复制代码
reader_writer_lock_test.cc

代码如下:

cpp 复制代码
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif

#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <vector>
#include <cstdlib>
#include <ctime>
#include <cstdio>
#include <cstring>
#include <string>

// ================================
// 共享资源
// ================================

int shared_data = 0;

// POSIX读写锁
pthread_rwlock_t rwlock;

// 线程参数
struct ThreadArg
{
    int id;
    int rounds;
};

// 检查pthread接口的返回值
void CheckPthread(int ret, const char* function)
{
    if (ret != 0)
    {
        std::fprintf(
            stderr,
            "%s failed: %s\n",
            function,
            std::strerror(ret)
        );

        std::exit(EXIT_FAILURE);
    }
}

// ================================
// 读者线程
// ================================

void* Reader(void* arg)
{
    ThreadArg* info =
        static_cast<ThreadArg*>(arg);

    int number = info->id;

    for (int i = 0; i < info->rounds; ++i)
    {
        // 申请读锁
        CheckPthread(
            pthread_rwlock_rdlock(&rwlock),
            "pthread_rwlock_rdlock"
        );

        // 读取共享数据
        std::printf(
            "[读者-%d] 进入读区,数据:%d\n",
            number,
            shared_data
        );

        // 模拟耗时的读取操作
        sleep(1);

        std::printf(
            "[读者-%d] 离开读区\n",
            number
        );

        // 释放读锁
        CheckPthread(
            pthread_rwlock_unlock(&rwlock),
            "pthread_rwlock_unlock"
        );

        // 模拟下一次读取之前的间隔
        usleep(100000);
    }

    return nullptr;
}

// ================================
// 写者线程
// ================================

void* Writer(void* arg)
{
    ThreadArg* info =
        static_cast<ThreadArg*>(arg);

    int number = info->id;

    for (int i = 0; i < info->rounds; ++i)
    {
        // 申请写锁
        CheckPthread(
            pthread_rwlock_wrlock(&rwlock),
            "pthread_rwlock_wrlock"
        );

        // 修改共享数据
        shared_data = std::rand() % 100;

        std::printf(
            "[写者-%d] 进入写区,新数据:%d\n",
            number,
            shared_data
        );

        // 模拟耗时的写入操作
        sleep(2);

        std::printf(
            "[写者-%d] 离开写区\n",
            number
        );

        // 释放写锁
        CheckPthread(
            pthread_rwlock_unlock(&rwlock),
            "pthread_rwlock_unlock"
        );

        // 模拟两次写入之间的间隔
        usleep(100000);
    }

    return nullptr;
}

// ================================
// 主线程
// ================================

int main(int argc, char* argv[])
{
    std::srand(
        static_cast<unsigned int>(
            std::time(nullptr) ^ getpid()
        )
    );

    // 可修改读者和写者数量进行对比
    const int reader_num = 2;
    const int writer_num = 2;

    // 每个线程执行的轮数
    const int reader_rounds = 3;
    const int writer_rounds = 2;

    const int total = reader_num + writer_num;

    // 根据命令行选择策略
    std::string mode = "reader";

    if (argc >= 2)
    {
        mode = argv[1];
    }

    if (mode != "reader" && mode != "writer")
    {
        std::cerr
            << "Usage: "
            << argv[0]
            << " [reader|writer]"
            << std::endl;

        return 1;
    }

    // 初始化属性对象
    pthread_rwlockattr_t attr;

    CheckPthread(
        pthread_rwlockattr_init(&attr),
        "pthread_rwlockattr_init"
    );

    // 选择优先策略
    int preference =
        PTHREAD_RWLOCK_PREFER_READER_NP;

    if (mode == "writer")
    {
        preference =
            PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP;
    }

    CheckPthread(
        pthread_rwlockattr_setkind_np(
            &attr,
            preference
        ),
        "pthread_rwlockattr_setkind_np"
    );

    // 初始化读写锁
    CheckPthread(
        pthread_rwlock_init(&rwlock, &attr),
        "pthread_rwlock_init"
    );

    // 初始化完成后可以销毁属性对象
    CheckPthread(
        pthread_rwlockattr_destroy(&attr),
        "pthread_rwlockattr_destroy"
    );

    std::cout
        << "当前策略:"
        << mode
        << std::endl;

    // 保存线程ID
    std::vector<pthread_t> threads(total);

    // 保存线程参数
    std::vector<ThreadArg> args(total);

    int created = 0;

    // 创建读者线程
    for (int i = 0; i < reader_num; ++i)
    {
        args[i] = {i, reader_rounds};

        int ret = pthread_create(
            &threads[i],
            nullptr,
            Reader,
            &args[i]
        );

        if (ret != 0)
        {
            std::fprintf(
                stderr,
                "pthread_create Reader failed: %s\n",
                std::strerror(ret)
            );

            break;
        }

        ++created;
    }

    // 读者创建成功后再创建写者
    if (created == reader_num)
    {
        for (int i = 0; i < writer_num; ++i)
        {
            int index = reader_num + i;

            args[index] = {i, writer_rounds};

            int ret = pthread_create(
                &threads[index],
                nullptr,
                Writer,
                &args[index]
            );

            if (ret != 0)
            {
                std::fprintf(
                    stderr,
                    "pthread_create Writer failed: %s\n",
                    std::strerror(ret)
                );

                break;
            }

            ++created;
        }
    }

    // 等待已经成功创建的全部线程
    for (int i = 0; i < created; ++i)
    {
        CheckPthread(
            pthread_join(threads[i], nullptr),
            "pthread_join"
        );
    }

    // 销毁读写锁
    CheckPthread(
        pthread_rwlock_destroy(&rwlock),
        "pthread_rwlock_destroy"
    );

    std::cout
        << "所有已创建线程结束,最终数据:"
        << shared_data
        << std::endl;

    return created == total ? 0 : 1;
}

24.1 为什么使用vector保存线程ID?

因为我们可能需要调整:

cpp 复制代码
reader_num

和:

cpp 复制代码
writer_num

例如:

cpp 复制代码
const int reader_num = 8;
const int writer_num = 2;

如果使用:

cpp 复制代码
std::vector<pthread_t> threads(total);

就能够方便地根据运行时确定的数量保存线程 ID。

同时避免依赖非标准的 C++ 变长数组写法。

24.2 为什么线程参数不使用局部循环变量地址?

错误写法:

cpp 复制代码
for (int i = 0; i < 5; ++i)
{
    pthread_create(
        &tid,
        nullptr,
        Reader,
        &i
    );
}

因为多个线程可能获得同一个循环变量的地址。

而主线程还在继续修改:

cpp 复制代码
i

这样可能造成参数竞争和结果错误。

正确做法是为每个线程准备独立且生命周期足够长的参数对象。

例如:

cpp 复制代码
std::vector<ThreadArg> args(total);

每个线程使用:

cpp 复制代码
&args[i]

获得自己的参数。

并且主线程通过:

cpp 复制代码
pthread_join()

等待所有工作线程退出,保证这些参数在使用期间仍然有效。

24.3 为什么使用printf而不是多次cout?

多个读者可以同时获得读锁。

因此两个读者可能同时执行:

cpp 复制代码
std::cout << "Reader "
          << number
          << " value = "
          << shared_data
          << std::endl;

这样多次流插入操作之间的输出内容可能交错。

为了方便观察实验结果,代码使用一次:

cpp 复制代码
std::printf(...)

输出一条完整日志。

这可以减少多次流插入造成的交错现象。

但要注意:

打印日志本身的输出同步,与读写锁对共享数据的保护,是两个不同问题。


二十五、编写Makefile

创建:

text 复制代码
Makefile

内容:

makefile 复制代码
CXX := g++
CXXFLAGS := -std=c++11 -Wall -Wextra -pthread

TARGET := reader_writer_lock_test
SRC := reader_writer_lock_test.cc

$(TARGET): $(SRC)
	$(CXX) $(CXXFLAGS) -o $@ $^

.PHONY: clean

clean:
	rm -f $(TARGET)

需要注意:

Makefile中的编译命令前面通常必须使用:

Tab制表符。

而不是普通空格。

25.1 编译

bash 复制代码
make

也可以直接执行:

bash 复制代码
g++ reader_writer_lock_test.cc \
    -std=c++11 \
    -Wall \
    -Wextra \
    -pthread \
    -o reader_writer_lock_test

25.2 读者优先模式

bash 复制代码
./reader_writer_lock_test reader

25.3 写者优先模式

bash 复制代码
./reader_writer_lock_test writer

25.4 清理编译结果

bash 复制代码
make clean

二十六、分析运行结果

26.1 读者优先的运行现象

执行:

bash 复制代码
./reader_writer_lock_test reader

可能出现:

text 复制代码
当前策略:reader

[读者-0] 进入读区,数据:0
[读者-1] 进入读区,数据:0

[读者-0] 离开读区
[读者-1] 离开读区

[写者-0] 进入写区,新数据:82
[写者-0] 离开写区

[读者-1] 进入读区,数据:82
[读者-0] 进入读区,数据:82

...

这里只展示一种可能的执行顺序。

真正输出以线程调度结果为准。

26.2 怎样证明两个读者可以同时读取?

重点观察:

text 复制代码
[读者-0] 进入读区
[读者-1] 进入读区

随后才出现:

text 复制代码
[读者-0] 离开读区
[读者-1] 离开读区

这说明两个读者的持锁区间发生了重叠。

因此两个读者能够同时持有读锁。

26.3 怎样证明写者独占?

假设输出:

text 复制代码
[写者-0] 进入写区

然后:

text 复制代码
[写者-0] 离开写区

在这段持锁期间,不应看到其他线程成功进入受同一读写锁保护的读区或写区。

这是因为:

text 复制代码
写锁具有独占语义

所以不允许:

text 复制代码
写者与读者同时持锁

也不允许:

text 复制代码
两个写者同时持锁

26.4 为什么实验输出不是固定的?

因为:

cpp 复制代码
pthread_create()

只是创建线程。

它不能保证:

text 复制代码
Reader-0一定先运行

Reader-1一定第二个运行

Writer-0一定第三个运行

每个线程真正获得 CPU 执行机会的顺序,由操作系统调度决定。

所以:

不要根据线程创建顺序推断线程获取锁的必然顺序。


二十七、怎样观察读者优先与写者优先的区别?

默认使用:

cpp 复制代码
const int reader_num = 2;
const int writer_num = 2;

读者数量较少。

因此两种策略可能看不出非常明显的差异。

为了提高观察概率,可以修改实验参数。

27.1 实验一:读者较多

例如:

cpp 复制代码
const int reader_num = 8;
const int writer_num = 2;

运行:

bash 复制代码
./reader_writer_lock_test reader

观察写者是否需要等待多个读者释放读锁。

27.2 实验二:更换优先策略

保持线程数量不变。

执行:

bash 复制代码
./reader_writer_lock_test writer

通过:

cpp 复制代码
PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP

设置写者优先。

观察当写者开始等待以后,新到达的读者是否被限制进入。

27.3 实验三:修改读者进入间隔

当前代码中:

cpp 复制代码
usleep(100000);

表示每次读取完成以后暂停约100毫秒。

可以适当缩短这个间隔,让读者更频繁地发起读锁请求。

例如:

cpp 复制代码
usleep(10000);

这样有助于观察:

text 复制代码
读者持续竞争读锁

对写者等待时间的影响。

27.4 为什么不能通过一次运行证明没有饥饿?

因为饥饿是关于长期等待和进展保证的问题。

例如:

text 复制代码
一次实验中写者成功进入

只能说明:

text 复制代码
这一次写者没有被永久阻塞

并不能证明所有负载下写者都能在有限时间内获得执行机会。

同样:

text 复制代码
某次读者等待比较久

也不能立刻证明实现存在永久读者饥饿。

因此:

读者优先与写者优先要结合策略规则理解,实验结果主要用于观察和验证。


二十八、为什么读写锁还需要考虑公平性?

现在可以发现:

text 复制代码
读读共享
读写互斥
写写互斥

只解决了:

哪些线程允许同时访问共享资源。

但没有完整解决:

等待中的线程应该按照什么顺序获得锁。

例如:

text 复制代码
Reader-1正在读取

Writer-1已经等待

Reader-2刚刚到来

这时:

text 复制代码
应该让Reader-2继续读取?

还是:

text 复制代码
应该优先让Writer-1获得写入机会?

这就是调度策略问题。

28.1 读者优先

可能选择:

text 复制代码
Reader-2继续进入

好处是:

text 复制代码
更多读操作可以并发

但可能导致:

text 复制代码
Writer-1持续等待

28.2 写者优先

可能选择:

text 复制代码
Reader-2等待

让已经等待的 Writer-1 在已有读者结束后尽快获得写锁。

好处是:

text 复制代码
降低写者长期等待的风险

但如果写者不断到来,又可能导致:

text 复制代码
读者长时间等待

28.3 是否存在完全公平的策略?

可以设计其他读写调度策略,例如按照请求队列或其他规则协调读者、写者的获取顺序。

其目标可以是减少长期饥饿。

但公平性与吞吐量之间也可能存在取舍。

本阶段先掌握:

读者优先可能使写者饥饿,写者优先可能使读者饥饿。

不需要立即实现新的复杂调度算法。


二十九、读写锁与普通互斥锁的区别

对比维度 普通互斥锁 读写锁
同时持锁的线程数量 通常最多一个 可允许多个读者
读与读 互斥 可共享
读与写 互斥 互斥
写与写 互斥 互斥
使用方式 加锁、解锁 读加锁、写加锁、解锁
典型适用场景 一般临界资源保护 多读少写的共享数据
内部管理成本 通常相对简单 通常需要更多状态管理
饥饿问题 依赖实现和调度 还需要考虑读写优先策略

29.1 什么时候使用Mutex?

例如:

cpp 复制代码
++counter;

或者:

cpp 复制代码
queue.push(task);

如果这些操作涉及共享状态的修改,并且临界区非常短,普通互斥锁往往已经足够。

29.2 什么时候考虑读写锁?

例如:

text 复制代码
大量线程同时查询共享数据

少量线程偶尔修改共享数据

且读取操作本身具有一定执行时间,就可以考虑读写锁。

但要通过实际测试判断是否真的获得性能收益。

不能认为:

text 复制代码
读写锁一定比互斥锁高级

就无条件使用读写锁。


三十、读写锁使用中的常见错误

30.1 写者错误地使用读锁

错误示例:

cpp 复制代码
pthread_rwlock_rdlock(&rwlock);

shared_data = 100;

pthread_rwlock_unlock(&rwlock);

为什么错误?

因为读锁允许多个读者共同持有。

如果多个线程在读锁保护下修改共享数据,就可能出现数据竞争。

所以:

需要修改共享数据时,应取得写入所需的独占权限。

正确:

cpp 复制代码
pthread_rwlock_wrlock(&rwlock);

shared_data = 100;

pthread_rwlock_unlock(&rwlock);

30.2 读者没有加锁

错误示例:

cpp 复制代码
int value = shared_data;

如果其他写者可能同时修改:

cpp 复制代码
shared_data

就可能发生数据竞争。

因此读者也需要遵守相应同步协议。

例如:

cpp 复制代码
pthread_rwlock_rdlock(&rwlock);

int value = shared_data;

pthread_rwlock_unlock(&rwlock);

30.3 提前解锁后继续访问共享数据

错误示例:

cpp 复制代码
pthread_rwlock_rdlock(&rwlock);

pthread_rwlock_unlock(&rwlock);

std::cout << shared_data << std::endl;

这里实际读取发生在解锁以后。

如果写者已经获得写锁并修改数据,读者就没有得到原本希望的保护。

正确思路是:

cpp 复制代码
pthread_rwlock_rdlock(&rwlock);

int value = shared_data;

pthread_rwlock_unlock(&rwlock);

std::cout << value << std::endl;

将需要保护的读取放在锁内。

在这个整数示例中,解锁后使用本地副本就不再需要访问共享变量。

30.4 持有读锁时直接申请写锁

例如:

cpp 复制代码
pthread_rwlock_rdlock(&rwlock);

// 尝试升级
pthread_rwlock_wrlock(&rwlock);

不能把这种操作理解为可靠的"读锁自动升级为写锁"。

标准 POSIX 读写锁没有提供一个通用的原子读锁升级接口。

直接这样操作可能造成死锁或其他不符合预期的行为。

因此:

不要假设可以安全地持有读锁再直接申请同一把锁的写锁。

30.5 在工作线程还没结束时销毁读写锁

错误示例:

cpp 复制代码
pthread_create(...);

pthread_rwlock_destroy(&rwlock);

如果新线程仍然使用这把锁,就可能发生严重问题。

应该确保所有相关线程都不再使用锁以后再销毁。

30.6 在写者优先模式中递归申请读锁

如果设置:

cpp 复制代码
PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP

就应遵守其非递归读锁要求。

避免同一线程持有读锁时再次请求同一把读锁。

30.7 认为读写锁一定没有饥饿问题

错误。

读写锁保证访问互斥关系,并不天然保证所有线程完全公平地获得锁。

其具体行为还受到:

text 复制代码
优先策略

实现方式

线程调度

等因素影响。


三十三、最终总结

读者写者问题是操作系统并发编程中的经典同步问题。

它与生产者消费者模型最大的区别在于:

生产者消费者模型强调数据生产与消费的条件协作,而读者写者模型强调不同数据访问方式之间的并发兼容性。

读者写者模型存在三种基本关系:

text 复制代码
读读共享

读写互斥

写写互斥

为了理解如何实现它,我们引入了:

cpp 复制代码
reader_count
count_lock
writer_lock

其中:

cpp 复制代码
reader_count

记录当前正在读取的读者数量。

cpp 复制代码
count_lock

保护读者数量的变化。

而:

cpp 复制代码
writer_lock

从抽象上负责协调读者组与写者对共享资源的访问权。

通过:

text 复制代码
第一个读者获取访问许可

最后一个读者释放访问许可

可以使多个读者共同读取,同时阻止写者进入。

但这种读者优先算法可能导致:

写者饥饿。

因此,还需要考虑读写锁的优先策略。

POSIX提供:

cpp 复制代码
pthread_rwlock_t

以及:

cpp 复制代码
pthread_rwlock_init()
pthread_rwlock_destroy()

pthread_rwlock_rdlock()
pthread_rwlock_wrlock()
pthread_rwlock_unlock()

完成读写锁管理。

在 Linux/glibc 环境中,还可以通过:

cpp 复制代码
pthread_rwlockattr_setkind_np()

配置相应的读写优先策略。

其中:

cpp 复制代码
PTHREAD_RWLOCK_PREFER_READER_NP

表示读者优先。

cpp 复制代码
PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP

可以在遵守非递归读锁约束的前提下,实现更有利于写者获取锁的行为。

但不管使用哪种策略,都需要记住:

读写锁不仅涉及线程安全,还涉及并发效率和调度公平性。

因此最终应该根据实际业务选择:

text 复制代码
普通互斥锁

或者:

text 复制代码
读写锁

而不是认为读写锁在所有场景下都一定更好。

读写锁的核心思想,就是让可以安全并发的读者共同执行,让必须独占资源的写者排他执行。


相关推荐
QX_hao1 小时前
【GPG】使用GPG安全传递隐私信息
1024程序员节·gpg
特立独行的猫a1 小时前
仓颉语言cj-tauri 跨端开发框架实战:给鸿蒙侧加一个「读设备电量」的原生能力
ui·harmonyos·tauri·1024程序员节·仓颉·鸿蒙pc
_upupup1 小时前
Linux中的进程
linux·服务器·1024程序员节
AI技术新视界1 小时前
递归自我改进(RSI)是什么
人工智能·llm·1024程序员节·rsi
@zulnger1 小时前
安卓进行稳定性测试
android·appium·1024程序员节
enjoy编程1 小时前
playwright学习-I
typescript·vue·nodejs·1024程序员节·playwright
承渊政道1 小时前
iOS远程控制电脑用什么好?iPhoneiPad远程控制Windows电脑最佳方案2026横评
windows·ios·远程控制·1024程序员节·iphone ipad
艾灸爱好者1 小时前
艾草精油专业制造商哪3点决定品质高低?
python·1024程序员节
Linux实战笔记1 小时前
GraphQL vs REST API 设计实测:灵活查询与性能权衡逐项对比
性能优化·graphql·rest api·1024程序员节·dataloader·n+1 查询