POSIX信号量
信号量的概念与理解
- 我们之前在进程间通信这一节简单讲解了System V信号量,信号量本质就是一把计数器,描述临界资源中资源数目的大小(信号量能够更细粒度的对临界资源进行管理)。
- 本节要讲的POSIX信号量和SystemV信号量作用相同,都是用于同步互斥操作,达到无冲突的访问共享资源目的。 但POSIX可以用于线程间同步。
信号量原理
- 我们将可能会被多个执行流同时访问的资源叫做临界资源,临界资源需要进行保护否则会出现数据不一致等问题。
- 当我们仅用一个互斥锁对临界资源进行保护时,相当于我们将这块临界资源看作一个整体,同一时刻只允许一个执行流对这块临界资源进行访问。
- 但实际上如果我们处理得当,可以将这块临界资源再分割为多个区域,当多个执行流需要访问临界资源时,如果这些执行流访问的是临界资源的不同区域,那么我们可以让这些执行流同时访问临界资源的不同区域,此时不会出现数据不一致等问题,也就是说我么可以让多个线程线程访问临界资源的不同区域,从而实现并发。
每个执行流在进入临界区之前都应该先申请信号量,申请成功就有了操作特点的临界资源的权限,当操作完毕后就应该释放信号量。
信号量的PV操作
信号量表示资源的数量,控制信号量的方式有两种原子操作:
- 一个是 P 操作,这个操作会把信号量减去 -1,相减后如果信号量 < 0,则表明资源已被占用,进程需阻塞等待;相减后如果信号量 >= 0,则表明还有资源可使用,进程可正常继续执行。
- 另一个是 V 操作,这个操作会把信号量加上 1,相加后如果信号量 <= 0,则表明当前有阻塞中的进程,于是会将该进程唤醒运行;相加后如果信号量 > 0,则表明当前没有阻塞中的进程;
P 操作是用在进入共享资源之前,V 操作是用在离开共享资源之后,这两个操作是必须成对出现的。
接下来,举个例子,如果要使得两个进程互斥访问共享内存,我们可以初始化信号量为 1。
具体的过程如下:
- 进程 A 在访问共享内存前,先执行了 P 操作,由于信号量的初始值为 1,故在进程 A 执行 P 操作后信号量变为 0,表示共享资源可用,于是进程 A 就可以访问共享内存。
- 若此时,进程 B 也想访问共享内存,执行了 P 操作,结果信号量变为了 -1,这就意味着临界资源已被占用,因此进程 B 被阻塞。
- 直到进程 A 访问完共享内存,才会执行 V 操作,使得信号量恢复为 0,接着就会唤醒阻塞中的线程 B,使得进程 B 可以访问共享内存,最后完成共享内存的访问后,执行 V 操作,使信号量恢复到初始值 1。
可以发现,信号初始化为 1,就代表着是互斥信号量,它可以保证共享内存在任何时刻只有一个进程在访问,这就很好的保护了共享内存。
另外,在多进程里,每个进程并不一定是顺序执行的,它们基本是以各自独立的、不可预知的速度向前推进,但有时候我们又希望多个进程能密切合作,以实现一个共同的任务。
例如,进程 A 是负责生产数据,而进程 B 是负责读取数据,这两个进程是相互合作、相互依赖的,进程 A 必须先生产了数据,进程 B 才能读取到数据,所以执行是有前后顺序的。
那么这时候,就可以用信号量来实现多进程同步的方式,我们可以初始化信号量为 0。
具体过程:
- 如果进程 B 比进程 A 先执行了,那么执行到 P 操作时,由于信号量初始值为 0,故信号量会变为 -1,表示进程 A 还没生产数据,于是进程 B 就阻塞等待;
- 接着,当进程 A 生产完数据后,执行了 V 操作,就会使得信号量变为 0,于是就会唤醒阻塞在 P 操作的进程 B;
- 最后,进程 B 被唤醒后,意味着进程 A 已经生产了数据,于是进程 B 就可以正常读取数据了。
可以发现,信号初始化为 0,就代表着是同步信号量,它可以保证进程 A 应在进程 B 之前执行。
PV操作的原子性
多个执行流为了访问临界资源会竞争式的申请信号量,因此信号量是会被多个执行流同时访问的,也就是说信号量本质也是临界资源。
所以我们必须保证信号量的PV操作是原子操作。
我们知道对于信号量的++、–操作并不是原子操作,因此PV操作不可能只是简单的对一个全局变量进行++、–操作。下面简单用一个伪代码理解PV操作的实现原理:
根据图得知,信号量本质也是临界资源,所以在申请信号量之前,先进行加锁,然后如果count小于0,表示线程需阻塞等待,否则对conut–,最后进行解锁;这样下来,就保证了PV操作的原子性。
信号量函数
信号量的类型是sem_t,我们可以根据这个类型自己定义信号量对象:
#include<semaphore.h>
sem_t sem1;
sem_t sem2;
初始化信号量
初始化信号量的函数叫做sem_init
该函数的函数原型如下:
int sem_init(sem_t *sem, int pshared, unsigned int value);
参数说明:
- sem:需要初始化的信号量。
- pshared:传入0值表示线程间共享,传入非零值表示进程间共享。
- value:信号量的初始值(计数器的初始值)。
函数说明:
该函数主要用于设置信号量对象的基本属性。
销毁信号量
销毁信号量的函数叫做sem_destroy
该函数的函数原型如下:
int sem_destroy(sem_t *sem);
参数说明:
- sem:需要销毁的信号量。
函数说明:
只需传入信号量对象的地址即可销毁该信号量。
等待信号量(申请信号量)
等待信号量的函数叫做sem_wait
该函数的函数原型如下:
int sem_wait(sem_t *sem);
参数说明:
- sem:需要等待的信号量。
函数说明:
传入信号量对象的地址用于申请该信号量,调用成功返回0,count- -;失败返回-1,count值不变。
发布信号量(释放信号量)
发布信号量的函数叫做sem_post
该函数的函数原型如下:
int sem_post(sem_t *sem);
参数说明:
- sem:需要发布的信号量。
函数说明:
传入信号量对象的地址用于释放该信号量,调用成功返回0,count++;失败返回-1,count值不变。
二元信号量模拟实现互斥功能
当count = 1
时,说明整块临界资源作为一个整体使用而没有被切分管理,那么这个信号量对象就相当于是一把互斥锁,称为二元信号量。
下面我们用二元信号量模拟互斥锁完成抢票代码:
- 在主线程中创建4个新线程去抢10张票。
- 此时票是临界资源,我们用二元信号量对其进行保护。
- 每个新线程抢票之前都要先申请二元信号量,没有申请到线程被阻塞挂起。
#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <semaphore.h>
using namespace std;
// 封装一个自己的信号量类
class MySem
{
public:
// 构造函数,用于初始化信号量对象,默认线程间通信,只需传入需要设置的count得值即可
MySem(size_t num)
{
sem_init(&_sem, 0, num);
}
// 析构函数,销毁信号量对象
~MySem()
{
sem_destroy(&_sem);
}
// 申请信号量
void P()
{
sem_wait(&_sem);
}
// 释放信号量
void V()
{
sem_post(&_sem);
}
private:
// 成员变量是一个信号量对象
sem_t _sem;
};
// 定义的全局对象
int count = 10;// 票数设为10张
MySem sem(1);// 一元信号量
// 新线程执行的抢票逻辑
void* GetTickets(void* arg)
{
while(true)
{
size_t id = (size_t)arg;
sem.P();
if(count > 0)
{
usleep(1000);
cout<<'['<<"thread "<<id<<']'<<" get ticket No."<<count--<<endl;
sem.V();
}
else
{
sem.V();
break;
}
}
return nullptr;
}
int main()
{
// 创建4个新线程
pthread_t tids[4];
for(size_t i = 0; i < 4; ++i)
{
pthread_create(&tids[i], nullptr, GetTickets, (void*)(i+1));
}
// 等待4个新线程
for(size_t i = 0; i < 4; ++i)
{
pthread_join(tids[i], nullptr);
}
return 0;
}
编译运行,由于我们没有实现同步所以都是第一个创建的1号线程申请到信号量,但是最终票的结果是对的,说明互斥是实现了的:
基于环形队列的生产消费模型
1.环形队列的介绍
环形队列是什么
队列是一种常用的数据结构,这种结构保证了数据是按照“先进先出”的原则进行操作的,即最先进去的元素也是最先出来的元素.环形队列是一种特殊的队列结构,保证了元素也是先进先出的,但与一般队列的区别是,他们是环形的,即队列头部的上个元素是队列尾部,通常是容纳元素数固定的一个闭环。
环形队列的优缺点
- 1、 保证元素是先进先出的
- 2、元素空间可以重复利用
- 3、为多线程数据通信提供了一种高效的机制。
在最典型的生产者消费者模型中,如果引入环形队列,那么生成者只需要生成“东西”然后放到环形队列中即可,而消费者只需要从环形队列里取“东西”并且消费即可,没有任何锁或者等待,巧妙的高效实现了多线程数据通信。
环形队列的原理分析
2.基于环形队列的生产消费模型
我们可以用环形队列 实现生产消费模型,同时,我们需要有以下认知:
- 生产者只关心是否还有格子用来生产数据。,消费者只关心环形队列中是否还有数据。
对于生产者和消费者来说,它们关注的资源是不同的:
- 生产者关注的是环形队列当中是否有空间(blank),只要有空间生产者就可以进行生产。
- 消费者关注的是环形队列当中是否有数据(data),只要有数据消费者就可以进行消费。
- 一开始没有数据,生产者和消费者指向同一个位置,这时生产者要先执行生产操作,消费者阻塞挂起;数据满时,生产者和消费者也指向同一个位置,这时消费者先执行消费操作再轮到生产者生产。
也就是说:当指向同一个位置时,要判断空,满的状态,来判定让谁先执行。
- 生产者和消费者不能同时访问队列中的同一个位置。
如果生产者和消费者访问的是环形队列当中的同一个位置,那么此时生产者和消费者就相当于同时对这一块临界资源进行了访问,这是不允许的。
- 无论是生产者还是消费者,都不应该将对方套一个圈以上。
假设生产者生产的快,消费者消费的慢,当生产者套消费者一圈遇到消费者:
- 消费者下一步要消费之前生产者生产的t1数据,但是由于生产者速度快,就会将t1数据覆盖,并且生产为新的数据,那么会造成生产的t1数据没有被消费过就已经被覆盖了。
假设生产者生产的慢,消费者消费的快,当消费者消费完生产者生产的一圈数据再次遇到生产者:
- 此时不能再往下进行消费了,因为后面没有数据供消费者消费。
- 生产者和消费者可以并发访问环形队列中的不同位置。
blank_sem和data_sem的初始值设置
我们用信号量来描述环形队列当中的空间资源(blank_sem)和数据资源(data_sem),在我们初始信号量时给它们设置的初始值是不同的:
- blank_sem的初始值我们应该设置为环形队列的容量,因为刚开始时环形队列当中全是空间。
- data_sem的初始值我们应该设置为0,因为刚开始时环形队列当中没有数据。
生产者和消费者申请和释放资源
生产者申请空间资源,释放数据资源
对于生产者来说,生产者每次生产数据前都需要先申请blank_sem:
- 如果blank_sem的值不为0,则信号量申请成功,此时生产者可以进行生产操作。
- 如果blank_sem的值为0,则信号量申请失败,此时生产者需要在blank_sem的等待队列下进行阻塞等待,直到环形队列当中有新的空间后再被唤醒。
当生产者生产完数据后,应该释放data_sem:
- 虽然生产者在进行生产前是对blank_sem进行的P操作(对空间资源进行- -),但是当生产者生产完数据,应该对data_sem进行V操作(对数据资源进行++)而不是blank_sem。
生产者在生产数据前申请到的是blank位置,当生产者生产完数据后,该位置当中存储的是生产者生产的数据,在该数据被消费者消费之前,该位置不再是blank位置,而应该是data位置。
消费者申请数据资源,释放空间资源
对于消费者来说,消费者每次消费数据前都需要先申请data_sem:
- 如果data_sem的值不为0,则信号量申请成功,此时消费者可以进行消费操作。
- 如果data_sem的值为0,则信号量申请失败,此时消费者需要在data_sem的等待队列下进行阻塞等待,直到环形队列当中有新的数据后再被唤醒。
当消费者消费完数据后,应该释放blank_sem:
- 虽然消费者在进行消费前是对data_sem进行的P操作(对数据资源进行- -),但是当消费者消费完数据,应该对blank_sem进行V操作(对空间资源进行++)而不是data_sem。
消费者在消费数据前申请到的是data位置,当消费者消费完数据后,该位置当中的数据已经被消费过了,再次被消费就没有意义了,为了让生产者后续可以在该位置生产新的数据,我们应该将该位置算作blank位置,而不是data位置。
当消费者消费完数据后,意味着环形队列当中多了一个blank位置,因此我们应该对blank_sem进行V操作。
代码实现
其中的RingQueue就是生产者消费者模型当中的交易场所,我们可以用C++STL库当中的vector进行实现。
#pragma once
#include<iostream>
#include<vector>
#include<semaphore.h>
using namespace std;
namespace ns_ring_queue
{
const int g_cap_default=10;
template<class T>
class RingQueue
{
private:
vector<T> ring_queue_;
int cap_;//环形队列容量上限
//生产者关心空位置资源
sem_t blank_sem_; //描述空间资源
//消费者关心数据资源
sem_t data_sem_;//描述数据资源
//写入位置的标记
int c_step_;//消费位置
int p_step_;//生产位置
public:
RingQueue(int cap = g_cap_default) : ring_queue_(cap), cap_(cap)
{
sem_init(&blank_sem_,0,cap);
sem_init(&data_sem_,0,0);
c_step_=p_step_=0;
}
~RingQueue()
{
sem_destroy(&blank_sem_);
sem_destroy(&data_sem_);
}
public:
//目前高优先级的先实现单生产和单消费
void Push(const T &in)
{
//生产接口
sem_wait(&blank_sem_);//P(空位置)
//可以生产了,可是往哪个位置生产呢?
ring_queue_[p_step_]=in;
sem_post(&data_sem_);//V(数据)
//更新下一次生产的位置
p_step_++;
p_step_%=cap_;
}
void Pop(T* out)
{
//消费接口
sem_wait(&data_sem_);//P
*out=ring_queue_[c_step_];
sem_post(&blank_sem_);
//更新下一次消费的位置
c_step_++;
c_step_%=cap_;
}
};
}
相关说明:
- 当不设置环形队列的大小时,我们默认将环形队列的容量上限设置为10。
- 代码中的RingQueue是用vector实现的,生产者每次生产的数据放到vector下标为p_step_的位置,消费者每次消费的数据来源于vector下标为c_step_的位置。
- 生产者每次生产数据后p_step_都会进行++,即更新为下一次生产数据的存放位置,++后的下标会与环形队列的容量进行取模运算,实现“环形”的效果。
- 消费者每次消费数据后c_step_都会进行++,即更新为下一次消费数据的来源位置,++后的下标会与环形队列的容量进行取模运算,实现“环形”的效果。
单生产者单消费者
我们这里实现单生产者、单消费者的生产者消费者模型。在主函数我们就只需要创建一个生产者线程和一个消费者线程,生产者线程不断生产数据放入环形队列,消费者线程不断从环形队列里取出数据进行消费。
#include"ring_queue.hpp"
#include<pthread.h>
#include<time.h>
#include<unistd.h>
using namespace ns_ring_queue;
void* consumer(void* args)
{
RingQueue<int> *rq=(RingQueue<int>*)args;
while(true)
{
int data=0;
rq->Pop(&data);
cout<<"消费数据是:"<<data<<endl;
sleep(1);
}
}
void* producter(void* args)
{
RingQueue<int> *rq=(RingQueue<int>*)args;
while(true)
{
int data=rand()%20+1;
cout<<"生产数据是:"<<data<<endl;
rq->Push(data);
sleep(1);
}
}
int main()
{
//如果我想改成多生产者,多消费者模型,该怎么写
srand((long long)time(nullptr));
RingQueue<int> *rq=new RingQueue<int>();
pthread_t c,p;
pthread_create(&c,nullptr,consumer,(void*)rq);
pthread_create(&p,nullptr,producter,(void*)rq);
pthread_join(c,nullptr);
pthread_join(p,nullptr);
return 0;
}
环形队列要让生产者线程向队列中Push数据,让消费者线程从队列中Pop数据,因此这个环形队列必须要让这两个线程同时看到,所以我们在创建生产者线程和消费者线程时,需要将环形队列作为线程执行例程的参数进行传入。
由于代码中生产者是每隔一秒生产一个数据,而消费者是每隔一秒消费一个数据,因此运行代码后我们可以看到生产者和消费者的执行步调是一致的。
生产者生产的快,消费者消费的慢
我们可以让生产者不停的进行生产,而消费者每隔一秒进行消费:
void* consumer(void* args)
{
RingQueue<int> *rq=(RingQueue<int>*)args;
while(true)
{
int data=0;
rq->Pop(&data);
cout<<"消费数据是:"<<data<<endl;
sleep(1);
}
}
void* producter(void* args)
{
RingQueue<int> *rq=(RingQueue<int>*)args;
while(true)
{
int data=rand()%20+1;
cout<<"生产数据是:"<<data<<endl;
rq->Push(data);
//sleep(1);
}
}
此时由于生产者生产的很快,运行代码后一瞬间生产者就将环形队列塞满了,此时生产者想要再进行生产,但空间资源已经为0了,于是生产者只能在blank_sem的等待队列下进行阻塞等待,直到由消费者消费完一个数据后对blank_sem进行了V操作,生产者才会被唤醒进而继续进行生产。
此时生产者的生产速度很快,生产者生产完一个数据后(队列满了)又会进行等待,因此后续生产者和消费者的步调又变成一致的了。
生产者生产的慢,消费者消费的快
我们也可以让生产者每隔一秒进行生产,而消费者不停的进行消费。
void* consumer(void* args)
{
RingQueue<int> *rq=(RingQueue<int>*)args;
while(true)
{
int data=0;
rq->Pop(&data);
cout<<"消费数据是:"<<data<<endl;
//sleep(1);
}
}
void* producter(void* args)
{
RingQueue<int> *rq=(RingQueue<int>*)args;
while(true)
{
int data=rand()%20+1;
cout<<"生产数据是:"<<data<<endl;
rq->Push(data);
sleep(1);
}
}
虽然消费者消费的很快,但一开始环形队列当中的数据资源为0,因此消费者只能在data_sem的等待队列下进行阻塞等待,直到生产者生产完一个数据后对data_sem进行了V操作,消费者才会被唤醒进而进行消费。
由于消费者的消费速度很快,生产者生产一个数据后立马被消费者消费,此时消费者又会进行等待,因此此情况下生产者和消费者的步调看起来是一致。
多生产者多消费者
接下来我们实现基于环形队列的多生产者多消费者:
- 这次我们在主线程中分别新建三个生产者线程、三个消费者线程。
- 在生产者和消费者获取到信号量之后,生产者之间竞争p_mtx_这把锁,消费者之间竞争c_mtx_这把锁,竞争到锁的线程才能去生产或拿取数据,它们完成一次操作后释放锁,然后线程间重新竞争。
#pragma once
#include<iostream>
#include<vector>
#include<semaphore.h>
#include<pthread.h>
using namespace std;
namespace ns_ring_queue
{
const int g_cap_default=10;
template<class T>
class RingQueue
{
private:
vector<T> ring_queue_;
int cap_;//环形队列容量上限
//生产者关心空位置资源
sem_t blank_sem_; //描述空间资源
//消费者关心数据资源
sem_t data_sem_;//描述数据资源
//写入位置的标记
int c_step_;//消费位置
int p_step_;//生产位置
//定义锁
pthread_mutex_t c_mtx_;
pthread_mutex_t p_mtx_;
public:
RingQueue(int cap = g_cap_default) : ring_queue_(cap), cap_(cap)
{
sem_init(&blank_sem_,0,cap);
sem_init(&data_sem_,0,0);
c_step_=p_step_=0;
//初始化锁
pthread_mutex_init(&c_mtx_,nullptr);
pthread_mutex_init(&p_mtx_,nullptr);
}
~RingQueue()
{
sem_destroy(&blank_sem_);
sem_destroy(&data_sem_);
//释放锁
pthread_mutex_destroy(&c_mtx_);
pthread_mutex_destroy(&p_mtx_);
}
public:
//目前高优先级的先实现单生产和单消费
//多生产和多消费的优势,在于并发的获取和处理任务
void Push(const T &in)
{
//生产接口
sem_wait(&blank_sem_);//P(空位置)
//获取到信号量的多个线程进行竞争锁
pthread_mutex_lock(&p_mtx_);
//可以生产了,可是往哪个位置生产呢?
ring_queue_[p_step_]=in;
//它也变成了临界资源
//更新下一次生产的位置
p_step_++;
p_step_%=cap_;
pthread_mutex_unlock(&p_mtx_);
sem_post(&data_sem_);//V(数据)
}
void Pop(T* out)
{
//消费接口
sem_wait(&data_sem_);//P
pthread_mutex_lock(&c_mtx_);
*out=ring_queue_[c_step_];
//更新下一次消费的位置
c_step_++;
c_step_%=cap_;
pthread_mutex_unlock(&c_mtx_);
sem_post(&blank_sem_);
}
};
}
#include"ring_queue.hpp"
#include<pthread.h>
#include<time.h>
#include<unistd.h>
using namespace ns_ring_queue;
void* consumer(void* args)
{
RingQueue<int> *rq=(RingQueue<int>*)args;
while(true)
{
int data=0;
rq->Pop(&data);
cout<<"消费数据:"<<data<<"我是:"<<pthread_self()<<endl;
sleep(1);
}
}
void* producter(void* args)
{
RingQueue<int> *rq=(RingQueue<int>*)args;
while(true)
{
int data=rand()%20+1;
cout<<"生产数据:"<<data<<"我是:"<<pthread_self()<<endl;
rq->Push(data);
sleep(1);
}
}
int main()
{
//如果我想改成多生产者,多消费者模型,该怎么写
srand((long long)time(nullptr));
RingQueue<int> *rq=new RingQueue<int>();
pthread_t c0,c1,c2,p0,p1,p2;
pthread_create(&c0, nullptr, consumer, (void*)rq);
pthread_create(&c1, nullptr, consumer, (void*)rq);
pthread_create(&c2, nullptr, consumer, (void*)rq);
pthread_create(&p0, nullptr, producter, (void*)rq);
pthread_create(&p1, nullptr, producter, (void*)rq);
pthread_create(&p2, nullptr, producter, (void*)rq);
pthread_join(c0, nullptr);
pthread_join(c1, nullptr);
pthread_join(c2, nullptr);
pthread_join(p0, nullptr);
pthread_join(p1, nullptr);
pthread_join(p2, nullptr);
return 0;
}
编译运行,生产和消费操作实现了并发执行:
总结:
- 我们要知道,在生产者消费者模型中,对于谁把数据放到队列里,谁把数据从队列里拿出来,以及谁快谁慢—这都不是主要矛盾;推送到队列的数据的制造过程(数据从哪里来),以及计算数据的过程(计算任务所用的时间)–才是主要矛盾,也就是说,解决并发性是此模型最大的作用。
– the End –
以上就是我分享的POSIX信号量相关内容,感谢阅读!
本文收录于专栏:Linux
关注作者,持续阅读作者的文章,学习更多知识!
https://blog.csdn.net/weixin_53306029?spm=1001.2014.3001.5343