生产者消费者模型
生产者消费者模型的概念
生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。
生产者和消费者彼此之间不进行直接通讯,而通过这个容器来通讯,所以生产者生产完数据之后不用等待消费者处理,直接将生产的数据放到这个容器中,消费者也不用找生产者要数据,而是直接从容器也就是阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。
这个阻塞队列就是用来给生产者和消费者解耦的
如果缓冲区已经满了,则生产者线程阻塞;
如果缓冲区为空,那么消费者线程阻塞。
生产者消费者模型的特点
生产者消费者是多线程同步与互斥的一个经典场景,其特点如下:
- 三种关系:生产者和生产者(互斥关系),生产者和消费者(互斥关系),生产者和消费者(互斥关系,同步关系)
- 两种角色:生产者和消费者(通常由进程或线程承担)
- 一个交易场所:通常指的是内存中的一段缓冲区。生产者和生产者,消费者和消费者,生产者和消费者,它们之间为什么会存在互斥关系?
- 介于生产者和消费者之间的容器可能会被多个执行流同时访问,因此我们需要将该临界资源用互斥锁保护起来。
- 其中,所有的生产者和消费者都会竞争式的申请锁,因此生产者和生产者,消费者和消费者,生产者和消费者之间都存在互斥关系。
生产者和消费者之间为什么会存在同步关系?
- 如果让生产者一直生产,那么当生产者生产的数据将容器塞满后,生产者再生产数据就会生产失败。
- 反之,让消费者一直消费,那么当容器当中的数据被消费完后,消费者再进行消费就会消费失败。
- 虽然这样不会造成任何数据不一致的问题,但是这样会引起另一方的饥饿问题,是非常低效的。我们应该让生产者和消费者访问该容器时具有一定的顺序性,比如让生产者先生产,然后再让消费者进行消费。
注意:互斥关系保证的是数据的正确性,而同步关系是为了让多线程之间协同起来。
生产者消费者模型优点
- 解耦:假设生产者和消费者分别是两个类。如果让生产者直接调用消费者的某个方法,那么生产者对于消费者就会产生依赖(耦合)。将来如果消费者的代码发生变化,可能会影响到生产者。而如果两者都依赖于某个缓冲区,两者之间不直接依赖,耦合也就相应降低了。
- 支持并发:生产者直接调用消费者的某个方法,还有另一个弊端,由于函数调用是同步的(或者叫阻塞的),在消费者的方法没有返回之前,生产者只好一直在等待。玩意消费者处理数据很慢,生产者就会白白浪费时间,使用了生产者/消费者模型后,生产者和消费者可以是两个独立的开发主体(常见并发类型有进程和线程两种)。生产者把制造出来的数据往缓冲区一丢,就可以再去生产下一个数据,基本上不用依赖消费者的处理速度。
- 支持忙闲不均:缓冲区还有另一个好处。如果制造数据的速度时快时慢,缓冲区的好处就体现出来了。当数据制造块的时候,消费者来不及处理,未处理的数据可以暂时存在缓冲区中。等生产者的制造商速度慢下来,消费者再慢慢处理。
基于BlockingQueue的生产消费者模型
基于阻塞队列的生产者消费者模型
在多线程编程中,阻塞队列(Blocking Queue)是一种常用于实现生产者和消费者模型的数据结构。
其与普通的队列的区别在于:
- 当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中放入了元素
- 当队列满时,往队列里存放元素的操作会被阻塞,直到有元素从队列中取出。
知识联系: 看到以上阻塞队列的描述,我们很容易想到的就是管道,而阻塞队列最典型的应用场景实际上就是管道的实现。
模拟实现基于阻塞队列的生产消费模型
下面我们以单生产者、单消费者为例进行实现.
我们可以用C++STL库当中的queue实现阻塞队列
#include<iostream> #include<queue> #include<pthread.h> template<class T> #define NUM 5 class BlockQueue { private: bool IsFull()//判满 { return _q.size()==_cap; } bool IsEmpty()//判空 { return _q.empty(); } public: BlockQueue(int cap=NUM) :_cap(cap) { pthread_mutex_init(&_mutex,nullptr); pthread_cond_init(&_full,nullptr); pthread_cond_init(&_empty,nullptr); } ~BlockQueue() { pthread_mutex_destroy(&_mutex); pthread_cond_destory(_full); pthread_cond_destory(_empty); } //向阻塞队列插入数据(生产者调用) void Push(const T& data) { pthread_mutex_lock(&_mutex);//加锁 while(IsFull()) { //等待的,把线程挂起,我们当前是持有锁的 pthread_cond_wait(&_full,&_mutex); } _q.push(data);//向队列中放入数据,生产函数 pthread_cond_signal(&_empty);//唤醒在empty条件变量下等待的消费者线程 pthread_mutex_unlock(&_mutex);//解锁 } //从阻塞队列中取数据 void Pop(T& data) { pthread_mutex_lock(&_mutex);//加锁 while(IsEmpty()) { //不能进行消费,直到阻塞队列有新的数据 pthread_cond_wait(_empty,&_mutex); } data=_q.front(); _q.pop(); pthread_cond_signal(&_full);//唤醒在full条件变量下等待的生产者线程 pthread_mutex_unlock(&_mutex);//解锁 } private: std::queue<T> _q;//阻塞队列 int _cap;//阻塞队列最大容器数据个数 pthread_mutex_t _mutex;//保护临界资源的锁 pthread_cond_t _full;//_q满的,消费者在该条件变量下等待 pthread_cond_t _empty;//_q空的,生产者在该条件变量下等待 };
相关说明:
- 由于我们实现的是单生产者、单消费者的生产者消费者模型,因此我们不需要维护生产者和生产者之间的关系,也不需要维护消费者和消费者之间的关系,我们只需要维护生产者和消费者之间的同步与互斥关系即可。
- 将BlockingQueue当中存储的数据模板化,方便以后需要时进行复用。
- 这里设置BlockingQueue存储数据的上限为5,当阻塞队列中存储了五组数据时生产者就不能进行生产了,此时生产者就应该被阻塞。
- 阻塞队列是会被生产者和消费者同时访问的临界资源,因此我们需要用一把互斥锁将其保护起来。
- 生产者线程要向阻塞队列当中Push数据,前提是阻塞队列里面有空间,若阻塞队列已经满了,那么此时该生产者线程就需要进行等待,直到阻塞队列中有空间时再将其唤醒
- 消费者线程要从阻塞队列当中Pop数据,前提是阻塞队列里面有数据,若阻塞队列为空,那么此时该消费者线程就需要进行等待,直到阻塞队列中有新的数据时再将其唤醒。
- 因此在这里我们需要用到两个条件变量,一个条件变量用来描述队列为空,另一个条件变量用来描述队列已满。当阻塞队列满了的时候,要进行生产的生产者线程就应该在full条件变量下进行等待;当阻塞队列为空的时候,要进行消费的消费者线程就应该在empty条件变量下进行等待。
- 不论是生产者线程还是消费者线程,它们都是先申请到锁进入临界区后再判断是否满足生产或消费条件的,如果对应条件不满足,那么对应线程就会被挂起。但此时该线程是拿着锁的,为了避免死锁问题,在调用pthread_cond_wait函数时就需要传入当前线程手中的互斥锁,此时当该线程被挂起时就会自动释放手中的互斥锁,而当该线程被唤醒时又会自动获取到该互斥锁。
- 当生产者生产完一个数据后,意味着阻塞队列当中至少有一个数据,而此时可能有消费者线程正在empty条件变量下进行等待,因此当生产者生产完数据后需要唤醒在empty条件变量下等待的消费者线程。
- 同样的,当消费者消费完一个数据后,意味着阻塞队列当中至少有一个空间,而此时可能有生产者线程正在full条件变量下进行等待,因此当消费者消费完数据后需要唤醒在full条件变量下等待的生产者线程。
注意:为什么判断是否满足生产消费条件时不能用if,而应该用while:
- 情况1->挂起失败:pthread_cond_wait函数是让当前执行流进行等待的函数,是函数就意味着有可能调用失败,调用失败后该执行流就会继续往后执行,这样会有问题。
- 举个例子:比如现在我们准备进行生产数据(并且此时队列是满的),进入临界区后首先会进行判断:
if(IsFull()){ ProducterWait(); }
假设在调用pthread_cond_wait函数时调用失败:也就是说此时阻塞队列是满的,但是该线程却没有成功进行等待,整个if语句结束后队列依旧是满的,于是执行完if语句接着往下执行时,进行了bq_.push(in);,造成的结果就是在队列满的情况下依旧进行了插入数据,明显这是不合理的。
- 情况2->被伪唤醒:假设在多消费者的情况下,当生产者生产了一个数据后如果使用pthread_cond_broadcast函数唤醒消费者,就会一次性唤醒多个消费者,但待消费的数据只有一个,此时其他消费者就被伪唤醒了。
为了避免出现上述情况,我们用while循环,这样如果挂起成功,在消费者通过条件变量告知生产者队列不为满的时候,就跳出循环;如果挂起失败,此时队列还是满的,那么条件满足,循环会继续下去,直到挂起成功,队列不为满,退出循环,进行生产者生产数据操作。
在主函数中我们就只需要创建一个生产者线程和一个消费者线程,让生产者线程不断生产数据,让消费者线程不断消费数据。
#include"BlockQueue.hpp" #include<time.h> #include<cstdlib> #include<unistd.h> using namespace ns_blockqueue; void *consumer(void *args) { BlockQueue<int> *bq = (BlockQueue<int>*)args; while(true){ sleep(1); int data = 0; bq->Pop(&data); std::cout << "消费者消费了一个数据: " << data << std::endl; } } void *producter(void *args) { BlockQueue<int> *bq = (BlockQueue<int>*)args; while(true){ sleep(1); //1. 制造数据,生产者的数据(task)从哪里来?? int data = rand()%20 + 1; std::cout << "生产者生产数据: " << data << std::endl; bq->Push(data); } } int main() { srand((long long)time(nullptr)); BlockQueue<int> *bq = new BlockQueue<int>(); pthread_t c,p; pthread_create(&c, nullptr, consumer, (void*)bq); pthread_create(&p, nullptr, producter, (void*)bq); pthread_join(c, nullptr); pthread_join(p, nullptr); return 0; }
相关说明:
- 阻塞队列要让生产者线程向队列中Push数据,让消费者线程从队列中Pop数据,因此这个阻塞队列必须要让这两个线程同时看到,所以我们在创建生产者线程和消费者线程时,需要将该阻塞队列作为线程执行例程的参数进行传入。
- 代码中生产者生产数据就是将获取到的随机数Push到阻塞队列,而消费者消费数据就是从阻塞队列Pop数据,为了便于观察,我们可以将生产者生产的数据和消费者消费的数据进行打印输出。
小贴士: 以.hpp为后缀的文件也是头文件,该头文件同时包含类的定义与实现,调用者只需include该hpp文件即可。因为开源项目一般不需要进行保护,所以在开源项目中用的比较多。
情况1:我们可以让生产者不停的进行生产,而消费者每隔一秒进行消费(不让生产者进行sleep)。
void *producter(void *args) { BlockQueue<int> *bq = (BlockQueue<int>*)args; while(true){ //sleep(1); //1. 制造数据,生产者的数据(task)从哪里来?? int data = rand()%20 + 1; std::cout << "生产者生产数据: " << data << std::endl; bq->Push(data); } }
此时由于生产者生产的很快,运行代码后一瞬间生产者就将阻塞队列塞满了,此时生产者想要再进行生产就只能进行等待,直到消费者消费完一个数据后,生产者才会被唤醒进而继续进行生产,生产者生产完一个数据后又会进行等待,因此后续生产者和消费者的步调又变成一致的了。
情况2:我们也可以让生产者每隔一秒进行生产,而消费者不停的进行消费。
void *consumer(void *args) { BlockQueue<int> *bq = (BlockQueue<int>*)args; while(true){ // sleep(1); int data = 0; bq->Pop(&data); std::cout << "消费者消费了一个数据: " << data << std::endl; } }
这种情况我们会发现:虽然消费者消费的很快,但一开始阻塞队列中是没有数据的,因此消费者只能在empty条件变量下进行等待,直到生产者生产完一个数据后,消费者才会被唤醒进而进行消费,消费者消费完这一个数据后又会进行等待,因此生产者和消费者的步调从一开始就是一致的。
情况3:在现实场景下,我们通常会在满足某一条件时再唤醒对应的生产者或消费者。
–》比如以超市为例:超市的货架就是我们这里的队列,一般超市的货物是不会有短缺的,因为通常假设进货了100个面包,店员一般会每天清点货物,每次他会在面包少于30个的时候就会通知厂家(生产者)进行补货,这样才会让超市的货物永远处于不断货的状态。
我们也可以通过一些方式,当阻塞队列当中存储的数据大于队列容量的一半时,再唤醒消费者线程进行消费;当阻塞队列当中存储的数据小于队列容器的一半时,再唤醒生产者线程进行生产。
void Push(const T &in)//插数据 { LockQueue(); //临界区 while(IsFull()){ //bug? //等待的,把线程挂起,我们当前是持有锁的!!! ProducterWait(); } //向队列中放数据,生产函数 bq_.push(in); //控制消费条件 if(bq_.size() >= cap_/2 ) WakeupComsumer(); UnlockQueue(); } void Pop(T *out)//取数据 { LockQueue(); //从队列中拿数据,消费函数函数 while(IsEmpty()){ //bug? //无法消费 ConsumerWait(); } *out = bq_.front(); bq_.pop(); //控制生产条件 if(bq_.size() <= cap_/2 ) WakeupProducter(); UnlockQueue(); }
我们仍然让生产者生产的快,消费者消费的慢。运行代码后生产者还是一瞬间将阻塞队列塞满后进行等待,但此时不是消费者消费一个数据就唤醒生产者线程,而是当阻塞队列当中的数据小于等于队列容器的一半时,才会唤醒生产者线程进行生产。
基于计算任务的生产者消费者模型
- 实际使用生产者消费者模型时不是简单的让生产者生产一个数字让消费者进行打印而已,我们这样做只是为了测试代码的正确性。
- 由于我们将BlockingQueue当中存储的数据进行了模板化,此时就可以让BlockingQueue当中存储其他类型的数据。
例如,我们想要实现一个基于计算任务的生产者消费者模型,此时我们只需要定义一个Task类,这个类当中需要包含一个Run成员函数,该函数代表着我们想让消费者如何处理拿到的数据。
#pragma once #include <iostream> #include <pthread.h> namespace ns_task { class Task { private: int x_; int y_; char op_; //+/*/% public: // void (*callback)(); Task() {} Task(int x, int y, char op) : x_(x), y_(y), op_(op) { } int Run() { int res = 0; switch (op_) { case '+': res = x_ + y_; break; case '-': res = x_ - y_; break; case '*': res = x_ * y_; break; case '/': res = x_ / y_; break; case '%': res = x_ % y_; break; default: std::cout << "bug??" << std::endl; break; } std::cout << "当前任务正在被: " << pthread_self() << " 处理: " \ << x_ << op_ << y_ << "=" << res << std::endl; return res; } int operator()() { return Run(); } ~Task() {} }; }
此时生产者放入阻塞队列的数据就是一个Task对象,而消费者从阻塞队列拿到Task对象后,就可以用该对象调用Run成员函数进行数据处理。
#include "BlockQueue.hpp" #include "task.hpp" using namespace ns_blockqueue; using namespace ns_task; void *consumer(void *args) { BlockQueue<Task> *bq = (BlockQueue<Task> *)args;//模板中放入的就是类型是Task while (true) { // sleep(2); // int data = 0; // bq->Pop(&data); // std::cout << "消费者消费了一个数据: " << data << std::endl; Task t; bq->Pop(&t); //完成任务消费的第一步 t(); //完成任务消费的第二步 } } void *producter(void *args) { BlockQueue<Task> *bq = (BlockQueue<Task> *)args; string ops = "+-*/%"; while (true) { // sleep(1); // 1. 制造数据,生产者的数据(task)从哪里来?? // int data = rand()%20 + 1; // std::cout << "生产者生产数据: " << data << std::endl; // bq->Push(data); // 1. 制造数据 int x = rand() % 20 + 1; int y = rand() % 10 + 1; char op = ops[rand() % 5]; Task t(x, y, op); cout << "生产者派发了一个任务:" << x << op << y << "=?" << endl; // 2.将数据推送到任务队列中 bq->Push(t); sleep(1); } } int main() { srand((long long)time(nullptr)); BlockQueue<Task> *bq = new BlockQueue<Task>(); pthread_t c, p; pthread_t c1, c2, c3, c4; pthread_create(&c, nullptr, consumer, (void *)bq); pthread_create(&c1, nullptr, consumer, (void *)bq); pthread_create(&c2, nullptr, consumer, (void *)bq); pthread_create(&c3, nullptr, consumer, (void *)bq); pthread_create(&c4, nullptr, consumer, (void *)bq); pthread_create(&p, nullptr, producter, (void *)bq); pthread_join(c, nullptr); pthread_join(c1, nullptr); pthread_join(c2, nullptr); pthread_join(c3, nullptr); pthread_join(c4, nullptr); pthread_join(p, nullptr); return 0; }
我们让生产者每隔一秒进行输入任务,此时消费者进行计算任务。我们也可以定义多个消费者,让不同的消费者进行计算任务。