目录
1.空间资源(SpaceSem)和数据资源(DataSem)
POSIX信号量
1. 为什么需要信号量?
POSIX信号量和SystemV信号量作用相同,都是用于同步操作,达到无冲突的访问共享资源目的。 但POSIX可以用于线程间同步。
- 我们将可能会被多个执行流同时访问的资源叫做临界资源,临界资源需要进行保护否则会出现数据不一致等问题。
- 当我们仅用一个互斥锁对临界资源进行保护时,相当于我们将这块临界资源看作一个整体,同一时刻只允许一个执行流对这块临界资源进行访问。
- 但实际我们可以将这块临界资源再分割为多个区域,当多个执行流需要访问临界资源时,如果这些执行流访问的是临界资源的不同区域,那么我们可以让这些执行流同时访问临界资源的不同区域,此时不会出现数据不一致等问题。这样就提高了临界资源的使用效率!
举个例子来加深理解:我们平时去看电影院看电影,电影院就相当于是临界资源,我们每个人都可以申请去电影院这个临界资源看电影,但是电影院有很多座位,这意味着不止电影院同一时刻不止允许一个人进行看电影,每个买了票的人都可以进行去电影院看电影。这样一来,电影院这块临界资源就可以在同一时刻让许多人去看电影!这样就提高了电影院这个临界资源的使用效率!
注意:我们申请到临界资源是我们买了票(申请到临界资源)就有访问的权限,而不是一定要去电影院坐着对应的位置才算拥有这块临界资源的访问权限。(我们买到票了位置就得给我们留着,但我们因为各种原因不一定会去电影院坐着对应的位置)
2. 信号量的概念
信号量(信号灯)本质是一个计数器,是描述临界资源中资源数目的计数器,信号量能够更细粒度的对临界资源进行管理。
每个执行流在进入临界区之前都应该先申请信号量,申请成功就有了操作特定的临界资源的权限,当操作完毕后就应该释放信号量。
信号量的PV操作:
- P操作:我们将申请信号量称为P操作,申请信号量的本质就是申请获得临界资源中某块资源的使用权限,当申请成功时临界资源中资源的数目应该 -1,因此P操作的本质就是让计数器 -1。
- V操作:我们将释放信号量称为V操作,释放信号量的本质就是归还临界资源中某块资源的使用权限,当释放成功时临界资源中资源的数目就应该 +1,因此V操作的本质就是让计数器 +1。
注意:PV操作必须是原子操作
- 多个执行流为了访问临界资源会竞争式的申请信号量,因此信号量是会被多个执行流同时访问的,也就是说信号量本质也是临界资源。
- 但信号量本质就是用于保护临界资源的,我们不可能再用信号量去保护信号量,所以信号量的PV操作必须是原子操作。
- 内存当中变量的++、--操作并不是原子操作,因此信号量不可能只是简单的对一个全局变量进行++、--操作。
申请信号量失败被挂起等待
当执行流在申请信号量时,可能此时信号量的值为0,也就是说信号量描述的临界资源已经全部被申请了,此时该执行流就应该在该信号量的等待队列当中进行等待,直到有信号量被释放时再被唤醒。
注意: 信号量的本质是计数器,但不意味着只有计数器,信号量还包括一个等待队列。
3. 信号量函数
- 初始化信号量函数为sem_init,函数原型如下:
#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);
参数说明:
- sem:需要初始化的信号量。
- pshared:0表示线程间共享,非零表示进程间共享
- value:信号量初始值
返回值:
- 初始化信号量成功返回0,失败返回-1。
- 销毁信号量函数为sem_destroy,函数原型如下:
int sem_destroy(sem_t *sem);
参数说明:
- sem:需要销毁的信号量。
返回值说明:
- 销毁信号量成功返回0,失败返回-1。
- 等待信号量sem_wait,函数原型如下:
int sem_wait(sem_t *sem); //P()
功能:等待信号量,会将信号量的值减1
参数说明:
- sem:需要等待的信号量。
返回值说明:
- 等待信号量成功返回0,信号量的值-1。
- 等待信号量失败返回-1,信号量的值保持不变。
- 发布信号量sem_post,函数原型如下
int sem_post(sem_t *sem);//V()
功能:发布信号量,表示资源使用完毕,可以归还资源了。将信号量值+1。
参数说明:
- sem:需要发布的信号量。
返回值说明:
- 发布信号量成功返回0,信号量的值+1。
- 发布信号量失败返回-1,信号量的值保持不变。
上一节生产者-消费者的例子是基于阻塞queue的,其空间可以动态分配,现在基于固定大小的环形队列重写这个程序(POSIX信号量):
基于环形队列的生产消费模型
- 环形队列采用数组模拟,用模运算来模拟环状特性
- 环形结构起始状态和结束状态都是一样的,不好判断为空或者为满,所以可以通过加计数器或者标记位来判断满或者空。另外也可以预留一个空的位置,作为满的状态
- 但是我们现在有信号量这个计数器,就很简单的进行多线程间的同步过程
1.空间资源(SpaceSem)和数据资源(DataSem)
- 生产者关注什么资源呢?还剩多少空间(SpaceSem),初始值设置为环形队列的容量,因为刚开始时环形队列当中全是空间。
- 消费者关注什么资源呢?还有多少剩余数据(DataSem),初始值应该设置为0,因为刚开始时环形队列当中没有数据。
2.生产者和消费者申请和释放资源
- 生产者申请空间资源,释放数据资源
对于生产者来说,生产者每次生产数据前都需要先申请SpaceSem:
如果SpaceSem的值不为0,则信号量申请成功,此时生产者可以进行生产操作。
如果SpaceSem的值为0,则信号量申请失败,此时生产者需要在SpaceSem的等待队列下进行阻塞等待,直到环形队列当中有新的空间后再被唤醒。
当生产者生产完数据后,应该释放data_sem:
当生产者生产完数据后,意味着环形队列当中多了一个data位置,因此我们应该对data_sem进行V操作。
- 消费者申请数据资源,释放空间资源
对于消费者来说,消费者每次消费数据前都需要先申请data_sem:
如果data_sem的值不为0,则信号量申请成功,此时消费者可以进行消费操作。
如果data_sem的值为0,则信号量申请失败,此时消费者需要在data_sem的等待队列下进行阻塞等待,直到环形队列当中有新的数据后再被唤醒。
当消费者消费完数据后,应该释放SpaceSem:
当消费者消费完数据后,意味着环形队列当中多了一个Space位置,因此我们应该对SpaceSem进行V操作。
3.消费者和生产者正常进行追逐游戏,必须满足的三个条件:
在基于环形队列的生产者和消费者模型当中,生产者和消费者必须遵守如三个规则。
1.指向同一个位置,只能一个人进行访问
生产者和消费者在访问环形队列时:
- 如果生产者和消费者访问的是环形队列当中的同一个位置,那么此时生产者和消费者就相当于同时对这一块临界资源进行了访问,这当然是不允许的。
- 而如果生产者和消费者访问的是环形队列当中的不同位置,那么此时生产者和消费者是可以同时进行生产和消费的,此时不会出现数据不一致等问题。
2.生产者不能将消费者套成一个圈。
- 生产者从消费者的位置开始一直按一个方向进行生产,如果生产者生产的速度比消费者消费的速度快,那么当生产者绕着消费者生产了一圈数据后再次遇到消费者,此时生产者就不应该再继续生产了,因为再生产就会覆盖还未被消费者消费的数据。
3.消费者不能超过生产者
- 同理,消费者从生产者的位置开始一直按一个方向进行消费,如果消费者消费的速度比生产者生产的速度快,那么当消费者绕着生产者消费了一圈数据后再次遇到生产者,此时消费者就不应该再继续消费了,因为再消费就会消费到缓冲区中保存的废弃数据。
4.信号量保护环形队列的方法
在space_sem和data_sem两个信号量的保护后,该环形队列中不可能会出现数据不一致的问题。
因为只有当生产者和消费者指向同一个位置并访问时,才会导致数据不一致的问题,而此时生产者和消费者在对环形队列进行写入或读取数据时,只有两种情况会指向同一个位置:
- 环形队列为空时。
- 环形队列为满时。
但是在这两种情况下,生产者和消费者不会同时对环形队列进行访问:
- 当环形队列为空的时,消费者一定不能进行消费,因为此时数据资源为0。
- 当环形队列为满的时,生产者一定不能进行生产,因为此时空间资源为0。
也就是说,当环形队列为空和满时,我们已经通过信号量保证了生产者和消费者的串行化过程。而除了这两种情况之外,生产者和消费者指向的都不是同一个位置,因此该环形队列当中不可能会出现数据不一致的问题。并且大部分情况下生产者和消费者指向并不是同一个位置,因此大部分情况下该环形队列可以让生产者和消费者并发的执行。
5.代码实现
其中的RingQueue就是生产者消费者模型当中的交易场所,我们可以用C++STL库当中的vector进行实现。
RingQueue.hpp
#pragma once
#include <iostream>
#include <vector>
#include <semaphore.h>
#include <pthread.h>
using namespace std;
const static int defaultcap = 5;
template<class T>
class RingQueue
{
public:
void P(sem_t &sem)
{
sem_wait(&sem);
}
void V(sem_t &sem)
{
sem_post(&sem);
}
void Lock(pthread_mutex_t& mutex)
{
pthread_mutex_lock(&mutex);
}
void UnLock(pthread_mutex_t& mutex)
{
pthread_mutex_unlock(&mutex);
}
public:
RingQueue(int cap = defaultcap)
:ringqueue_(cap),cap_(cap),p_step_(0),c_step_(0)
{
sem_init(&p_space_sem,0,cap);//p_space_sem初始值设置为环形队列的容量
sem_init(&c_data_sem,0,0);//c_data_sem初始值设置为0
pthread_mutex_init(&p_mutex_,nullptr);
pthread_mutex_init(&p_mutex_,nullptr);
}
//生产,向环形队列插入数据
void Push(const T& in)
{
P(p_space_sem);
Lock(p_mutex_);//为什么P在Lock外面?
ringqueue_[p_step_] = in;
// 位置后移,维持环形特性
p_step_++;
p_step_%=cap_;
UnLock(p_mutex_);
V(c_data_sem);
}
//消费,从环形队列获取数据
void Pop(T* out)
{
P(c_data_sem);
Lock(c_mutex_);
*out = ringqueue_[c_step_];
//位置后移,维持环形特性
c_step_++;
c_step_%=cap_;
UnLock(c_mutex_);
V(p_space_sem);
}
~RingQueue()
{
sem_destroy(&p_space_sem);
sem_destroy(&c_data_sem);
pthread_mutex_destroy(&p_mutex_);
pthread_mutex_destroy(&c_mutex_);
}
private:
vector<T> ringqueue_;//环形队列
int cap_;//环形队列的容量上限
int p_step_;//生产者下标
int c_step_;//消费者下标
sem_t p_space_sem;//生产者关注的空间资源
sem_t c_data_sem;//消费者关注的数据资源
pthread_mutex_t p_mutex_;
pthread_mutex_t c_mutex_;
};
代码说明:
- 当不设置环形队列的大小时,我们默认将环形队列的容量上限设置为5。
- 代码中的RingQueue是用vector实现的,生产者每次生产的数据放到vector下标为p_pos的位置,消费者每次消费的数据来源于vector下标为c_pos的位置。
- 生产者每次生产数据后p_pos都会进行++,标记下一次生产数据的存放位置,++后的下标会与环形队列的容量进行取模运算,实现“环形”的效果。
- 消费者每次消费数据后c_pos都会进行++,标记下一次消费数据的来源位置,++后的下标会与环形队列的容量进行取模运算,实现“环形”的效果。
- 要实现多生产多消费,同一个时间只能有一个生产者进行生产,一个消费者进行消费。这样才能防止任务被覆盖。所以我们用两把锁分别对每次生产和p_step_、c_step_的操作和进行保护起来。
- 为什么申请信号量放在申请锁的外面?1.因为信号量是原子的,不需要锁保护。2. 当一个线程持有锁,其它线程可以先去申请信号量,只要申请到了信号量,资源就被这个线程预定了,下一步只要等待锁释放即可访问临界资源。也就是说,如果现在一个生产者线程在生产,其它生产者线程可以先去申请信号量。而如果把申请信号量放在申请锁里面,就是持有锁才能申请信号量,这样申请信号量的线程永远只有一个,就不能够很好的提高并发度。
Task.hpp
#pragma once
#include <iostream>
#include <string>
using namespace std;
string opers = "+-*/%";
enum{
DivZero = 1,
ModZero,
Unknown
};
class Task
{
public:
Task()
{
}
Task(int data1,int data2,char oper)
:data1_(data1),data2_(data2),oper_(oper),result_(0),exitcode_(0)
{
}
void run()
{
switch (oper_)
{
case '+':
result_ = data1_ + data2_;
break;
case '-':
result_ = data1_ - data2_;
break;
case '*':
result_ = data1_ * data2_;
break;
case '/':
{
if(data2_ == 0) exitcode_ = DivZero;
else result_ = data1_ / data2_;
}
break;
case '%':
{
if(data2_ == 0) exitcode_ = ModZero ;
else result_ = data1_ % data2_;
}
break;
default:
exitcode_ = Unknown;
break;
}
}
void operator()()
{
run();
}
string GetResult()
{
string r = to_string(data1_);
r += oper_;
r += to_string(data2_);
r += "=";
r += to_string(result_);
r += "[code: ";
r += to_string(exitcode_);
r += "]";
return r;
}
string GetTask()
{
string r = to_string(data1_);
r += oper_;
r += to_string(data2_);
r += "=?";
return r;
}
~Task()
{}
private:
int data1_;
int data2_;
char oper_;
int result_;
int exitcode_;
};
为了方便理解,我们这里先实现单生产者、单消费者的生产者消费者模型。于是在主函数我们就只需要创建一个生产者线程和一个消费者线程,生产者线程不断生产数据放入环形队列,消费者线程不断从环形队列里取出数据进行消费。
main.cpp
#include<iostream>
#include<pthread.h>
#include<unistd.h>
#include<ctime>
#include"RingQueue.hpp"
#include"Task.hpp"
using namespace std;
struct ThreadData
{
RingQueue<Task> *rq;
string threadname;
};
void* Productor(void* args)
{
ThreadData* td = static_cast<ThreadData*>(args);
RingQueue<Task> *rq = td->rq;
string name = td->threadname;
int len = opers.size();
while (true)
{
//1.获取数据
int data1 = rand()%10+1;
usleep(10);
char oper = opers[rand()%len];
int data2 = rand()%5;
Task t(data1,data2,oper);
//2.生产数据
rq->Push(t);
cout << "Productor task done, task is" << t.GetTask() << "who:" << name <<endl;
sleep(1);
}
return nullptr;
}
void* Consumer(void* args)
{
ThreadData* td = static_cast<ThreadData*>(args);
RingQueue<Task> *rq = td->rq;
string name = td->threadname;
while (true)
{
sleep(1);
//1.消费数据
Task t;
rq->Pop(&t);
//2.处理数据
t();
cout << "Consumer get task, task is :" << t.GetTask() << " who: " << name << "result:" << t.GetResult() << endl;
}
return nullptr;
}
int main()
{
srand(time(nullptr)^getpid());
RingQueue<Task>* rq = new RingQueue<Task>(10);
pthread_t p[3],c[5];
for(int i = 0;i < 1;++i)
{
ThreadData* td = new ThreadData();
td->rq = rq;
td->threadname = "Productor-" + to_string(i);
pthread_create(p+i,nullptr,Productor,td);
}
for(int i = 0;i < 1;++i)
{
ThreadData* td = new ThreadData();
td->rq = rq;
td->threadname = "Consumer-" + to_string(i);
pthread_create(c+i,nullptr,Consumer,td);
}
for (int i = 0; i < 1; i++)
{
pthread_join(p[i],nullptr);
}
for (int i = 0; i < 1; i++)
{
pthread_join(c[i],nullptr);
}
return 0;
}
代码说明:
- 环形队列要让生产者线程向队列中Push数据,让消费者线程从队列中Pop数据,因此这个环形队列必须要让这两个线程同时看到,所以我们在创建生产者线程和消费者线程时,需要将环形队列作为线程执行例程的参数进行传入。代码中我封装了一个ThreadData结构体,存放着线程的name和环形队列。
- 代码中生产者生产数据就是将获取到的随机数Push到环形队列,而消费者就是从环形队列Pop数据,为了便于观察,我们可以将生产者生产的数据和消费者消费的数据进行打印输出。
生产者消费者步调一致
代码中我们让生产者每个一秒生产一次,消费者每隔一秒消费一次,因此运行代码后我们可以看到生产者和消费者的执行步调是一致的。
生产者生产的快,消费者消费的慢
我们可以让生产者不停的进行生产,而消费者每隔一秒进行消费。
void* Productor(void* args)
{
ThreadData* td = static_cast<ThreadData*>(args);
RingQueue<Task> *rq = td->rq;
string name = td->threadname;
int len = opers.size();
while (true)
{
//1.获取数据
int data1 = rand()%10+1;
usleep(10);
char oper = opers[rand()%len];
int data2 = rand()%5;
Task t(data1,data2,oper);
//2.生产数据
rq->Push(t);
cout << "Productor task done, task is" << t.GetTask() << "who:" << name <<endl;
}
return nullptr;
}
void* Consumer(void* args)
{
ThreadData* td = static_cast<ThreadData*>(args);
RingQueue<Task> *rq = td->rq;
string name = td->threadname;
while (true)
{
sleep(1);
//1.消费数据
Task t;
rq->Pop(&t);
//2.处理数据
t();
cout << "Consumer get task, task is :" << t.GetTask() << " who: " << name << "result:" << t.GetResult() << endl;
}
return nullptr;
}
运行结果:
此时由于生产者生产的很快,运行代码后一瞬间生产者就将环形队列打满了,此时生产者想要再进行生产,但空间资源已经为0了,于是生产者只能在p_space_sem的等待队列下进行阻塞等待,直到由消费者消费完一个数据后对p_space_sem进行了V操作,生产者才会被唤醒进而继续进行生产。
但由于生产者的生产速度很快,生产者生产完一个数据后又会进行等待,因此后续生产者和消费者的步调又变成一致的了。
生产者生产的慢,消费者消费的快
当然我们也可以让生产者每隔一秒进行生产,而消费者不停的进行消费。
void* Productor(void* args)
{
ThreadData* td = static_cast<ThreadData*>(args);
RingQueue<Task> *rq = td->rq;
string name = td->threadname;
int len = opers.size();
while (true)
{
//1.获取数据
int data1 = rand()%10+1;
usleep(10);
char oper = opers[rand()%len];
int data2 = rand()%5;
Task t(data1,data2,oper);
//2.生产数据
rq->Push(t);
cout << "Productor task done, task is" << t.GetTask() << "who:" << name <<endl;
sleep(1);
}
return nullptr;
}
void* Consumer(void* args)
{
ThreadData* td = static_cast<ThreadData*>(args);
RingQueue<Task> *rq = td->rq;
string name = td->threadname;
while (true)
{
//1.消费数据
Task t;
rq->Pop(&t);
//2.处理数据
t();
cout << "Consumer get task, task is :" << t.GetTask() << " who: " << name << "result:" << t.GetResult() << endl;
}
return nullptr;
}
运行结果:
虽然消费者消费的很快,但一开始环形队列当中的数据资源为0,因此消费者只能在c_data_sem的等待队列下进行阻塞等待,直到生产者生产完一个数据后对c_data_sem进行了V操作,消费者才会被唤醒进而进行消费。
但由于消费者的消费速度很快,消费者消费完一个数据后又会进行等待,因此后续生产者和消费者的步调又变成一致的了。