一、前言
前面我们学习了死锁的相关知识,今天我们来学习读写锁的相关知识。
二、读写锁
读写锁是 Linux 系统中一种支持并发读、独占写 的同步机制,核心设计目标是提高读多写少场景下的并发性能,解决互斥锁(mutex)在多读场景下的性能瓶颈。
2.1、读写锁的核心思想
读写锁遵循 "读者共享,写者独占" 的原则,将访问者分为两类角色:读者(Reader) :仅读取共享资源,不修改。多个读者可以同时持有锁,互不阻塞;写者(Writer):修改共享资源。写者必须独占锁,同一时间只能有一个写者,且写者与读者互斥。
这种设计的优势在于:当共享资源以读操作为主时,读写锁的并发效率远高于互斥锁(互斥锁会强制所有读者串行执行)。
2.2、读写锁的特性
1、互斥原则
读者与读者:不互斥,允许多个读者同时加锁。
读者与写者:互斥,读者持有锁时写者阻塞,反之亦然。
写者与写者:互斥,多个写者串行执行。
2、锁的状态
读写锁内部维护两个核心状态:
读者计数:记录当前持有锁的读者数量;写者标记:标记是否有写者持有锁或等待锁。
3、工作模式
公平模式:遵循 FIFO 原则,等待队列中的读者和写者按顺序获取锁,避免写者饥饿。
非公平模式(默认):一般来说,Linux中的pthread_rwlock更偏向于写者优先,具体表现是,一旦有 写线程在等待 ,新来的读线程 通常会被阻塞,等写线程完成后再放行读线程。
2.3、读写锁场景练习
1、线程A加写锁成功,线程B请求读锁:线程B阻塞;
2、线程A持有读锁,线程B请求写锁:线程B阻塞;
3、线程A拥有读锁,线程B请求读锁:线程B加锁;
4、线程A持有读锁,然后线程B请求写锁,然后线程C请求读锁:线程B阻塞,线程C阻塞 ;线程B加锁,线程C阻塞, 线程C加锁;
5、线程A持有写锁,然后线程B请求读锁,然后线程C请求写锁:线程B阻塞,线程C阻塞;线程C加锁, 线程B阻塞,线程B加锁;
2.4、读写锁相关函数
1、初始化读写锁
函数原型如下:
cs
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,
const pthread_rwlockattr_t *restrict attr);
功能:初始化一个读写锁对象,分配必要的资源。
参数: rwlock:指向待初始化的读写锁变量;
attr:读写锁属性,NULL表示使用默认属性(非公平模式);
返回值:成功:0;失败:非 0 错误码。
注:未初始化的读写锁不能直接使用;初始化后的锁必须通过pthread_rwlock_destroy销毁。
2、销毁读写锁
函数原型如下:
cs
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
功能:销毁读写锁,释放其占用的资源。
参数:rwlock:指向已初始化的读写锁。
返回值:成功:0;失败:非0错误码;
注:销毁前必须确保锁未被任何线程持有,否则行为未定义。
3、读者阻塞加锁
函数原型如下:
cs
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
功能:读者申请读锁,若锁被写者持有则阻塞,直到获取锁。
返回值:成功 0;失败非 0 错误码。
注:多个读者可同时持有读锁,与写者互斥。
4、写者阻塞加锁
函数原型如下:
cs
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
功能:写者申请写锁,若锁被读者 / 其他写者持有则阻塞,直到独占锁。
返回值:成功 0;失败非 0 错误码。
注:写锁为独占锁,同一时间仅一个写者持有。
5、读者尝试加锁(非阻塞)
函数原型如下:
cs
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
cs
int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);
功能:读者尝试获取读锁,若锁被占用(写者持有 / 写者等待),立即返回失败,不阻塞;
**返回值:**成功:0,失败:EBUSY(锁被占用),EINVAL(参数无效);
6、写者尝试加锁(非阻塞)
函数原型如下:
cs
int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);
功能:写者尝试获取写锁,若锁被读者 / 其他写者占用,立即返回失败。
返回值:成功:0,失败:EBUSY(锁被占用),EINVAL(参数无效);
7、 解锁(统一接口)
函数原型如下:
cs
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
功能:释放读锁或写锁(无需区分读写,锁内部自动识别)。
返回值:成功 0;失败:EINVAL(锁无效)、EPERM(当前线程未持有锁)。
**注:**读者加锁后必须由同一线程解锁,写者同理。读锁解锁时,若读者计数归 0,才会唤醒等待的写者;写锁解锁时,会唤醒所有等待的读者 / 写者(按优先级)。
2.5、典型示例
我们来完成下面一个小练习:三个线程不定时写同一个全局变量,五个线程不定时期读同一全局资源。
首先创建一个文件pthread_rwlock.c文件,输入以下代码:
cs
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
//不加锁
int number = 10000;
void *write_myfun(void *arg)
{
while(1)
{
number++;
printf("write: %ld %d\n",pthread_self(),number);
usleep(500);
}
}
void *read_myfun(void *arg)
{ while(1)
{
printf("read: %ld %d\n",pthread_self(),number);
usleep(500);
}
}
int main()
{
pthread_t p[8];
for(int i=0;i<3;++i)
{
pthread_create(&p[i],NULL,write_myfun,NULL);
}
for(int i=3;i<8;++i)
{
pthread_create(&p[i],NULL,read_myfun,NULL);
}
for(int i=0;i<8;++i)
{
pthread_join(p[i],NULL);
}
return 0;
}
编译并运行,结果如下:
截取其中的一段来看,可以看到第二个的write比第一个还小,说明两个写操作可能同时在执行,数据可能被交叉覆盖;第二个read的标识是17107,远早于之前 write的17109------ 说明读操作没有被写操作正确阻塞,在写操作完成前就读取了旧的缓存数据,或者读锁加锁时机错误。
上面是不加锁的情况,下面我们来加上读写锁:
cs
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
//加读写锁
int number = 10000;
pthread_rwlock_t lock;
void *write_myfun(void *arg)
{
while(1)
{
pthread_rwlock_wrlock(&lock);
number++;
printf("write: %ld %d\n",pthread_self(),number);
pthread_rwlock_unlock(&lock);
usleep(500);
}
}
void *read_myfun(void *arg)
{ while(1)
{
pthread_rwlock_rdlock(&lock);
printf("read: %ld %d\n",pthread_self(),number);
pthread_rwlock_unlock(&lock);
usleep(500);
}
}
int main()
{
pthread_rwlock_init(&lock,NULL);
pthread_t p[8];
for(int i=0;i<3;++i)
{
pthread_create(&p[i],NULL,write_myfun,NULL);
}
for(int i=3;i<8;++i)
{
pthread_create(&p[i],NULL,read_myfun,NULL);
}
for(int i=0;i<8;++i)
{
pthread_join(p[i],NULL);
}
pthread_rwlock_destroy(&lock);
return 0;
}
编译并运行,结果如下:
可以看到加了读写锁的程序运行地很好,没有交叉覆盖的情况。
2.6、读写锁和互斥锁的区别
区别如下:
| 特性 | 读写锁(rwlock) | 互斥锁(mutex) |
|---|---|---|
| 访问模式 | 读者共享、写者独占 | 所有线程独占 |
| 并发性能 | 读多写少场景下性能高 | 无论读写,性能固定(串行) |
| 适用场景 | 共享资源以读操作为主 | 共享资源读写频率相当,或写操作频繁 |
| 锁状态 | 读者计数 + 写者标记 | 加锁 / 未加锁 二值状态 |
| 饥饿问题 | 非公平模式下写者可能饥饿 | 无饥饿问题(公平模式) |
| 接口复杂度 | 较高(区分读写操作) | 简单(统一加解锁) |