生产者消费者模型是通过一个中间容器来解决生产者消费者的强耦合问题。
设想场景:
main函数作为生产者将数据提供给消费者func,func如果没有处理完数据,main函数将一直等待而无法执行之后的程序(不支持并发),这便是强耦合的情形。
如果我们提供容器,使得生产者和消费者之间不构建直接通讯,而通过容器来进行通讯,那么生产者生产完数据后可以直接丢给容器;消费者也不用直接去找生产者要数据,而是直接从容器中取。除非容器满,生产者无法继续生产;容器空,消费者无法继续提取,否则在大部分时间里,两者可以无等待的继续执行自己的程序(支持并发),平衡了生产者和消费者的处理能力。其中的阻塞队列便是给两者解耦的关键角色。
生产者消费者模型的优点
生产者只负责生产数据,消费者只负责消费数据,在消费者消费期间生产者可以同时进行生产(需保持互斥关系),因此生产者消费者模型本质是一种松耦合。
生产者为容器提供了数据资源,这个过程中可能不只有一个生产者,而容器作为临界资源是需要保护的,所以生产者之间应做到互斥。
同理,亦可能存在多个消费者提取数据,所以消费者之间也应该做到互斥。
如果生产者竞争优先级更高,让它一直生产数据,即使容器满了也一直抓着临界资源不放,那消费者永远也读不到数据。反之,如果消费者竞争优先级更高,那么即使把容器读空了也不肯放,那么后续数据生产者便没有机会存放至容器了。
虽然这样不会造成任何数据不一致的问题,但是这样会引起另一方的饥饿问题,是非常低效的。我们应该让生产者和消费者访问该容器时具有一定的顺序性,比如让生产者先生产,然后再让消费者进行消费。所以消费者和生产者应达成同步关系。
综上所述,为了便于记忆,可以按照321原则来理解:
3
种关系:生产者之间(互斥关系),消费者之间(互斥关系),消费者与生产者(互斥+同步)2
个角色:生产者和消费者。1
个交易场所:内存中的一段缓冲区(自己组织的数据结构)BlockingQueue
—— 阻塞队列
多线程中,阻塞队列是一种常用于实现生产者消费者模型的数据结构。
其特点在于:
方便理解,以单生产者,单消费者为例进行实现。
所需工具
代码实现:
BlockingQueue.hpp
先把 BlockingQueue 封装成类写在头文件 BlockingQueue.hpp
中
//BlockingQueue.hpp
#pragma once
#include
#include
#include
#include
#include
#include
namespace PC
{
const int default_cap=16;
template<typename T>
class BlockingQueue
{
private:
bool IsFull()
{
return q.size()==_cap;
}
bool IsEmpty()
{
return q.empty();
}
public:
BlockingQueue(int cap=default_cap):_cap(cap)
{
pthread_mutex_init(&lock,nullptr);
pthread_cond_init(&full,NULL);
pthread_cond_init(&empty,NULL);
}
~BlockingQueue()
{
pthread_mutex_destroy(&lock);
pthread_cond_destroy(&full);
pthread_cond_destroy(&empty);
}
// const & :输入参数
// * : 输出参数
// & :输入输出参数
void Push(const T& input)
{
//访问临界资源 需加锁
pthread_mutex_lock(&lock);
while(IsFull())//如果临界资源已满
{
//不能进行生产,需要等待
//1. 调用wait的时候,会首先自动释放lock!,然后再挂起自己
//2. wait返回的时候,会首先自动竞争锁,获取到锁之后,才能返回!
pthread_cond_wait(&full,&lock);
}
q.push(input);
//生产者通知消费者开始消费
if(q.size()>_cap/2)
pthread_cond_signal(&empty);
pthread_mutex_unlock(&lock);
}
void Pop(T* output)
{
//访问临界资源 需加锁
pthread_mutex_lock(&lock);
while(IsEmpty())//如果临界资源已空
{
//不能进行消费,需要等待
pthread_cond_wait(&empty,&lock);
}
*output=q.front();
q.pop();
//消费者通知生产者开始生产
if(q.size()<_cap/2)
pthread_cond_signal(&full);
pthread_mutex_unlock(&lock);
}
private:
std::queue<T> q;//临界资源
int _cap;
pthread_mutex_t lock;
pthread_cond_t full;
pthread_cond_t empty;
};
}
相关说明:
main.cc
//main.cc
#include "BlockingQueue.hpp"
void* Consumer(void* arg)
{
auto bq=(PC::BlockingQueue<int>*)arg;
while(true)
{
int data=0;
bq->Pop(data);//消费数据
std::cout<<"Consumer:"<<data<<std::endl;
sleep(1);
}
}
void* Producer(void* arg)
{
auto bq=(PC::BlockingQueue<int>*)arg;
while(true)
{
int data=rand()%100+1;
bq->Push(data);//生产数据
std::cout<<"Producer:"<<data<<std::endl;
sleep(1);
}
}
int main()
{
srand((unsigned int)time(nullptr));
PC::BlockingQueue<int>* bq=new PC::BlockingQueue<int>();
pthread_t c_id,p_id;
pthread_create(&c_id,NULL,Consumer,(void*)bq);
pthread_create(&p_id,NULL,Producer,(void*)bq);
pthread_join(c_id,nullptr);
pthread_join(p_id,nullptr);
return 0;
}
生产者每隔一秒生产数据,消费者的无间隔消费
结果:
当然,阻塞队列里除了存放一些内置类型,我们还可以定义队列中存放自定义类型的对象。消费者可以根据接收到的对象数据执行对象相关的方法,来达成任务——基于任务的生产者消费者模型:
我们自定义一个Task类,这个类包含两个整型成员变量,其中的成员函数来完成对这两个数据的加减乘除以及取余。
Task.hpp
#pragma once
#include
class Task
{
public:
Task(int _a=0,int _b=0,char _op=0):a(_a),b(_b),op(_op)
{}
std::string Show()
{
std::string message=std::to_string(a);
message+=op;
message+=std::to_string(b);
message+=" = ";
return message;
}
int Run()
{
int result=0;
switch(op)
{
case '+':
result=a+b;
break;
case '-':
result=a-b;
break;
case '*':
result=a*b;
break;
case '/':
if(b==0)
{
std::cout<<"div zero"<<std::endl;
result=-1;
}
else
{
result=a/b;
}
break;
case '%':
if (b == 0)
{
std::cout << "mod zero!" << std::endl;
result = -1;
}
else
{
result = a % b;
}
break;
default:
break;
}
return result;
}
~Task(){}
private:
int a;
int b;
char op;
};
main.cpp
#include "BlockingQueue.hpp"
#include "Task.hpp"
void* Consumer(void* arg)
{
PC::BlockingQueue<Task>* bq=(PC::BlockingQueue<Task>*)arg;
while(true)
{
sleep(1);
Task t;
bq->Pop(t);//消费数据
//执行任务
std::cout<<t.a<<t.op<<t.b<<" = "<<t.Run()<<std::endl;
}
}
void* Producer(void* arg)
{
PC::BlockingQueue<Task>* bq=(PC::BlockingQueue<Task>*)arg;
const char arr[]="+-*/%";
while(true)
{
int a=rand()%100;
int b=rand()%100;//100以内的运算
char op=arr[rand()%5];
Task t(a,b,op);
bq->Push(t);//生产数据
sleep(1);
}
}
int main()
{
srand((unsigned int)time(nullptr));
PC::BlockingQueue<Task>* bq=new PC::BlockingQueue<Task>();
pthread_t c_id,p_id;
pthread_create(&c_id,NULL,Consumer,(void*)bq);
pthread_create(&p_id,NULL,Producer,(void*)bq);
pthread_join(c_id,nullptr);
pthread_join(p_id,nullptr);
return 0;
}
我们将对象交给消费者后,消费者帮我们执行了任务:
可见自定义对象的成员函数和成员方法让阻塞队列为我们传递任务成为可能。
什么是信号量?
信号量本质是一个“计数器”,用于描述临界资源中可用资源数目,信号量相较于互斥锁,其访问管理临界资源的粒度更细。
当仅用一个互斥锁对临界资源进行保护时,这个临界资源便看作了一个整体,同一时刻只能有一个执行流进行访问,这样别的执行流只能留在门口,管理粒度较粗。
我们将临界资源划分成为多个块(由信号量负责计数),例如临界资源为一个含有1000个整型的数组,那么信号量可设为1000。每当一个执行流访问临界资源时,信号量便会执行P操作(信号量-1,表示已申请到一个资源的使用权),而当一个执行流离开,信号量执行V操作(信号量+1,表示线程释放一个资源使用权)。这样即使多执行流共同访问了临界资源,但由于访问的资源不同,并不会造成数据错乱的情况,提高了并发效率。而如果信号量为1,即可用的临界资源只有一个,那么便等价于 互斥锁。
这样的场景生活中比比皆是,如买票看电影,那么我们便预定了该场次的电影座位,看完电影后,这张作为便不再属于我。高考前,准考证上对应的座位是我申请的,考试时是属于我的,考后,这张椅子就不属于我了。
申请信号量(P操作),申请不到的线程将进入信号量的等待队列
操作特定临界资源
释放信号量(V操作)
由于信号量本身是所有线程都能访问的,故其为临界资源,所以信号量的PV操作一定是原子的!
如下介绍的POSIX的信号量接口存放在 semaphore.h
的头文件中,信号量的类型为 sem_t
。使用 g++ 编译链接时需要带上 -lpthread
初始化一个semaphore变量
int sem_init(sem_t *sem, int pshared, unsigned int value);
参数
返回值
成功返回0,失败返回-1,同时errno被设置。
在用完semaphore之后需调用此函数释放相关资源。
int sem_destroy(sem_t *sem);
调用sem_wait()可以获得资源,使semaphore的值减1,如果调用sem_wait()时semaphore的值已经是0,则挂起等待直到信号量大于0。如果不希望挂起等待,可以调用sem_trywait()。
这里暂不介绍sem_trywait()。
int sem_wait(sem_t *sem);//P()
调用sem_post()可以释放资源,使semaphore的值加1,同时唤醒挂起等待的线程。
int sem_post(sem_t *sem);//V()
这里我们使用一个总数为1的信号量(二元信号量)来模拟互斥锁:
#include
#include
#include
#include
#include
sem_t sem;
void* run1(void* arg)
{
std::string name=(char*)arg;
while(true)
{
sem_wait(&sem);
std::cout<<name<<" : run1"<<std::endl;
sem_post(&sem);
sleep(1);
}
pthread_exit((void*)0);
}
void* run2(void* arg)
{
std::string name=(char*)arg;
while(true)
{
sem_wait(&sem);
std::cout<<name<<" : run2"<<std::endl;
sem_post(&sem);
sleep(1);
}
pthread_exit((void*)0);
}
int main()
{
sem_init(&sem,0,1);//初始化:二元信号量
pthread_t tid1,tid2;
pthread_create(&tid1,NULL,run1,(void*)"thread1");
pthread_create(&tid2,NULL,run2,(void*)"thread2");
pthread_join(tid1,NULL);
pthread_join(tid2,NULL);
sem_destroy(&sem);
return 0;
}
对于生产者和消费者而言,他们需要的资源并非相同:
当生产者和消费者不是指向同一个位置的时候,生产者和消费者是可以并发执行的(此时环形队列即非空也非满),反之环形队列为空或者为满时则两者需要满足互斥关系,同时需要满足空时生产者运行,满时消费者运行的同步关系。
生产者与消费者在数据资源为0的时候处于同一个位置,而在整个环形队列为满时,也会处于同一位置(生产者套消费者一圈),
所以有必要区分这两种状态,两个方法:
生产者+1!=消费者
时,可以填入数据,当 生产者+1==消费者
时,说明队列已满。当然由于是环形队列,消费者和生产者的下标都应该对 <队列的长度>
进行 取余
操作模拟环形结构,以保证下标不越界。
这里我们采用计数器的方法,因为有个现成的工具——信号量。
这里有两类资源所以需设立两个信号量:
blank_sem
,初始值为整个环形队列的容量大小 capacity
。data_sem
,初始值为0。而且始终需满足 data_sem + blank_sem == capacity
思路:
规则1
blank_sem-1
(P操作),data_sem+1
(V操作);data_sem-1
(P操作),blank_sem+1
(V操作);规则2
生产者不能套圈消费者,即环形队列满时(blank_sem==0
),生产者要等消费者处理数据。
规则3
消费者不能超过生产者,即环形队列为空时(data_sem==0
),消费者要等生产者生产数据。
规则4
当消费者和生产者指向同一个位置(blank_sem==0 && data_sem==capacity
或者 data_sem==0 && blank_sem==capacity
),需要根据信号量来判断是谁先执行。除此之外两者可以并发执行。
我们将环形队列以及生成和消费的动作封装成类:RingQueue
#pragma once
#include
#include
#include
#include
#include
#include
namespace RQ
{
const int g_cap=10;
template<class T>
class RingQueue
{
public:
RingQueue(int cap=g_cap)
:_cap(cap),_rq(cap),c_pos(0),p_pos(0)
{
sem_init(&blank_sem,0,_cap);//blank_sem设为容量大小
sem_init(&data_sem,0,0);//data_sem 设为0
}
~RingQueue()
{
sem_destroy(&blank_sem);
sem_destroy(&data_sem);
}
//环形队列中填入数据(生产者)
void Push(const T& t)
{
P(blank_sem);//blank_sem--
_rq[p_pos]=t;
V(data_sem);//data_sem++
//更新下一次生产者插入数据的位置
p_pos++;//p_pos不是临界资源,无需放在临界区
p_pos%=_cap;
}
//环形队列获取数据(消费者)
void Pop(T* t)
{
P(data_sem);//data_sem--
*t=_rq[c_pos];
V(blank_sem);//blank_sem++
//更新下一次的消费者数据
c_pos++;//c_pos不是临界资源,无需放在临界区
c_pos%=_cap;
}
private:
void P(sem_t& sem)
{
sem_wait(&sem);
}
void V(sem_t& sem)
{
sem_post(&sem);
}
private:
int _cap;//容量
int c_pos;//消费者位置
int p_pos;//生产者位置
sem_t blank_sem;//空间资源信号量
sem_t data_sem;//数据资源信号量
std::vector<T> _rq;
};
}
主函数中使用单生产者单消费者模型来测试下环形队列:
//main.cc
#include "RingQueue.hpp"
#include
void* Consumer(void* arg)
{
RQ::RingQueue<int>* ring=(RQ::RingQueue<int>*)arg;
std::cout<<"i am consumer"<<std::endl;
while(true)
{
int data=0;
ring->Pop(&data);
std::cout<<"Consumer:"<<data<<std::endl;
sleep(1);
}
pthread_exit((void*)0);
}
void* Producer(void* arg)
{
std::cout<<"i am producer"<<std::endl;
RQ::RingQueue<int>* ring=(RQ::RingQueue<int>*)arg;
while(true)
{
int data=rand()%50+1;
ring->Push(data);
std::cout<<"Producer:"<<data<<std::endl;
}
pthread_exit((void*)0);
}
int main()
{
RQ::RingQueue<int>* ring=new RQ::RingQueue<int>;
srand((unsigned int)time(nullptr));
pthread_t con,pro;
pthread_create(&con,NULL,Consumer,(void*)ring);
pthread_create(&pro,NULL,Producer,(void*)ring);
pthread_join(con,NULL);
pthread_join(pro,NULL);
return 0;
}
结果可以看出消费者与生产者之间保持了同步+互斥的关系
现在我们将上述的基于环形队列的单消费者单生产者模型修改为多消费者多生产者模型,那么此时,生产者和生产者之间,以及消费者和消费者之间需建立起互斥关系,因为此时下标成为了临界资源,每次只有一个人可以改变下标,否则如果信号量资源充足,会有多个线程申请资源的同时修改下标,从而导致混乱。
很简单,分别给生产者和消费者加上互斥锁即可。每次消费者只准一人修改下标,生产者亦然。
而且我们需把lock的位置放在 sem_wait 的后面。这样就能够体现出资源是被预定的,可以之后进入临界区。就像我们可以提前订票,之后再入场,效率更高,而不是当场买票当场进入,这样和单生产者单消费者的模式没有两样。
还是基于上面的 RingQueue类
的代码,我们这次添加进锁
namespace RQ
{
const int g_cap=10;
template<class T>
class RingQueue
{
public:
RingQueue(int cap=g_cap)
:_cap(cap),_rq(cap),c_pos(0),p_pos(0)
{
sem_init(&blank_sem,0,_cap);//blank_sem设为容量大小
sem_init(&data_sem,0,0);//data_sem 设为0
pthread_mutex_init(&consumer_mtx,nullptr);
pthread_mutex_init(&producer_mtx,nullptr);
}
~RingQueue()
{
sem_destroy(&blank_sem);
sem_destroy(&data_sem);
pthread_mutex_destroy(&consumer_mtx);
pthread_mutex_destroy(&producer_mtx);
}
//环形队列中填入数据(生产者)
void Push(const T& t)
{
P(blank_sem);//blank_sem--
pthread_mutex_lock(&producer_mtx);
_rq[p_pos]=t;
//更新下一次生产者插入数据的位置
p_pos++;
p_pos%=_cap;
pthread_mutex_unlock(&producer_mtx);
V(data_sem);//data_sem++
}
//环形队列获取数据(消费者)
void Pop(T* t)
{
P(data_sem);//data_sem--
pthread_mutex_lock(&consumer_mtx);
*t=_rq[c_pos];
//更新下一次的消费者数据
c_pos++;
c_pos%=_cap;
pthread_mutex_unlock(&consumer_mtx);
V(blank_sem);//blank_sem++
}
private:
void P(sem_t& sem)
{
sem_wait(&sem);
}
void V(sem_t& sem)
{
sem_post(&sem);
}
private:
int _cap;//容量
int c_pos;//消费者位置
int p_pos;//生产者位置
sem_t blank_sem;//空间资源信号量
sem_t data_sem;//数据资源信号量
pthread_mutex_t consumer_mtx;//互斥锁保证消费者之间的互斥
pthread_mutex_t producer_mtx;//保证生产者之间的互斥
std::vector<T> _rq;
};
}
这次我们的环形队列中的将存放 Task类
对象,生产者给出计算任务,消费者帮我们进行计算。(Task类代码已在上文基于任务的队列中给出)
主函数略作修改:
#include
#include
#include
#include
#include
#include "RingQueue.hpp"
#include
#include
#include "Task.hpp"
const std::string ops="+-*/%";
void* Consumer(void* arg)
{
RQ::RingQueue<Task>* ring=(RQ::RingQueue<Task>*)arg;
std::cout<<"i am consumer"<<std::endl;
while(true)
{
Task t;
ring->Pop(&t);
std::cout<<"Consumer("<<pthread_self()<<"):"<<t.Show()<<t.Run()<<std::endl;
sleep(1);
}
pthread_exit((void*)0);
}
void* Producer(void* arg)
{
std::cout<<"i am producer"<<std::endl;
RQ::RingQueue<Task>* ring=(RQ::RingQueue<Task>*)arg;
while(true)
{
int x=rand()%50+1;
int y=rand()%50+1;
char op=ops[rand()%5];
Task t(x,y,op);
std::cout<<"Producer("<<pthread_self()<<"):"<<t.Show()<<'?'<<std::endl;
ring->Push(t);
sleep(1);
}
pthread_exit((void*)0);
}
int main()
{
RQ::RingQueue<Task>* ring=new RQ::RingQueue<Task>;
srand((unsigned int)time(NULL));
pthread_t con1,pro1;
pthread_t con2,pro2;
pthread_t con3,pro3;
pthread_create(&con1,NULL,Consumer,(void*)ring);
pthread_create(&con2,NULL,Consumer,(void*)ring);
pthread_create(&con3,NULL,Consumer,(void*)ring);
pthread_create(&pro1,NULL,Producer,(void*)ring);
pthread_create(&pro2,NULL,Producer,(void*)ring);
pthread_create(&pro3,NULL,Producer,(void*)ring);
pthread_join(con1,NULL);
pthread_join(con2,NULL);
pthread_join(con3,NULL);
pthread_join(pro1,NULL);
pthread_join(pro2,NULL);
pthread_join(pro3,NULL);
return 0;
}
结果:
假如每次任务的时间比较耗时,那么基于环形队列的多生产者多消费者可以很好的为我们并发执行任务,提高计算效率。
— end —
青山不改 绿水长流