目录
[1. 生产者消费者模型的概念](#1. 生产者消费者模型的概念)
[2. 生产者消费者模型的特点](#2. 生产者消费者模型的特点)
[3. 生产者消费者模型优点](#3. 生产者消费者模型优点)
一、生产者消费者模型
1. 生产者消费者模型的概念
生产者-消费者模型(Producer-Consumer Pattern)是多线程编程中一个经典的模型,它用来解决生产者和消费者之间协调数据生产和消费的问题。
模型描述:
- 生产者: 生产者线程负责生产数据,并将生产的数据放入一个缓冲区。
- 消费者: 消费者线程负责从缓冲区中获取数据,并进行消费。
- 缓冲区: 缓冲区是一个用来存储生产者生产的数据,供消费者消费的数据结构。
生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。
生产者和消费者彼此之间不直接通讯,而通过这个容器来通讯,所以生产者生产完数据之后不用等待消费者处理,直接将生产的数据放到这个容器当中,消费者也不用找生产者要数据,而是直接从这个容器里取数据,这个容器就相当于一个缓冲区,平衡了生产者和消费者的处理能力,这个容器实际上就是用来给生产者和消费者解耦的。
2. 生产者消费者模型的特点
生产者消费者模型是多线程同步与互斥的一个经典场景,其特点如下:
- 三种关系: 生产者和生产者(互斥关系)、消费者和消费者(互斥关系)、生产者和消费者(互斥关系、同步关系)。
- 两种角色: 生产者和消费者。(通常由进程或线程承担)
- 一个交易场所: 通常指的是内存中的一段缓冲区。(可以自己通过某种方式组织起来)
我们用代码编写生产者消费者模型的时候,本质就是对这三个特点进行维护。
生产者和生产者、消费者和消费者、生产者和消费者,它们之间为什么会存在互斥关系?
介于生产者和消费者之间的容器可能会被多个执行流同时访问,因此我们需要将该临界资源用互斥锁保护起来。
其中,所有的生产者和消费者都会竞争式的申请锁,因此生产者和生产者、消费者和消费者、生产者和消费者之间都存在互斥关系。
生产者和消费者之间为什么会存在同步关系?
- 如果让生产者一直生产,那么当生产者生产的数据将容器塞满后,生产者再生产数据就会生产失败。
- 反之,让消费者一直消费,那么当容器当中的数据被消费完后,消费者再进行消费就会消费失败。
虽然这样不会造成任何数据不一致的问题,但是这样会引起另一方的饥饿问题,是非常低效的。我们应该让生产者和消费者访问该容器时具有一定的顺序性,比如让生产者先生产,然后再让消费者进行消费。
注意: 互斥关系保证的是数据的正确性,而同步关系是为了让多线程之间协同起来。
3. 生产者消费者模型优点
- **解耦生产者和消费者:**生产者和消费者线程之间互相独立,无需直接依赖对方,可以分别进行开发和测试
- **支持并发:**通过缓冲区,生产者和消费者线程可以并行工作,提高程序的整体效率
- **支持忙闲不均:**即使生产者和消费者线程的运行速度不同,也不会影响彼此的工作。
如果我们在主函数中调用某一函数,那么我们必须等该函数体执行完后才继续执行主函数的后续代码,因此函数调用本质上是一种紧耦合。
对应到生产者消费者模型中,函数传参实际上就是生产者生产的过程,而执行函数体实际上就是消费者消费的过程,但生产者只负责生产数据,消费者只负责消费数据,在消费者消费期间生产者可以同时进行生产,因此生产者消费者模型本质是一种松耦合。
这个模型是为了让多线程并发协作来完成任务的
二、基于BlockingQueue的生产者消费者模型
在多线程编程中,基于BlockingQueue的生产者-消费者模型是一种非常常见的实现方式,是一种常用于实现生产者和消费者模型的数据结构。
其与普通的队列的区别在于:
- 当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中放入了元素。
- 当队列满时,往队列里存放元素的操作会被阻塞,直到有元素从队列中取出。
知识联系: 看到以上阻塞队列的描述,我们很容易想到的就是管道,而阻塞队列最典型的应用场景实际上就是管道的实现,所以管道在实现时就加上了同步和互斥。
模拟实现基于阻塞队列的生产消费模型
为了方便理解,下面我们先以单生产者、单消费者为例进行实现。
其中的BlockQueue就是生产者消费者模型当中的交易场所,我们可以用C++STL库当中的queue进行实现
cpp
#pragma once
#include <iostream>
#include <pthread.h>
#include <queue>
#include <unistd.h>
using namespace std;
template<class T>
class blockqueue
{
//静态成员变量
static const size_t cap_default = 4;
public:
blockqueue(size_t maxcap = cap_default)
:_maxcap(maxcap)
{
low_water = _maxcap / 3;
high_water = (_maxcap * 2) / 3;
pthread_mutex_init(&_mutex, nullptr);
pthread_cond_init(&_c_cond, nullptr);
pthread_cond_init(&_p_cond, nullptr);
}
// 那么由谁来唤醒在排队的线程?
// push函数如果push了,那么它一定知道阻塞队列里有数据了,可以唤醒在_c_cond排队的线程
// pop函数如果pop了,那么它一定知道阻塞队列里有空缺位置,可以唤醒在_p_cond排队的线程
const T& pop()
{
pthread_mutex_lock(&_mutex);
while (_q.size() == 0) // 判断临界资源是否就绪也是在访问临界资源,所以要先上锁
{
pthread_cond_wait(&_c_cond, &_mutex); //此时是持有锁的!在调用时自动释放锁,被唤醒时,重新持有锁
//误唤醒了怎么办, 改为while,在出去的时候再判断一次
}
//并不是想消费就消费,而是要满足消费条件
T out = _q.front();
_q.pop();
//唤醒,在unlock前、后都可以
if (_q.size() < low_water) pthread_cond_signal(&_p_cond);
pthread_mutex_unlock(&_mutex);
return out;
}
void push(const T& in)
{
pthread_mutex_lock(&_mutex);
while (_q.size() == _maxcap)
{
pthread_cond_wait(&_p_cond, &_mutex);
}
//代码执行到此处,表明队列没满或排队被唤醒
_q.push(in);
//唤醒
if (_q.size() > high_water) pthread_cond_signal(&_c_cond);
pthread_mutex_unlock(&_mutex);
}
~blockqueue()
{
pthread_mutex_destroy(&_mutex);
pthread_cond_destroy(&_c_cond);
pthread_cond_destroy(&_p_cond);
}
private:
queue<T> _q; // 阻塞队列
size_t _maxcap; // 阻塞队列的容量极值
pthread_mutex_t _mutex;
pthread_cond_t _c_cond; // 消费者的条件变量
pthread_cond_t _p_cond; // 生产者的条件变量,这两个不能共用一个条件变量,因为唤醒时我们要指定唤醒生产者或消费者
int low_water;
int high_water;
};
- 由于我们实现的是单生产者、单消费者的生产者消费者模型,因此我们不需要维护生产者和生产者之间的关系,也不需要维护消费者和消费者之间的关系,我们只需要维护生产者和消费者之间的同步与互斥关系即可。
- 将BlockingQueue当中存储的数据模板化,方便以后需要时进行复用。
- 这里设置BlockingQueue存储数据的上限为 cap_default(使用了静态成员变量),当阻塞队列中存储了cap_default组数据时生产者就不能进行生产了,此时生产者就应该被阻塞。
- 阻塞队列是会被生产者和消费者同时访问的临界资源,因此我们需要用一把互斥锁 _mutex 将其保护起来。
- 生产者线程要向阻塞队列当中Push数据,前提是阻塞队列里面有空间,若阻塞队列已经满了,那么此时该生产者线程就需要在生产者条件变量下进行等待,直到阻塞队列中有空间时再将其唤醒。
- 消费者线程要从阻塞队列当中Pop数据,前提是阻塞队列里面有数据,若阻塞队列为空,那么此时该消费者线程就需要在消费者条件变量下进行等待,直到阻塞队列中有新的数据时再将其唤醒。
- 因此在这里我们需要用到两个条件变量,一个条件变量用来描述消费者,另一个条件变量用来描述生产者。当阻塞队列满了的时候,要进行生产的生产者线程就应该在 _p_cond 条件变量下进行等待;当阻塞队列为空的时候,要进行消费的消费者线程就应该在 _c_cond 条件变量下进行等待。
- 不论是生产者线程还是消费者线程,它们都是先申请到锁进入临界区后再判断是否满足生产或消费条件的,如果对应条件不满足,那么对应线程就会去对应的条件变量下等待,但此时该线程是拿着锁的,为了避免死锁问题,在调用pthread_cond_wait函数时就需要传入当前线程手中的互斥锁,此时当该线程在条件变量下等待时就会自动释放手中的互斥锁,而当该线程被唤醒时又会自动申请获取到该互斥锁。
- push函数如果push了,那么它一定知道阻塞队列里有数据了,可以唤醒在 _c_cond 排队的线程
- pop函数如果pop了,那么它一定知道阻塞队列里有空缺位置,可以唤醒在 _p_cond 排队的线程
为什么 pthread_cond_signal 函数在 pthread_mutex_unlock 前后都可以?
1. pthread_cond_wait()
的行为:
- 自动释放锁: 当
pthread_cond_wait()
被调用时,线程会自动释放它持有的互斥锁。 - 进入等待状态: 线程进入一个等待状态,等待条件变量被信号通知。
- 重新获取锁: 当线程被唤醒时,它会自动重新获取互斥锁,并继续执行。
2. pthread_cond_signal()
的行为:
- 唤醒一个等待线程:
pthread_cond_signal()
函数会唤醒一个正在等待该条件变量的线程。 - 不阻塞调用线程:
pthread_cond_signal()
函数不会阻塞调用线程,它会立即返回。
3. pthread_cond_signal()
的调用时机:
- 在
pthread_mutex_unlock()
前: 如果在释放互斥锁之前调用pthread_cond_signal()
,唤醒的线程会在获取锁后才能继续执行。 - 在
pthread_mutex_unlock()
后: 如果在释放互斥锁之后调用pthread_cond_signal()
,唤醒的线程可能会立即获取到锁,继续执行。
因为 pthread_cond_wait()
会自动释放锁,而 pthread_cond_signal()
又不会阻塞调用线程,因此无论是在释放锁之前还是之后调用 pthread_cond_signal()
,都不会出现竞争条件。
并且pthread_cond_signal()
和 pthread_cond_wait()
都是线程安全的函数,无论在哪个线程中调用,都能保证数据的正确性。
无论是在释放锁之前还是之后调用 pthread_cond_signal()
,都能唤醒等待的线程。但是,建议在释放锁之前调用 pthread_cond_signal()
,这样可以避免唤醒的线程立即获取锁,从而减少潜在的竞争条件。
伪唤醒
例如在某个时刻,blcok queue队列已经满了,后续有三个生产者线程因队列已满,而加入生产者的条件变量的等待队列中等待,于此同时消费者被唤醒,消费一个数据,block queue 空出一个数据,然后消费者不小心采用了 pthread_cond_broadcast 唤醒了所有在生产者等待队列里的线程,例如三个生产者线程。此时这些线程又会粗鲁的竞争锁,而不是在等待队列等待。
我们知道线程在条件变量 wait 的时候,线程会释放锁,在唤醒时会再次申请锁。这三个生产者线程被唤醒后,会竞争这一把锁,最终其中一个线程获得了锁(剩下两个被迫休眠),然后继续往下执行push代码,然后唤醒消费者条件变量下的等待队列中等待的线程,然后释放锁,push结束,但是接下来可能并不是消费者申请到锁,而是之前剩下的两个生产者线程其中一个竞争到锁(它们已经被唤醒,不在条件变量下等待),然后又去执行push代码
但是此时block queue已经满了,再次push会超出block queue的最大容量,而导致数据出错,这种情况被称为伪唤醒状态
**解决:**将 if 改为 while,可以使被唤醒的线程走之前再判断一次,是否满足push、pop的条件
在主函数中我们就只需要创建一个生产者线程和一个消费者线程,让生产者线程不断生产数据,让消费者线程不断消费数据。
cpp
#include "BlockQueue.hpp"
#include <ctime>
void* Customer(void* args)
{
blockqueue<int>* bq = static_cast<blockqueue<int>*>(args);
while (true)
{
int data = bq->pop();
printf("获取一个数据: %d\n", data);
//让消费者慢一点
// sleep(1);
}
}
void* Producer(void* args)
{
blockqueue<int>* bq = static_cast<blockqueue<int>*>(args);
while (true)
{
// 让生产者慢一点
// sleep(2);
int data = rand() % 10 + 1;
bq->push(data);
printf("生产了一个数据: %d\n", data);
// sleep(1);
}
}
int main()
{
srand(time(nullptr));
blockqueue<int>* bq = new blockqueue<int>;
pthread_t c, p;
pthread_create(&c, nullptr, Customer, bq);
pthread_create(&p, nullptr, Producer, bq);
pthread_join(c, nullptr);
pthread_join(p, nullptr);
delete bq;
return 0;
}
相关说明:
- 阻塞队列要让生产者线程向队列中Push数据,让消费者线程从队列中Pop数据,因此这个阻塞队列必须要让这两个线程同时看到,所以我们在创建生产者线程和消费者线程时,需要将该阻塞队列作为线程执行例程的参数进行传入。
- 代码中生产者生产数据就是将获取到的随机数Push到阻塞队列,而消费者消费数据就是从阻塞队列Pop数据,为了便于观察,我们可以将生产者生产的数据和消费者消费的数据进行打印输出
我们可以看到,当生产者生产的数据达到 high_water 时,push 函数内部唤醒 _c_cond 内排队的消费者线程,消费者线程会一直抢占锁资源直到消费完,因为此时消费者阻塞在互斥锁的申请,即使阻塞队列内的数据量小于 low_water,开始唤醒生产者条件变量下等待的线程,但是此时生产者并不在条件变量下等待,所以直到阻塞队列内的资源消耗完,消费者才会进入消费者条件变量下等待,此时生产者线程才竞争到锁,这是加入了水位线 high_water、low_water 的影响结果
如果没有水位线判断条件,则两者会同步,因为每一个push都会唤醒
**小思考:**单生产单消费时,如果最初push先申请到锁资源,然后push数据,唤醒_c_cond等待的线程(signal函数不会阻塞),释放锁,但是此时等待队列内没有线程等待,所以signal无效,但是也不会造成任何错误和异常,因为生产者释放了锁,那么此时消费者可能竞争到锁,从而消费,也有可能一直等到生产者将队列放满数据后进入 _p_cond 条件变量下等待,此时消费者才能申请到锁
注意: 第一次的打印错乱是因为屏幕也是共享资源,没有互斥保护,所以多线程并发打印时会信息串行
生产者生产的慢,消费者消费的快
生产者生产的快,消费者消费的慢
可以看出生产者将整个阻塞队列都push满数据后(默认maxcap为4),消费者开始pop消费数据,形成交错
基于计算任务的生产者消费者模型
我们使用的模板参数BlockQueue,所以生产者还可以传递任务给消费者 ,将阻塞队列内的数据改为类对象Task(单生产、单消费模型)
Task.hpp
cpp
#include <string>
enum
{
DivZero = 1,
ModZero,
Unknown
};
template <class T>
class Task
{
public:
Task(int x, int y, char op)
: _data1(x), _data2(y), _op(op), _result(0), _exitcode(0)
{
}
// run起来
void operator()()
{
switch (_op)
{
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;
}
}
std::string GetResult()
{
std::string result = std::to_string(_data1) + _op + std::to_string(_data2) + '=' + std::to_string(_result);
result += '[' + std::to_string(_exitcode) + ']';
return result;
}
private:
T _data1;
T _data2;
char _op;
T _result;
int _exitcode;
};
blockqueue.hpp
cpp
#pragma once
#include <iostream>
#include <pthread.h>
#include <queue>
#include <unistd.h>
using namespace std;
template<class T>
class blockqueue
{
//静态成员变量
static const size_t cap_default = 20;
public:
blockqueue(size_t maxcap = cap_default)
:_maxcap(maxcap)
{
low_water = _maxcap / 3;
high_water = (_maxcap * 2) / 3;
pthread_mutex_init(&_mutex, nullptr);
pthread_cond_init(&_c_cond, nullptr);
pthread_cond_init(&_p_cond, nullptr);
}
// 那么由谁来唤醒在排队的线程?
// push函数如果push了,那么它一定知道阻塞队列里有数据了,可以唤醒在_c_cond排队的线程
// pop函数如果pop了,那么它一定知道阻塞队列里有空缺位置,可以唤醒在_p_cond排队的线程
const T& pop()
{
pthread_mutex_lock(&_mutex);
while (_q.size() == 0) // 判断临界资源是否就绪也是在访问临界资源,所以要先上锁
{
pthread_cond_wait(&_c_cond, &_mutex); //此时是持有锁的!在调用时自动释放锁,被唤醒时,重新持有锁
}
T out = _q.front();
_q.pop();
//唤醒,在unlock前、后都可以
if (_q.size() < low_water) pthread_cond_signal(&_p_cond);
pthread_mutex_unlock(&_mutex);
return out;
}
void push(const T& in)
{
pthread_mutex_lock(&_mutex);
while (_q.size() == _maxcap)
{
pthread_cond_wait(&_p_cond, &_mutex);
}
//代码执行到此处,表明队列没满或排队被唤醒
_q.push(in);
//唤醒
if (_q.size() > high_water) pthread_cond_signal(&_c_cond);
pthread_mutex_unlock(&_mutex);
}
~blockqueue()
{
pthread_mutex_destroy(&_mutex);
pthread_cond_destroy(&_c_cond);
pthread_cond_destroy(&_p_cond);
}
private:
queue<T> _q; // 阻塞队列
size_t _maxcap; // 阻塞队列的容量极值
pthread_mutex_t _mutex;
pthread_cond_t _c_cond; // 消费者的条件变量
pthread_cond_t _p_cond; // 生产者的条件变量,这两个不能共用一个条件变量,因为唤醒时我们要指定唤醒生产者或消费者
int low_water;
int high_water;
};
cpp
#include "BlockQueue.hpp"
#include "Task.hpp"
#include <ctime>
void* Customer(void* args)
{
blockqueue<Task<int>>* bq = static_cast<blockqueue<Task<int>>*>(args);
while (true)
{
Task<int> t = bq->pop();
// std::cout << "消费了一个数据, " << data << std::endl;
// printf("消费了一个数据, %d\n", data);
t();
std::cout << "处理任务: " << t.GetTask() << ", 运算结果是: " << t.GetResult() << " thread id: " << pthread_self() << std::endl;
//让消费者慢一点
sleep(1);
}
}
void* Producer(void* args)
{
int len = opers.size();
blockqueue<Task<int>>* bq = static_cast<blockqueue<Task<int>>*>(args);
while (true)
{
// 让生产者慢一点
// sleep(2);
int data1 = rand() % 10 + 1;
usleep(10);
int data2 = rand() % 10;
char op = opers[rand()%len];
Task<int> t(data1, data2, op);
bq->push(t);
// std::cout << "生产了一个数据, " << data << std::endl;
// printf("生产了一个数据, %d\n", data);
std::cout << "生产了一个任务: " << t.GetTask() << " thread id: " << pthread_self() << std::endl;
}
}
int main()
{
srand(time(nullptr));
blockqueue<Task<int>>* bq = new blockqueue<Task<int>>;
pthread_t c, p;
pthread_create(&c, nullptr, Customer, bq);
pthread_create(&p, nullptr, Producer, bq);
pthread_join(c, nullptr);
pthread_join(p, nullptr);
return 0;
}
所以不要只看到生产者向仓库放数据、消费者从仓库拿数据,我们也要看到生产者要花时间从哪里获取数据、消费者要花时间加工处理数据
生产者的数据从哪里来?
用户、网络等,所以生产者生产的数据也是要花时间获取的,同样的消费者消费数据就是对数据的加工处理
- 获取数据
- 生产数据到队列
- 消费数据
- 加工处理数据
生产消费模型的高效体现在哪里?
由于是互斥访问临界资源(串行)所以这里并不是高效的,该模型的高效体现在,如果生产者正在向仓库互斥的写入数据,同时消费者正在并发的处理数据(不是消费读取数据),即一方在访问临界区,另一方在访问非临界区,这才是生产消费模型的高效之处,并发操作
所以不要只看到生产者向仓库放数据、消费者从仓库拿数据,我们也要看到生产者要花时间从哪里获取数据、消费者要花时间加工处理数据
多生产、多消费模型
只需要在线程创建时改为数组,并循环create即可
cpp
int main()
{
srand(time(nullptr));
blockqueue<Task<int>>* bq = new blockqueue<Task<int>>;
pthread_t c[3], p[5];
for (int i = 0; i < 3; i++)
{
pthread_create(c+i, nullptr, Customer, bq);
}
for (int i = 0; i < 5; i++)
{
pthread_create(p+i, nullptr, Producer, bq);
}
for (int i = 0; i < 3; i++)
pthread_join(c[i], nullptr);
for (int i = 0; i < 5; i++)
pthread_join(p[i], nullptr);
delete bq;
return 0;
}
任何时刻都只允许一个线程进入临界资源进行访问,这么多线程共用一把锁,满足321原则,所以很快就把单生产、单消费模型改变了
多生产、多消费模型的意义在哪?
多并发的高效性,一个生产者在将生产数据放到队列中时,其他的生产者正在并发获取数据,其他的消费者正在并发的处理数据!提高程序的整体效率
多生产、多消费模型是一种更先进、更灵活的模型,它可以有效地提高程序的效率、灵活性、可扩展性和容错性,更适合处理复杂的数据流和应对复杂的应用场景。