🐱作者:一只大喵咪1201
🐱专栏:《Linux学习》
🔥格言:你只管努力,剩下的交给时间!
目录
- 一、 信号量
- 1.1 概念
- 1.2 信号量的基本操作
- 1.3 信号量的基本使用接口
- 二、基于环形队列的生产者消费者模型
- 2.1 分析
- 2.2 代码实现
- 2.3 多生产多消费
- 三、自旋锁
- 3.1 基本使用接口
- 四、读写锁
- 4.1 基本接口
- 4.2 加锁原理
- 4.3 读写优先级
- 五、总结
一、 信号量
之前在学习进程间通信的时候,本喵简单的介绍过一下信号量,今天在这里进行详细的介绍。
这是之前写的基于阻塞队列的生产者消费者模型中向阻塞队列中push任务的代码。
- 上面代码中有什么不足之处吗?
一个线程在向阻塞队列中push任务的时候,必须满足临界资源不满的条件,否则就会被放入到条件变量的等待队列中去。
但是临界资源是否为满是不能直接得到答案的,需要先申请锁,然后进入临界区访问临界资源去判断它是否为满。
- 在判断临界资源是否满足条件的过程中,必须先加锁,再检测,再操作,最后再解锁。
检测临界资源的本质也是在访问临界资源。
只要对临界资源整体加锁,就默认现场会对这个临界资源整体使用,但是实际情况可能存在:一份临界资源,划分为多个不同的区域,而且运行多个线程同时访问不同的区域。
之前代码的不足之处:
- 在访问临界资源之前,无法得知临界资源的情况。
- 多个线程不能同时访问临界资源的不同区域。
1.1 概念
为此,提出了信号量来解决之前代码的不足。
- 信号量:本质是一把计数器,用来衡量临界资源中资源数量多少。
- 申请信号量的本质:对临界资源中特定的小块资源的预定机制。
信号量也是一种互斥量,只要申请到信号量的线程,在未来一定能够拥有一份临界资源。
如上图所示,将一块临界资源划分为9个不同的区域,现在想要让多个线程同时访问这9个不同的区域。
- 创建一个信号量,它的值是9。
- 每一个来访问临界资源的线程都先申请信号量,也就是将计数值减一。
- 当计数值被减到0的时候,说明临界资源中的9个区域都有现场再访问,其他想要访问临界资源的现场只能阻塞等待。
申请到信号量的现场就可以进入临界区去访问临界资源,当访问完毕以后,再将信号量加一。
- 每个线程访问临界资源中的哪块区域由程序员决定,但是必须保证一个区域只能有一个现场在访问。
通过信号量的方式就解决了之前代码的不足:
- 线程不用访问临界资源就可以知道资源的使用情况。
信号量只要申请成功就一定有资源使用,只要申请失败就说明条件不满足,只能阻塞等待。
- 临界资源中的不同区域可以被多线程同时访问。
1.2 信号量的基本操作
所有线程必须都能看到信号量才能申请,所以信号量是一个公共资源,公共资源就涉及到线程安全问题。
根据上面分析,信号量的基本操作就是对信号量进行加一和减一,所以这两个操作是原子的。
- P操作:就是信号量减减(sem–),也就是在申请资源,而且该操作必须是原子的。
- V操作:就是信号量加加(sem++),也就是在归还资源,同样也必须是原子的。
1.3 信号量的基本使用接口
#include <semaphore.h>//信号量必须包含的头文件
sem_t sem;//创建信号量
初始化信号量:
int sem_init(sem_t* sem, int pshared, unsigned int value);
- sem:信号量指针
- shared:0表示线程间共享,非0表示进程间共享。我们一般情况下写0就行。
- value:信号量初始值,也就是计数器的值。
- 返回值:成功返回0,失败返回-1,并且设置errno。
信号量销毁:
int sem_destroy(sem_t* sem);
- sem:信号量指针
- 返回值:成功返回0,失败返回-1,并且设置errno。
等待信号量:
int sem_wait(sem_t* sem);//P操作
- sem:信号量指针。
- 返回值:成功返回0,失败返回-1,并且设置errno。
- 功能:等待信号量,会将信号量的值减1,也就是在申请信号量。
发布信号量:
int sem_post(sem_t* sem);//V操作
- sem:信号量指针。
- 返回值:成功返回0,失败返回-1,并且设置errno。
- 功能:发布信号量,表示资源使用完毕,可以归还资源,将信号量值加1,也就是在归还信号量。
这些接口和前面mutex的接口非常类似,因为他们都是POSIX标准的,所以使用起来没有任何难度。
二、基于环形队列的生产者消费者模型
这里使用信号量来实现一个单生产单消费的环形队列模型。
- 环形队列采用数组来模拟,用取模运算来模拟环状特性。
- 环形结构起始状态和结束状态都是一样的,不好判断为空或者为满。
- 当环形队列为空时,头和尾都指向同一个位置。
- 当环形队列为满时,头和尾也都指向同一个位置。
- 可以通过加计数器或者标记位来判满或者空,也可以预留一个空的位置,作为满的状态。
- 但是我们现在有信号量这个计数器,就不需要用数据结构的方式来判空和判满了,能够很简单的进行多线程间的同步。
2.1 分析
单生产者和单消费者一共两个线程在访问环形队列这个公共资源,生产者向环形队列中生产数据,消费者从环形队列中消费数据。
- 生产者和消费者什么情况下会访问同一个位置?
- 环形队列为空的时候,生产者和消费者会访问同一个位置。
当队列为空的时候,生产者访问队尾,向队列中生产数据,消费者访问对首消费数据,由于环形队列且为空,所以队首和队尾是同一个位置。
- 环形队列为满的时候,生产者和消费者会访问同一个位置。
当环形队列只有一个空位置的时候,生产者访问队尾生产数据,生产完毕后指向下一个位置,由于环形队列且为满,所以此时生产者又指向了队首,和消费者访问同一个位置。
- 其他任何时候,生产者和消费者访问的都是不同的区域。
只要环形队列不满也不空,那么生产者和消费者之间都有数据,它们各自访问各自的区域。
- 为了完成环形队列的生产消费问题,必须要做的核心工作是什么?
- 消费者不能超过生产者。
消费者消费的是生产者生产的数据,生产者没有生产,消费者就无法消费。当消费者超过生产者后,消费者访问的区域并没有数据,所以没有任何意义。
消费者必须跟着生产者的后面,即使消费速度非常快(导致环形队列为空),此时消费者和生产者访问同一区域。
- 生产者不能把消费者套一圈以上
消费者消费的速度比较慢,环形队列满了以后,如果生产者继续生产,就会将消费者还没来得及消费的数据覆盖,消费者就无法消费到覆盖之前的数据了。
- 对于生产者而言,它在意的是环形队列中空闲的空间。
生产者只负责将数据生产到环形队列的空间中,当环形队列满了以后就不能生产了,所以它只关心环形队列中有多少空间可以用来生成数据。
- 对于消费者而言,它在意的是环形队列中数据的个数。
消费者只负责从环形队列中消费数据,当环形队列为空时就停止消费,所以它只关心环形队列中有多是个数据可以用来消费。
- 空间资源定义一个信号量。用来统计空闲空间的个数。
- 数据资源定义一个信号量。用来统计数据个数。
所以生产者每次在访问临界资源之前,需要先申请空间资源的信号量,申请成功就可以进行生产,否则就阻塞等待。
消费者在访问临界资源之前,需要申请数据资源的信号量,申请成功就可以消费数据,否则就阻塞等待。
- 空间资源信号量的申请由生产者进行,归还(V操作)由消费者进行,表示生产者可以生产数据。
- 数据资源信号量的申请有消费者进行,归还(V操作)由生产者进行,表示消费者可以进行消费。
在信号量的初始化时,空间资源的信号量为环形队列的大小,因为没有生产任何数据。数据资源的信号量为0,因为没有任何数据可以消费。
通过信号量的方式同样维护了环形队列的核心操作,消费者消费速度快时,会将数据资源信号量全部申请完,但是此时生产者没有生产数据,也就没有归还数据资源的信号量,所以消费者会阻塞等待,不会超生产者。
生产者生产速度快时,会将空间资源信号量全部申请完,但是此时消费者没有消费数据,也就没有归还空间资源的信号量,所以生产者会阻塞等待,不会超过套消费者一个圈。
生产者伪代码:
productor_sem = 环形队列大小;
P(productor_sem);//申请空间资源信号量
//申请成功,继续向下运行。
//生气失败,阻塞在申请处。
.......//从事生产活动——把数据放入队列中
V(comsumer_sem);//归还数据资源信号量
消费者伪代码:
comsumer_sem = 0;
P(comsumer_sem);//申请数据资源信号量
//申请成功,继续向下运行。
//生气失败,阻塞在申请处。
.......//从事消费活动——从队列中消费数据
V(proudctor_sem);//归还空间资源信号量
在环形队列中,大部分情况下单生产和单消费是可以并发执行的,只有在满或者空的时候,才会有同步和互斥问题,同步和互斥是通过信号量来实现的。
- 在生产者和消费者并发访问环形队列时,访问的位置其实就是队列的下标,而且是两个下标。
- 当空或者满的时候,两个下标相同。
2.2 代码实现
RingQueue.hpp:
const int RingQueueSize = 5;//环形队列默认大小
template<class T>
class RingQueue
{
private:
//申请信号量
void P(sem_t& sem)
{
int n = sem_wait(&sem);
assert(n == 0);
(void)n;
}
//归还信号量
void V(sem_t& sem)
{
int n = sem_post(&sem);
assert(n == 0);
(void)n;
}
public:
//构造函数
RingQueue(int capacity = RingQueueSize)
:_capacity(capacity)
,_queue(capacity)//初始化环形队列大小
{
//初始化空间资源信号量,计数值为环形队列大小
int n = sem_init(&_spaceSem,0,_capacity);
assert(n == 0);
//初始化数据资源信号量,计数值为0
n = sem_init(&_dataSem,0,0);
assert(n == 0);
(void)n;
//初始化生产者和消费者访问下标
_productorIndex = _comsumerIndex = 0;
}
//向队列中生产数据
void Push(const T& in)
{
P(_spaceSem);//先申请空间资源信号量
_queue[_productorIndex++] = in;//生产数据
_productorIndex%=_capacity;//维护环形
V(_dataSem);//归还数据资源信号量
}
//从队列中消费数据
void Pop(T* out)
{
P(_dataSem);//先申请数据资源信号量
*out = _queue[_comsumerIndex++];//消费数据
_comsumerIndex%=_capacity;//维护环形队列
V(_spaceSem);//归还空间资源信号量
}
//析构函数
~RingQueue()
{
//销毁空间资源和数据资源的信号量
int n = sem_destroy(&_spaceSem);
assert(n == 0);
n = sem_destroy(&_dataSem);
assert(n == 0);
(void)n;
}
private:
std::vector<T> _queue;//模拟环形队列
int _capacity;//统计数据个数
sem_t _spaceSem;//空间信号量
sem_t _dataSem;//数据信号量
int _productorIndex;//生产者访问下标
int _comsumerIndex;//消费者访问下标
};
RingQueue类模板中包括队列,容量,空间资源和数据资源的信号量,生产者和消费者访问队列的下标,根据前面的分析一步步写出即可。
main.cpp:
//获取当前线程名字tid
std::string Self_Name()
{
char nameBuffer[64];
snprintf(nameBuffer,sizeof nameBuffer,"tid:0x%x",pthread_self());
return nameBuffer;
}
//生产者线程
void* Productor_Routine(void* args)
{
RingQueue<CalTask>* rq = static_cast<RingQueue<CalTask>*>(args);
while(1)
{
//构建任务
int x = rand()%10;
int y = rand()%10;
char op = oper[rand()%oper.size()];
CalTask t(x,y,op,myath);
//生产任务
rq->Push(t);
std::cout<<Self_Name()<<",生产任务:"<<t.toTaskString()<<std::endl;
}
}
//消费者线程
void* Comsumer_Routine(void* args)
{
RingQueue<CalTask>* rq = static_cast<RingQueue<CalTask>*>(args);
while(1)
{
sleep(1);
CalTask t;
rq->Pop(&t);//消费任务
std::string result = t();//处理任务
std::cout<<Self_Name()<<",消费任务:"<<result<<std::endl;
}
}
生产者线程生产计算任务,并且生产到环形队列中,消费者线程从环形队列中消费认为,并且执行对应的计算任务。
- 只有rq->Push(t)和rq->Pop(&t)两句是在访问环形队列,其他都是非临界区。
- 这里生产消费的任务使用的是之前学习阻塞队列的生产者消费者模型中的计算任务。
- 可以向环形队列中生产任何任务,消费者线程都不用知道认为是什么。
贴图计算任务代码:
运行结果:
创建消费者线程和生产者线程后,运行结构如上图右边所示。
- 由于生产者线程是没有延时,所以在线程一启动后就将环形队列全部生产满。
- 消费者线程在延时一秒后开始从环形队列中消费任务,执行相应的计算。
- 消费者每消费一个任务环形队列中就有一个位置,生产者接着生产一个任务,而且消费是按照生产的顺序进行的。
同样可以让生产者延时一秒,消费者不延时,有兴趣的小伙伴自行观察现象。
2.3 多生产多消费
环形队列的生产者消费者模型同样遵循321原则:
- 3:三种关系,生产者和生产者(互斥关系),消费者和消费者(互斥关系),生产者和消费者(在队列为空或者满时——同步和互斥关系)。
- 2:两种角色,生产者和消费者。
- 1:一个交易场所,环形队列。
上面单消费单生成模型,维护的只是生产者和消费者之间的关系,要想实现多生产多消费,只需要将另外两种关系维护好即可。
- 在RingQueue中增加两把互斥锁,一把生产者使用,一把消费者使用。
- 在构造函数中将锁初始化,在析构函数中将锁摧毁。
这里本喵就不贴代码了。
Push是向环形队列中生产任务,是生产者在调用,所以在生产之前需要加锁。Pop是从环形队列中消费认为,是消费者在调用,所以在消费之前加锁。
- 互斥锁和申请信号量谁在前比较合适呢?
如果互斥锁在前,申请信号量在后:
- 所有生产者线程或者是消费者线程都需要先竞争锁,然后再去申请信号量,信号量申请成功才能进入临界区。
- 如果信号量申请失败就抱着锁阻塞,其他同类型线程就无法申请到锁。
这就好比去电影院买票,必须先排队进入放映厅才能买票。
如果申请信号量在前,互斥锁在后:
- 所有生产者线程或者消费者线程先申请信号量,再去申请锁,然后进入临界区。
- 如果信号量申请失败就不会再去申请锁。
同样是电影院,这就好比先买票,然后再排队进入放映厅,没买上票就没必要排队了。
对于线程来说,申请锁也是有代价的,将信号量申请放在前面可以减少申请锁的次数,所以申请信号量在互斥锁之前更合适。
创建多个生产者线程和多个消费者线程,去执行生产计算任务和消费计算任务。
- 生产任务的线程是不同的,可以根据tid值区别出来。
- 消费认为的现场也是不同的,同样可以根据tid值区别。
此时就实现了基于环形队列的多生产多消费模型。
三、自旋锁
挂起等待锁:
之前使用的互斥锁就是挂起等待锁。多线程在竞争互斥锁时,申请到锁的线程进入临界区,而没有申请到锁的线程阻塞等待。
所谓的阻塞等待,其实是将该现场放入到操作系统维护的等待队列中,在合适的时候,操作系统再将其唤醒,放到运行队列中继续去申请锁。
自旋锁:
自旋锁也互斥锁,它的作用也是保护共享资源的安全。多线程在竞争自旋锁时,申请到锁的线程进入临界区,而没有申请到锁的线程不会挂起等待。
没有申请到锁的线程会不停的继续去申请锁,直到申请锁成功进入临界资源,自旋和进程等待中的轮询非常的相似。
自旋锁和挂起等待锁的区别就在于:没有申请到锁时,自旋锁仍然继续申请,挂起等待锁则进入等待队列等待,在被唤醒后继续申请锁。
- 挂起等待锁在的线程挂起后,CPU就暂时不用去调度它,可以去执行其他任务。
- 自旋锁的线程则会始终占用CPU资源。
是什么决定着线程的等待方式呢?是需要等待的时长。
- 当访问临界资源的时间较短的时候,可以使用自旋锁,因为进入临界区的线程会非常快的出来,处于自旋状态的线程也可以很快进入临界区。
此时申请自旋锁的线程,免去了被挂起等待和唤醒的过程,一定程度上提高了效率。
- 当访问临界资源的时间较长的时候,就要使用挂起等待锁,因为进入临界区的现场不会很快出来。
此时将申请锁失败的线程挂起,就将CPU资源空闲了出来,如果不挂起而处于自旋状态,则CPU就一直被占用。
那么需要等待的时间长短是如何定义的呢?
- 像前面写的多线程抢票的代码,对于tickets的访问就可以使用自旋锁。
- 对于需要进行复杂运算,高IO,以及等待某些软件标志就位的情况就是用挂起等待所。
等待时间的长短并没有明确的定义,使用自旋锁还是挂起等待锁根据具体情况来觉得。最好的方式是分别测试两种锁,哪种效率高就用哪种。
3.1 基本使用接口
#include <pthread.h>//使用自旋锁要包含的头文件
pthread_spinlock_t lock;//创建自旋锁
初始化自旋锁:
int pthread_spin_init(pthread_spinlock_t* lock, int shared);
- lock:自旋锁指针
- shared:0表示线程间共享,非0表示进程间共享,和信号量初始化中的shared一样。
- 返回值:成功返回0,失败返回-1。
销毁自旋锁:
int pthread_spin_destroy(pthread_spinlock_t* lock);
- lock:自旋锁指针
- 返回值:成功返回0,失败返回-1。
加锁:
int pthread_spin_lock(pthread_spinlock_t* lock);
- lock:自旋锁指针
- 返回值:加锁成功返回0,失败返回-1。
解锁:
int pthread_spin_unlock(pthread_spinlock_t* lock);
- lock:自旋锁指针
- 返回值:成功返回0,失败返回-1。
这些接口和之前学习的互斥锁以及信号量非常相似,只是换个函数名而已,因为它们遵循POSIX标准。
四、读写锁
读写锁主要使用在读者写者模型中,读者写者模型和生产者消费者模型很类似,也是遵循321原则:
- 3:三种关系,写者和写者(互斥),读者和写者(同步和互斥),读者和读者(没有关系)。
- 2:两种角色,读者和写者。
- 1:一个交易场所,任意类型的数据结构。
读者线程和写者线程并发访问一块临界资源:
- 写者向临界资源中写数据。
- 读者从临界资源中读数据。
- 读者和写者之间是互斥关系
写者在写数据时,读者无法访问临界资源,因为如果在读取的时候,写者还没有写完,那么读者读到的数据就不全。
- 读者和写者之间也是同步关系
如果写者写好数据,读者不去都,那么写者写的数据就没有意义,所以写者写好数据后必须有读者来读。
反之,如果所有读者都已经读取过临界区的数据了,再读就是重复的旧数据,此时读取也没有意义,所以读者读完数据以后,写者必须来写入新的数据。
- 写者和写者直接是互斥关系
如果一个写者正在写数据,另一个写者也来写,假设他们写的是同一块公共资源,就有可能发生覆盖。
- 读者和读者之间没有关系
读者只从临界区中读取数据,并不拿走,所以读者之间并不会产生影响。
读者写者模型使用场景:一次发布,很长时间不做修改,大部分时间都是在被读取,比如本喵写的博客。
- 读者写者模型 VS 生产者消费者模型的本质区别是:消费者会拿走临界资源中的数据,而读者不会。
有些共享资源的数据修改的机会比较少,相比较改写,它们读的机会反而高的多。
在读的过程中,往往伴随着查找的操作,中间耗时很长。给这种代码段加锁,会极大地降低我们程序的效率。
读写锁就是专门用于读者写者模型中的一种锁,可以给读者加锁,也可以给写者加锁,可以维护读者写者的321原则。
临界区的状态 | 读者请求 | 写者请求 |
---|---|---|
无锁 | 可以 | 可以 |
读锁 | 可以 | 阻塞 |
写锁 | 阻塞 | 阻塞 |
- 持有写锁的线程独占临界资源,持有读锁的线程,读者之间共享临界资源。
4.1 基本接口
#include <pthread.h>//读写锁必须包含的头文件
pthread_rwlock_t rwlock;//创建读写锁
初始化读写锁:
int pthread_rwlock_init(pthread_rwlock_t* rwlock, const pthread_rwlockattrt_t* attr);
- rwlock:读写锁指针
- attr:读写锁属性结构体指针,一般设置成nullptr即可。
- 返回值:成功返回0,失败返回-1
销毁读写锁:
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);
- 读锁和写锁都通过这个接口去解锁。
同样是POSIX标准,所以返回值,参数等风格和前面的互斥锁,信号量,以及自旋锁一样,本喵就不详细解释了。
4.2 加锁原理
- 读写锁:在任何时刻,只允许一个写者写入,但是允许多个读者并发读取(写者阻塞)。
是不是感觉非常奇怪?上面的接口中明明只有一把锁,但是可以给读者和写者分别加锁,而且对于读者和写者的效果还不同?
下面本喵写一段伪代码来解释一下:
pthread_rwlock_t lock;
读写锁的类型是一个结构体,它里面封装的也是互斥锁,而且针对读者有一把,针对写者有一把,只是机制不一样而已
读加锁伪代码: pthread_rwlock_rdlock(pthread_rwlock_t* rwlock)
pthread_mutex_t rdlock;//创建读锁
int reader_count = 0;//读者计数
------------------------------------------------------------
lock(&rdlock);//读加锁
reader_count++;//读者数量加一
if(reader_count == 1)
{
//只要有读者在访问临界资源,就将写锁也申请走
lock(&rwlock);//写加锁
}
unlock(&rdlock);//解读锁
------------------------------------------------------------
//读取数据....
------------------------------------------------------------
lock(&rdlock);//再次读加锁
read_count--;//读者数量减一
if(reader_count == 0)
{
//读者全部读完以后,释放写锁
unlock(&rwlock);//写解锁
}
unlock(&rdlock);//读解锁
加读锁时,有一个计数器,该计数器所有读者线程共享,是一份共享资源,用来统计访问公共资源的读者数量。
伪代码解释:
- 每个读者访问公共资源的时候,都需要将计数值加1,考虑到线程安全,所以计数值要加锁。
- 当第一个读者到来后,它先申请了读锁,然后又申请了写锁,此时写者线程就无法访问临界资源了,因为写锁在读者手里。之后的读者线程仅将计数值加一即可。
- 当读者线程访问完计数值以后就将读锁解锁,然后去公共资源中读数据(仅读取,不拿走)。
- 读者读完数据以后,继续线程安全的访问计数值,将值减一,当值被减到0时,说明没有读者再来读数据了,此时将申请的写锁解锁,好方便写者访问公共资源。
通过这样的方式就实现了读者和写者之前的互斥,读者和读者之间没有关系。
- 互斥访问读者计数值非常的快,读者真正访问公共资源的时候是没有任何关系的(不存在加锁)。
写加锁伪代码: pthread_rwlock_wrlock(pthread_rwlock_t* lock)
pthread_mutex_t wrlock;//创建写锁
------------------------------------------------------------
lock(&wrlock);//写加锁
//向临界资源中写入数据
unlock(&wrlock);//写解锁
写者的加锁解锁,实现了写者之间的互斥关系。
解释:
- 写者线程在访问临界资源的时候会先申请锁,申请成功的进入临界区,失败的阻塞等待。
- 如果写者申请写锁成功,那么第一个读者在申请写锁的时候同样会阻塞,直到写者释放锁。
- 如果第一个读者申请写锁成功,那么写者在申请写锁的时候也会阻塞,直到读者释放锁。
写锁的原理非常简单,正是由于读者会申请写锁,写者也会申请写锁,所以才能实现写者和读者的互斥。
4.3 读写优先级
上面讲解的读写锁是读者优先的,前提是有读者已经在访问公共资源。
- 已经有读者在访问公共资源的时候,写锁已经被读者申请走了。
- 当后面写者和读者同时到来的时候,写者会因为无法申请锁而阻塞,而读者可以访问公共资源。
如果没有读者在访问公共资源,第一个读者和写者同时到来时,它两就不存在优先关系,谁的竞争能力强谁申请到写锁,进入临界资源。
试想,读者非常多,那么写者就始终无法进入临界区访问临界资源,所以就会导致写者饥饿问题,但是读写锁就是应用在这种场景下,写者数量少执行少,读者数量多执行多。
- 读写锁是可以设置成写者优先的。
- 即使已经有读者在访问公共资源,并且写锁已经被申请走了。
- 当后面的写者和读者同时到来的时候,将写者后面的所有读者阻塞,不让它们访问公共资源。
- 当进入临界区的读者出来以后,并且归还了写锁,此时写者直接申请写锁并进入临界区访问临界资源。
大概的道理是这样,具体如何阻塞写者之后的读者策略可以在代码层面进行设计。
pthread库其实是提供了设置读写优先级的接口的:
int pthread_rwlockattr_setkind_np(pthread_rwlockattr_t* attr, int pref);
- attr:属性设置
- pref:有三种选择
- PTHREAD_RWLOCK_PREFER_READER_NP:(默认设置)读者优先,可能会导致写者饥饿情况。
- PTHREAD_RWLOCK_PREFER_WRITER_NP:写者优先
- PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP:写者优先,但写者不能递归加锁。
五、总结
至此本喵已经把常用的锁介绍完了,锁的种类非常的多,但是常用的就这几种,尤其是是mutex(挂起等待锁),cond(条件变量),sem(信号量),以及基于阻塞队列和环形队列的生产者消费者模型尤为重要。
至于自旋锁和读写锁,使用的频率没有那么高,所以本喵也没有去演示具体的代码,有兴趣的小伙伴可以去自行尝试。