文章目录
一、引入信号量
上一篇文章我们介绍了把共享资源作为整体使用的场景,下面我们要将共享资源分开局部使用,可以类比成现实世界的电影院,电影院本身是公共的,电影院的座位是分开使用的。
把共享资源分开使用我们需要考虑两个问题:
- 放过多的线程进入共享资源空间,例如电影院只用10个座位,却放了12个人进来。
- 没有放过多的线程进入共享资源空间,但是依旧有不同的线程访问了同一份资源。
第二个问题需要程序员自己通过代码维护,我们写具体代码时再来详细阐释,第一个问题可以通过信号量解决,所以我们之前提过互斥量本质是一个计数器,对整体共享资源的局部资源数目做计数,线程进入共享资源需要对计数器做--(P操作,本质对资源做申请),退出共享资源需要对计数器做++(V操作,本质对资源做归还)。当计数器小于等于0时就不再允许线程进入共享资源。
线程要访问局部资源时要先申请信号量(P操作),类似买电影票预定座位,申请信号量本质是一种对资源的预订机制。
线程要访问信号量首先要看到它,所以信号量本质也是共享资源,信号量是用来保护临界区的那么它自己由谁来保护呢?所以信号量只能自立更生!申请信号量和释放信号量的操作都是原子的。
二、信号量接口
初始化信号量

信号量没有在pthread.h头文件中,而是在独立的头文件semaphore.h中。
第一个参数需要传信号量类型sem_t,第二个参数是设置信号量在进程中使用还是线程中使用,设置为0表示在线程中使用。第三个参数是设置信号量计数器的初始值。
返回值和大部分接口类似,成功返回0,失败返回-1,并设置错误码。
销毁信号量

申请信号量(P操作)

对计数器做--。
释放信号量(V操作)

对计数器做++。
三、基于环形队列的生产消费场景
环形队列
1、环形队列在逻辑上是环形的,但是物理结构本质是一个数组,只不过在插入数据时,数组下标index++后需要模等队列元素个数才是插入数据的数组下标。
2、环形队列还有两个指针:头指针和尾指针,当插入数据时头指针不动,尾指针++,当尾指针一直++直到头指针等于尾指针时说明队列满了,当队列一开始为空时头指针也等于尾指针。所以当头指针等于尾指针时队列既可能为空也可能为满。
3、我们要判断环形队列为空还是为满可以引入一个计数器统计环形队列中的元素个数,数据入队列时计数器++,数据出队列时计数器--,当头尾指针相等且计数器为0说明队列为空,头尾指针相等且计数器为队列元素个数说明队列为满。(本文后续实现中,利用信号量的空间/数据计数特性,天然解决了环形队列的空满判断问题,无需额外维护计数器,是更优雅的实现方式)
三种情况
下面我们要将环形队列用于生产者消费者模型,也就是将环形队列作为交易场所,生产者访问尾指针指向的数据,消费者访问头指针指向的数据,那么对于生产消费过程有下面三种主要情形:
1、队列为空,生产者消费者访问同一个位置,此时必须让生产者先运行(同步),并且生产者运行时消费者不能干扰(互斥)。
2、队列为满,生产者消费者访问同一个位置,此时必须让消费者先运行(同步),并且消费者运行时生产者不能干扰(互斥)。
3、队列不为空也不为满,在单生产单消费的场景下,此时生产者消费者访问的一定不是同一个位置,此时生产者和消费者并发运行,该 场景产生概率最高。
单生产单消费模型具体实现
下面我们要利用信号量和环形队列实现单生产单消费模型,本质就是要让整个过程符合上面介绍的三种情况。
首先我们要知道生产者最关心队列中的空余空间资源,因为生产者访问的是空余位置,同理消费者最关心队列中的数据资源,因为消费者访问的是有数据的位置。
假设整个环形队列一共有10个位置,生产者实现思路就是定义两个信号量:space_sem表示空余空间资源计数器,初始化为10,data_sem表示数据资源计数器,初始化为0。
生产者进行生产时需要申请空间信号量(P),此时空间信号量计数器--,生产完成后需要释放数据信号量(V),此时数据信号量计数器++。消费者进行消费时需要申请数据信号量(P),此时数据信号量计数器--,生产消费后需要释放空间信号量(V),此时空间信号量计数器++。
所以生产者和消费者各自只用申请自己的资源,释放对方的资源就能维护好前两种情况。

Sem.hpp
下面我们正式开始代码实现,由于信号量操作比较简单,我们就不像之前一样先写一份未封装信号量版本再写一份封装版本了,我们直接一步到位,对信号量进行封装:
cpp
#include <iostream>
#include <semaphore.h>
class Sem
{
public:
Sem(int initnum)
:_initnum(initnum)
{
sem_init(&_sem, 0, _initnum);
}
void P()
{
sem_wait(&_sem);
}
void V()
{
sem_post(&_sem);
}
~Sem()
{
sem_destroy(&_sem);
}
private:
sem_t _sem;
int _initnum; //计数器初始值
};
RingQueue.hpp
这里我们需要解决上文"引入信号量"部分的第二个问题,如何在线程数量和资源数目合理的情况下避免多线程访问同一份资源。这时就需要在RingQueue类中多定义两个成员变量_p_step和_c_step表示生产者和消费者在环形队列中所处的位置。
注意事项:
1、在消费Pop接口中注意消费代码实现,因为函数参数传递是值传递,包括指针变量,在函数体内对指针变量做修改是不影响实参的,需要把数据赋值给指针变量指向的那块内存空间才能完成输出型参数的值传递。
cpp
*out = _ring_queue[_c_step++];
out = &_ring_queue[_c_step++]; //错误!!
cpp
#include "Sem.hpp"
#include <iostream>
#include <vector>
const static int gcap = 5;
template <class T>
class RingQueue
{
public:
RingQueue(int cap = gcap)
:_cap(cap)
,_ring_queue(cap)
,_space_sem(cap)
,_data_sem(0)
,_p_step(0)
,_c_step(0)
{}
void Enqueue(const T &in)
{
_space_sem.P();
//进行生产
_ring_queue[_p_step++] = in;
//维护环形队列
_p_step %= _cap;
_data_sem.V();
}
void Pop(T *out)
{
_data_sem.P();
//进行消费
*out = _ring_queue[_c_step++];
//维护环形队列
_c_step %= _cap;
_space_sem.V();
}
~RingQueue()
{}
private:
std::vector<T> _ring_queue; //临界资源
int _cap; //环形队列容量,定义后只读,非临界资源
Sem _space_sem; //空闲空间信号量
Sem _data_sem; //数据信号量
//通过代码定义生产和消费的位置
int _p_step;
int _c_step;
};
main.cc
cpp
#include "RingQueue.hpp"
#include <pthread.h>
#include <unistd.h>
void *consumer(void *args)
{
RingQueue<int> *rq = static_cast<RingQueue<int> *>(args);
while(true)
{
int data = 0;
rq->Pop(&data);
std::cout << "消费者消费了一个数据:" << data << std::endl;
sleep(1);
}
}
void *productor(void *args)
{
RingQueue<int> *rq = static_cast<RingQueue<int> *>(args);
int data = 1;
while(true)
{
rq->Enqueue(data);
std::cout << "生产者生产了一个数据:" << data << std::endl;
data++;
}
}
int main()
{
RingQueue<int> *rq = new RingQueue<int>();
pthread_t c, p;
pthread_create(&c, nullptr, consumer, (void *)rq);
pthread_create(&p, nullptr, productor, (void *)rq);
pthread_join(c, nullptr);
pthread_join(p, nullptr);
return 0;
}
重新理解互斥锁和信号量
1、为什么上面的单生产单消费没有加锁也能维护好互斥关系和保证数据的安全?当环形队列不为空和不为满是生产者和消费者是并行运行的,不用考虑同步和互斥问题,所以同步、互斥发生在队列为空或者队列为满的情况下,我们以队列为空为例。环形队列为空,此时生产者和消费者访问同一资源,此时_data_sem为零,消费者会在P操作处阻塞住,此时只有生产者能运行,在生产者V操作之前消费者都无法访问临界资源,变相保证了生产者能互斥访问临界资源,当队列为满时同理。
2、为什么临界区代码中没有判断资源是否就绪就直接访问临界资源了?(补充:PV 操作之间操作共享临界资源的代码段即为临界区)因为申请信号量时本质就在对资源是否就绪做判断,所以申请信号量其实就是把对资源是否就绪的判断从临界区内转移到了临界区外或临界区入口处。
3、重新理解信号量:当我们把信号量初识值设为1就是二元信号量,二元信号量本质就是一把互斥锁。
4、重新理解互斥锁:互斥锁本质就是认为自己只有一份资源的二元信号量,申请锁类比P操作,释放锁类比V操作。
5、锁本质就是信号量的一种特殊情况。
多生产多消费模型具体实现
我们知道在单生产单消费模型只用维护好生产者和消费者之间的同步和互斥关系,我们可以利用两个信号量办到,现在多生产多消费模型场景中还需要我们维护好生产者之间和消费者之间的互斥关系,这时生产者消费者的访问位置下标_p_step和_c_step就是我们需要保护的共享资源,多个生产者共享_p_step,多个消费者共享_c_step。此时如果只有信号量就无法保护好_p_step和_c_step,因为只要信号量不为零,同一个step位置就可以放进多个生产者或消费者访问,此时必然会造成数据不一致。
所以多生产多消费模型就需要利用互斥锁,如果只用一般锁虽然可以保证互斥关系,但是此时生产者和消费者就只能串行访问阻塞队列了,会造成效率下降。我们既想让生产者和消费者在访问不同位置时并行运行,也想维护好互斥关系,所以就需要加两把锁,将锁的互斥域彻底拆分,一把给生产者们使用,一把给消费者们使用,此时多生产多消费模型就退化成了单生产单消费模型,现在又有一个问题摆在我们面前,是先加锁再申请信号量还是先申请信号量再加锁,其实这里是符合我们生活常识的,以买电影票为例,我们是先买票再在电影院门口排队进入,所以应该先申请信号量再加锁 。如果先加锁再 P 信号量,线程会持有锁的状态下阻塞在信号量,导致锁的持有时间过长,其他线程无法竞争锁,严重降低并发效率;先 P 再加锁,仅在操作临界资源时持有锁,完全贴合「锁的持有时间压缩到极致」的黄金原则,黄金原则解释如下:黄金原则:能不持有锁就不持有,持有锁的时间必须压缩到极致 → 只在必须操作临界资源的几行代码时持有锁,其余时间一律释放。
源码如下:
cpp
//Mutex.hpp
#pragma once
#include <pthread.h>
class Mutex
{
public:
Mutex()
{
pthread_mutex_init(&_lock, nullptr);
}
void Lock()
{
pthread_mutex_lock(&_lock);
}
void Unlock()
{
pthread_mutex_unlock(&_lock);
}
pthread_mutex_t *Get()
{
return &_lock;
}
~Mutex()
{
pthread_mutex_destroy(&_lock);
}
private:
pthread_mutex_t _lock;
};
class LockGuard
{
public:
LockGuard(Mutex *mutexp)
:_mutexp(mutexp)
{
//构造的时候加锁
_mutexp->Lock();
}
~LockGuard()
{
//析构的时候解锁
_mutexp->Unlock();
}
private:
Mutex *_mutexp;
};
cpp
//RingQueue.hpp
#include "Sem.hpp"
#include "Mutex.hpp"
#include <iostream>
#include <vector>
const static int gcap = 5;
template <class T>
class RingQueue
{
public:
RingQueue(int cap = gcap)
: _cap(cap), _ring_queue(cap), _space_sem(cap), _data_sem(0), _p_step(0), _c_step(0)
{
}
void Enqueue(const T &in)
{
_space_sem.P();
{
LockGuard lockgard(&_p_lock);
// 进行生产
_ring_queue[_p_step++] = in;
// 维护环形队列
_p_step %= _cap;
}
_data_sem.V();
}
void Pop(T *out)
{
_data_sem.P();
{
LockGuard lockguard(&_c_lock);
// 进行消费
*out = _ring_queue[_c_step++];
// 维护环形队列
_c_step %= _cap;
}
_space_sem.V();
}
~RingQueue()
{
}
private:
std::vector<T> _ring_queue; // 临界资源
int _cap; // 环形队列容量,定义后只读,非临界资源
Sem _space_sem; // 空闲空间信号量
Sem _data_sem; // 数据信号量
// 通过代码定义生产和消费的位置
int _p_step;
int _c_step;
Mutex _p_lock;
Mutex _c_lock;
};
cpp
//main.cc
#include "RingQueue.hpp"
#include <pthread.h>
#include <unistd.h>
void *consumer(void *args)
{
RingQueue<int> *rq = static_cast<RingQueue<int> *>(args);
while(true)
{
int data = 0;
rq->Pop(&data);
std::cout << "消费者消费了一个数据:" << data << std::endl;
sleep(1);
}
}
void *productor(void *args)
{
RingQueue<int> *rq = static_cast<RingQueue<int> *>(args);
int data = 1;
while(true)
{
rq->Enqueue(data);
std::cout << "生产者生产了一个数据:" << data << std::endl;
data++;
}
}
int main()
{
RingQueue<int> *rq = new RingQueue<int>();
pthread_t c[2], p[3];
pthread_create(p, nullptr, productor, (void *)rq);
pthread_create(p + 1, nullptr, productor, (void *)rq);
pthread_create(p + 2, nullptr, productor, (void *)rq);
pthread_create(c, nullptr, consumer, (void *)rq);
pthread_create(c + 1, nullptr, consumer, (void *)rq);
pthread_join(c[0], nullptr);
pthread_join(c[1], nullptr);
pthread_join(p[0], nullptr);
pthread_join(p[1], nullptr);
pthread_join(p[2], nullptr);
return 0;
}
四、一句话总结
同步与互斥的关系:互斥是保证临界资源的安全访问,同步是保证线程的执行顺序;信号量可以同时完成同步 + 互斥,互斥锁只能完成互斥。
以上就是小编分享的全部内容了,如果觉得不错还请留下免费的赞和收藏
如果有建议欢迎通过评论区或私信留言,感谢您的大力支持。
一键三连好运连连哦~~
