文章目录
- 1. 生产者消费者模型的理解
- 1.1 生产者消费者模型的概念
- 1.2 生产者消费者模型的特点
- 1.3 生产者消费者模型的优点
- 2. 基于BlockQueue的生产者消费者模型
1. 生产者消费者模型的理解
1.1 生产者消费者模型的概念
生产者消费者模型就是通过一个容器来解决生产者和消费者的强耦合问题。
生产者和消费者彼此之间不直接通讯,而通过这个容器来通讯,所以生产者生产完数据之后不用等待消费者处理,直接将生产的数据放到这个容器中,消费者也不用找生产者要数据,而是直接从这个容器里取数据,这个容器就相当于一个缓冲区,平衡了生产者和消费者的处理能力,这个容器实际上就是用来给生产者和消费者解耦的。
1.2 生产者消费者模型的特点
生产者消费者模型是多线程同步与互斥的一个经典场景,其特点如下;
- 三种关系:生产者和生产者(互斥关系),消费者和消费者(互斥关系),生产者和消费者(互斥关系,同步关系)
- 两种角色:生产者和消费者。
- 一个交易场所:通常指的是内存中的一段缓冲区。
我们用代码编写生产者消费者模型的时候,本质就是对这三个特点进行维护。
生产者和生产者,消费者和消费者,生产者和消费者,它们之间为什么存在互斥关系?
因为生产者和消费者之间的容器可能被多个执行流同时访问,因此我们需要将该临界资源用互斥锁保护起来。
其中,所有的生产者和消费者都会竞争式地申请锁,因此生产者和生产者,消费者和生产者,生产者和消费者之间都存在互斥关系。
生产者和消费者之间为什么会存在同步关系?
- 如果让生产者一直生产,那么当生产者产生的数据将容器塞满之后,生产者再生产数据就会生产失败。
- 同理,如果让消费者一直消费,那么让容器中的数据被消费完后,消费者再进行消费就会消费失败。
虽然这样不会造成任何数据不一致的问题,但是这样会引起另一方的饥饿问题,是非常低效的。我们应该让生产者和消费者访问该容器时具有一定的顺序性,比如让生产者先生产,再让消费者进行消费。
1.3 生产者消费者模型的优点
- 让生产者和消费者解耦
- 支持并发
- 支持忙闲不均
如果我们在主函数中调用某一函数,那么我们必须等该函数体执行完之后才继续执行主函数的后序代码,因此函数调用本质是一种紧耦合。
对应到生产者消费者模型中,函数传参实际上就是生产者生产的过程,而执行函数体实际上就是消费者消费的过程,但生产者只负责生产数据,消费者只负责消费数据,在消费者消费期间生产者可以同时进行生产,因此生产者消费者模型本质是一种松耦合。
2. 基于BlockQueue的生产者消费者模型
在多线程编程中,阻塞队列(Block Queue)是一种常用于生产者和消费者模型的数据结构。
其与普通队列的区别在于:
- 当队列为空时,从队列中获取元素的操作将会被阻塞,直到队列中放入了元素。
- 当队列满时,往队列里存放元素的操作会被阻塞,直到有元素从队列中取出。
看到以上阻塞队列的描述,我们很容易想到的就是管道,而阻塞队列最典型的应用场景实际上就是管道的实现。
模拟实现基于阻塞队列的生产者消费者模型
阻塞队列实现的生产者消费者模型的基本代码如下(单生产者单消费者):
#pragma once
#include <iostream>
#include <queue>
#include <pthread.h>
static const int gmaxcap = 5;
template <class T>
class BlockQueue
{
public:
BlockQueue(const int& maxcap = gmaxcap)
: _maxcap(maxcap)
{
pthread_mutex_init(&_mutex, nullptr);
pthread_cond_init(&_pcond, nullptr);
pthread_cond_init(&_ccond, nullptr);
}
// 输入型参数,我们一般设置为const &
// 输出型参数,我们一般设置为 *
// 输入输出型,我们一般设置为 &
void push(const T& in)
{
pthread_mutex_lock(&_mutex);
while (is_full())
{
// 生产条件不满足,无法生产,此时我们的生产者进行等待
pthread_cond_wait(&_pcond, &_mutex);
}
_q.push(in);
pthread_cond_signal(&_ccond);
pthread_mutex_unlock(&_mutex);
}
void pop(T* out)
{
pthread_mutex_lock(&_mutex);
while (is_empty())
{
pthread_cond_wait(&_ccond, &_mutex);
}
*out = _q.front();
_q.pop();
pthread_cond_signal(&_pcond);
pthread_mutex_unlock(&_mutex);
}
~BlockQueue()
{
pthread_mutex_destroy(&_mutex);
pthread_cond_destroy(&_ccond);
pthread_cond_destroy(&_pcond);
}
private:
bool is_empty()
{
return _q.empty();
}
bool is_full()
{
return _q.size() == _maxcap;
}
private:
std::queue<T> _q;
int _maxcap; // 队列中元素的上限
pthread_mutex_t _mutex;
pthread_cond_t _pcond; // 生产者对应的条件变量
pthread_cond_t _ccond; // 消费者对应的条件变量
};
对于代码,有以下解释:
- 由于我们实现的是单生产者,单消费者的生消模型,因此我们不需要维护生产者和生产者之间的关系,也不需要维护消费者和消费者之间的关系,我们只需要维护生产者和消费者之间的关系即可。
- 这里设置BlockQueue存储数据的上线为5,当存储5组数据之后,生产者就不能再进程生产了,直到消费者消费一组数据之后,才可以继续生产。
- 阻塞队列是生产者和消费者都能访问的临界资源,因此我们需要用互斥锁将其保护起来。
- 生产者向队列中Push数据时,前提是队列里面有空间,若阻塞队列已经满了,那么此时该生产者线程就需要进行等待,直到阻塞队列中欧空间时再次将其唤醒。
- 消费者线程要从阻塞队列中Pop数据时,前提是阻塞队列里面有数据,若阻塞队列为空,那么此时该消费者线程就需要先进行等待,直到阻塞队列中有新的数据时再将其唤醒。
- 因此我们在这里用到两个条件变量,一个条件变量用来描述队列为空,一个条件变量用来描述队列为满。
- 不论是生产者线程还是消费者线程,它们都是先申请到锁进入临界区之后再判断是否满足生产或消费条件的,如果对应条件不满足,那么对应线程就会被挂起。但此时线程是拿着锁的,为了避免死锁问题,在调用pthread_cond_wait函数时就需要传入当前线程的互斥锁,此时当该线程被挂起时就会自动释放互斥锁,而当线程被唤醒时就又会自动获取互斥锁。
- 当生产者生产完一个数据后,意味着阻塞队列当中至少有一个数据,而此时可能有消费者线程正在empty条件变量下进行等待,因此当生产者生产完数据后需要唤醒在empty条件变量下等待的消费者线程。
判断是否满足生产消费条件时不能用if,而应该用while:
- pthread_cond_wait函数是让当前执行流进行等待的函数,是函数就意味着有可能调用失败,调用失败后该执行流就会进行往后执行。
- 其次,在多消费者情况下,当生产者生产了一个数据后如果使用pthread_cond_broadcast函数唤醒消费者,就会一次性唤醒多个消费者,但待消费的数据只有一个,此时其他消费者就被伪唤醒了。
- 为了避免上述情况,我们就要让线程被唤醒后再次进行判断,确认是否真的满足生产消费条件,因此这里必须要用while进行判断。
主函数中的代码如下:
#include "BlockQueue.hpp"
#include <unistd.h>
#include <sys/types.h>
#include <ctime>
#include <cstdlib>
void* consumer(void* bq_)
{
BlockQueue<int>* bq = static_cast<BlockQueue<int>*>(bq_);
while (true)
{
// 消费活动
int data;
bq->pop(&data);
std::cout << "消费数据:" << data << std::endl;
}
return nullptr;
}
void* productor(void* bq_)
{
BlockQueue<int>* bq = static_cast<BlockQueue<int>*>(bq_);
while (true)
{
// 生产活动
int data = rand() % 10 + 1;
bq->push(data);
std::cout << "生产数据:" << data << std::endl;
sleep(1);
}
return nullptr;
}
int main()
{
srand((unsigned long)time(nullptr) ^ getpid());
BlockQueue<int>* bq = new BlockQueue<int>();
pthread_t c, p;
// 传入bq,让两个线程看到同一份阻塞队列
pthread_create(&c, nullptr, consumer, bq);
pthread_create(&p, nullptr, productor, bq);
pthread_join(c, nullptr);
pthread_join(p, nullptr);
delete bq;
return 0;
}
当生产者生产很快,消费者消费很慢时,在生产者生生产的数据满了之后就会阻塞下来等待消费者,最终它们的步调相同。若生产者生产慢,消费者消费快也同理。
基于计算任务的生产者消费者模型
实际使用生产者消费者模型可不是简单地让生产者生产一个数据让消费者打印,我们这样做只是为了测试代码的正确性。
由于我们将BlockQueue中存储的数据进行了模板化,此时就可以让BlockQueue当中存储其他类型的数据。
例如,我们可以实现一个基于计算任务的生产消费模型,此时我们只需定义一个Task类,这个类当中需要包含一个Run成员,该函数代表着我们想让消费者如何处理拿到的数据。
#pragma once
#include <iostream>
class Task
{
public:
Task(int x = 0, int y = 0, int op = 0)
: _x(x), _y(y), _op(op)
{}
~Task()
{}
void Run()
{
int result = 0;
switch (_op)
{
case '+':
result = _x + _y;
break;
case '-':
result = _x - _y;
break;
case '*':
result = _x * _y;
break;
case '/':
if (_y == 0){
std::cout << "Warning: div zero!" << std::endl;
result = -1;
}
else{
result = _x / _y;
}
break;
case '%':
if (_y == 0){
std::cout << "Warning: mod zero!" << std::endl;
result = -1;
}
else{
result = _x % _y;
}
break;
default:
std::cout << "error operation!" << std::endl;
break;
}
std::cout << _x << _op << _y << "=" << result << std::endl;
}
private:
int _x;
int _y;
char _op;
};
主函数代码如下:
#include "BlockQueue.hpp"
#include "Task.hpp"
#include <unistd.h>
#include <sys/types.h>
#include <ctime>
#include <cstdlib>
void* consumer(void* bq_)
{
BlockQueue<Task>* bq = static_cast<BlockQueue<Task>*>(bq_);
while (true)
{
// 消费活动
Task t;
bq->pop(&t);
t.Run();
}
return nullptr;
}
void* productor(void* bq_)
{
BlockQueue<Task>* bq = static_cast<BlockQueue<Task>*>(bq_);
const char* arr = "+-*/%";
while (true)
{
// 生产活动
int x = rand() % 100 + 1;
int y = rand() % 100 + 1;
char op = arr[rand() % 5];
Task t(x, y, op);
bq->push(t);
std::cout << "producer task done" << std::endl;
sleep(1);
}
return nullptr;
}
运行代码,此时消费者执行的就是计算任务了。