生产者消费者模型(CP模型)是一种非常经典的设计,在实际开发中被广泛使用,因为它在多线程场景中十分高效
1、生产者消费者模型
1.1 什么是生产者消费者模型
生产者消费者模型
是通过一个交易场所来解决生产者与消费者的强耦合关系,生产者与消费者之间不直接进行通讯,而是利用 交易场所
来进行通讯
现实中的超市工作模式就是一个生动形象的生产者消费者模型
- 超市从工厂进货,工厂需要向超市提供商品
- 顾客在超市选购,超市需要向顾客提供商品
得益于超市(交易场所),顾客(消费者)不需要跑到工厂购买商品,工厂(生产者)也不需要将商品配送到顾客手中,这就是解决生产者与消费者间的强耦合关系
超市是交易场所,通常是一种特定的缓冲区
,常见的有阻塞队列
和 环形队列
交易场所
会被多个生产者消费者(多个线程) 看到,是一个共享资源;在多线程环境中,需要保证共享资源被多线程并发访问时的安全
1.2 生产者消费者模型的特点
生产者消费者模型是一个存在生产者、消费者、交易场所
三个条件,以及不同角色间的同步、互斥
关系的高效模型
生产者与生产者:互斥
比如多个工厂供应同一种商品时,为了抢占更多的市场,总会通过一些促销手段来排除竞品,但市场(超市中的货架位置)是有限的
消费者与消费者:互斥
当超市只有一个商品时,消费者之间会竞争
生产者与消费者:互斥、同步
生产者不断生产,交易场所堆满商品后,需要通知消费者进行消费,消费者不断消费,交易场所为空时,需要通知生产者进行生产
[管道](Linux应用编程基础05-进程通信 - 掘金 (juejin.cn))本质上就是一个天然的生产者消费者模型
,因为它允许多个进程同时访问,并且不会出现问题,意味着它维护好了互斥、同步
关系;当写端写满管道时,无法再写,通知读端进行读取;当管道为空时,无法读取,通知写端写入数据
1.3 生产者消费者模型的优点
- 生产者、消费者 可以在同一个交易场所中进行操作
- 生产者在生产时,无需关注消费者的状态,只需关注交易场所中是否有空闲位置
- 消费者在消费时,无需关注生产者的状态,只需关注交易场所中是否有就绪数据
- 可以根据不同的策略,调整生产者与消费者间的协同关系
生产者、消费者、交易场所 各司其职,可以根据具体需求自由设计,很好地做到了解耦
,便于维护和扩展
2、基于阻塞队列的生产者消费者模型
2.1 阻塞队列
阻塞队列是一种特殊的队列,作为队列家族的一员,它具备 先进先出 FIFO
的基本特性,与普通队列不同的是: 阻塞队列的大小是固定的
将其带入生产者消费者模型中,入队就是生产商品,而出队则是消费商品
- 阻塞队列为满时:无法入队 -> 无法生产(阻塞)
- 阻塞队列为空时:无法出队 -> 无法消费(阻塞)
至于如何处理队空/队满的特殊情况,就需要借助互斥、同步
相关知识
2.2 生产者消费者模型
阻塞队列模板类
hpp
#pragma once
#include <queue>
#include <mutex>
#include <pthread.h>
namespace MyBlockQueue
{
#define DEF_SIZE 10 // 阻塞队列长度
template <class T>
class BlockQueue
{
private:
std::queue<T> _queue; // 队列
size_t _cap; // 阻塞队列的容量
// 无论是「生产者」还是「消费者」,它们需要看到同一个阻塞队列,因此使用一把互斥锁进行保护
pthread_mutex_t _mtx; // 互斥锁(存疑)
//「生产者」关心是否为满,「消费者」关心是否为空,两者关注的点不一样,不能只使用一个条件变量,
pthread_cond_t _pro_cond; // 生产者条件变量
pthread_cond_t _con_cond; // 生产者条件变量
public:
BlockQueue(size_t cap = DEF_SIZE) : _cap(cap){
// 初始化锁和条件变量
pthread_mutex_init(&_mtx, nullptr);
pthread_cond_init(&_pro_cond, nullptr);
pthread_cond_init(&_con_cond, nullptr);
}
~BlockQueue(){
// 销毁锁和条件变量
pthread_mutex_destroy(&_mtx);
pthread_cond_destroy(&_pro_cond);
pthread_cond_destroy(&_con_cond);
}
// 生产数据(入队)
void push(const T &inData){
// 加锁
pthread_mutex_lock(&_mtx);
// 判断条件是否满足
while (isFull()){
//阻塞等待条件满足(阻塞的时候需要把锁作为参数进行传递)
// -阻塞时,需要释放锁,不然其他线程得不到锁,就会导致死锁
// 过了一段时间,当条件满足时(消费者已经消费数据了),代码从 pthread_cond_wait 函数之后继续运行
pthread_cond_wait(&_pro_cond, &_mtx);
}
_queue.push(inData);
// 消费者也会有阻塞的情况,当有数据时,唤醒消费者
pthread_cond_signal(&_con_cond);
pthread_mutex_unlock(&_mtx);
}
// 消费数据(出队)
void pop(T *outData){
// 加锁
pthread_mutex_lock(&_mtx);
// 判断条件使用while不使用if的理由:
// 1) pthread_cond_wait 函数可能调用失败(误唤醒、伪唤醒),此时如果是 if 就会向后继续运行,导致在条件不满足的时候进行了 生产/消费
// 2) 在多线程场景中,可能会使用 pthread_cond_broadcast 唤醒所有等待线程,如果在只生产了一个数据的情况下,唤醒所有线程,会导致只有一个线程进行了合法操作,其他线程都是非法操作了
while (isEmpty()){
pthread_cond_wait(&_con_cond, &_mtx);
}
*outData = _queue.front();
_queue.pop();
// 可以加策略唤醒,比如消费完后才唤醒生产者
pthread_cond_signal(&_pro_cond);
pthread_mutex_unlock(&_mtx);
}
private:
bool isFull(){
return _queue.size() == _cap;
}
bool isEmpty(){
return _queue.empty();
}
};
}
main.cpp
cpp
#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include "BlockingQueue.hpp"
// 生产线程
void *Producer(void *args)
{
MyBlockQueue::BlockQueue<int> *bq = static_cast<MyBlockQueue::BlockQueue<int> *>(args);
while (true)
{
// 1.生产商品(通过某种渠道获取数据)
int num = rand() % 10;
// 2.将商品推送至阻塞队列中
bq->push(num);
std::cout << "Producer 生产了一个数据: " << num << std::endl;
std::cout << "------------------------" << std::endl;
}
pthread_exit((void *)0);
}
// 消费线程
void *Consumer(void *args)
{
MyBlockQueue::BlockQueue<int> *bq = static_cast<MyBlockQueue::BlockQueue<int> *>(args);
while (true)
{
sleep(1); // 每隔1s消费一个
// 1.从阻塞队列中获取商品
int num;
bq->pop(&num);
// 2.消费商品(结合某种具体业务进行处理)
std::cout << "Consumer 消费了一个数据: " << num << std::endl;
std::cout << "------------------------" << std::endl;
}
pthread_exit((void *)0);
}
int main()
{
// 创建阻塞队列
MyBlockQueue::BlockQueue<int> *bq = new MyBlockQueue::BlockQueue<int>;
// 创建两个线程(生产、消费)
pthread_t pro, con;
pthread_create(&pro, nullptr, Producer, bq); // 生产者、消费者需要看到同一个阻塞队列
pthread_create(&con, nullptr, Consumer, bq);
pthread_join(pro, nullptr);
pthread_join(con, nullptr);
delete bq;
}
3、基于循环队列实现生产者消费者模型
3.1 POSIX 信号量
互斥、同步不只能通过 互斥锁、条件变量
实现,还能通过 信号量 sem、互斥锁
实现
信号量的本质就是一个 计数器
,只有在计数器不为 0 的情况下,才能进行资源申请
- 申请到资源,计数器 --(P 操作)
- 释放完资源,计数器 ++(V 操作)
如果信号量只有两种状态:1、0,可以实现类似 互斥锁 的效果,即实现线程互斥(二元信号量)
信号量不止可以用于互斥,它的主要目的是描述临界资源中的资源数目
,比如把阻塞队列切割成 N 份,初始化信号量的值为N,当某一份资源就绪时,sem--,资源被释放后,sem++,这样可以像条件变量一样实现同步(多元信号量)
- 当 sem == N 时,阻塞队列已经空了,消费者无法消费
- 当 sem == 0 时,阻塞队列已经满了,生产者无法生产
将信号量实际带入之前的生产者消费者模型中,是不需要进行资源条件判断的,因为信号量本身就已经是资源的计数器
在实现 互斥、同步 时,该如何选择?
结合业务场景进行分析,如果待操作的共享资源是一个整体,比较适合使用 互斥锁+条件变量 的方案,但如果共享资源是多份资源,使用 信号量 就比较方便
信号量相关操作:
初始化信号量:
c
#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);
/*
* sem:需要初始化的信号量,sem_t 实际就是一个联合体,里面包含了一个 char 数组,以及一个 long int
* pshared:表示当前信号量的共享状态,传递 0 表示线程间共享,传递 非0 表示进程间共享
* value:信号量的初始值,可以设置为双元或多元信号量
*/
销毁信号量:
c
#include <semaphore.h>
int sem_destroy(sem_t *sem);
申请信号量(等待信号量):
c
#include <semaphore.h>
int sem_wait(sem_t *sem); // 表示从哪个信号量中申请(阻塞)
int sem_trywait(sem_t *sem); // 尝试申请,如果没有申请到资源,就会放弃申请(非阻塞)
int sem_timedwait(sem_t *sem, const struct timespec *abs_timeout);//每隔一段时间进行申请
释放信号量(发布信号量):
c
#include <semaphore.h>
int sem_post(sem_t *sem);// 将资源释放到哪个信号量中
使用信号量
标识资源的使用情况,生产者和消费者关注的资源并不相同,所以需要使用两个信号量来进行操作
- 生产者信号量:标识当前有多少可用空间
- 消费者信号量:标识当前有多少数据
3.2 生产者消费者模型
通过两个信号量,当两个信号量都不为 0 时,双方可以并发
操作,这是循环队列
最大的特点
- 当生产者信号量为 0 时,生产者陷入阻塞等待,等待消费者消费
- 当消费者信号量为 0 时,消费者也会阻塞住,在这里阻塞就是
互斥
的体现
当对方完成 生产 / 消费 后,自己会解除阻塞状态,而这就是 同步
循环队列模板类:
hpp
#pragma once
#include <iostream>
#include <vector>
#include <semaphore.h>
#include <mutex>
#include <pthread.h>
namespace MyRingQueue
{
#define DEF_SIZE 10 // 循环队列长度
template <class T>
class RingQueue
{
private:
std::vector<T> _queue; //循环队列(用数组表示)
size_t _cap; // 容量
sem_t _pro_sem; // 生产者信号量
sem_t _con_sem; // 消费者信号量
size_t _pro_step; // 生产者下标
size_t _con_step; // 消费者下标
// 这里需要两把锁:因为当前的生产者和消费者关注的资源不一样,一个关注剩余空间,一个关注是否有商品
// (阻塞队列只需要一把锁:因为共享资源是一整个队列,生产者和消费者访问的是同一份资源)
pthread_mutex_t _pro_mtx;
pthread_mutex_t _con_mtx;
public:
RingQueue(size_t cap = DEF_SIZE) : _cap(cap)
{
_queue.resize(_cap);
// 初始化信号量
sem_init(&_pro_sem, 0, _cap);
sem_init(&_con_sem, 0, 0);
// 初始化互斥锁
pthread_mutex_init(&_pro_mtx, nullptr);
pthread_mutex_init(&_con_mtx, nullptr);
}
~RingQueue()
{
// 销毁信号量
sem_destroy(&_pro_sem);
sem_destroy(&_con_sem);
// 销毁互斥锁
pthread_mutex_destroy(&_pro_mtx);
pthread_mutex_destroy(&_con_mtx);
}
// 生产数据(入队)
void push(const T &inData)
{
// 申请信号量(空位-1)
sem_wait(&_pro_sem); // 因为操作信号量是原子操作,可以确保线程安全,也就不需要加锁保护
// 加锁
pthread_mutex_lock(&_pro_mtx);
// 生产(循环队列入队,不需要再单独判断对满,因为信号量已经判断)
_queue[_pro_step++] = inData;
_pro_step %= _cap;
pthread_mutex_unlock(&_pro_mtx);
// 释放信号量(可消费量+1)
sem_post(&_con_sem);
}
// 消费数据(出队)
void pop(T *outData)
{
// 申请信号量
sem_wait(&_con_sem);
pthread_mutex_lock(&_con_mtx);
// 消费
*outData = _queue[_con_step++];
_con_step %= _cap;
pthread_mutex_unlock(&_con_mtx);
// 释放信号量
sem_post(&_pro_sem);
}
};
}
多线程:
cpp
#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include "RingQueue.hpp"
// 生产线程
void *Producer(void *args)
{
MyRingQueue::RingQueue<int> *bq = static_cast<MyRingQueue::RingQueue<int> *>(args);
while (true)
{
sleep(1); // 每隔1s消费一个
// 1.生产商品(通过某种渠道获取数据)
int num = rand() % 10;
// 2.将商品推送至阻塞队列中
bq->push(num);
std::cout << "Producer 生产了一个数据: " << num << std::endl;
std::cout << "------------------------" << std::endl;
}
pthread_exit((void *)0);
}
// 消费线程
void *Consumer(void *args)
{
MyRingQueue::RingQueue<int> *bq = static_cast<MyRingQueue::RingQueue<int> *>(args);
while (true)
{
sleep(1); // 每隔1s消费一个
// 1.从阻塞队列中获取商品
int num;
bq->pop(&num);
// 2.消费商品(结合某种具体业务进行处理)
std::cout << "Consumer 消费了一个数据: " << num << std::endl;
std::cout << "------------------------" << std::endl;
}
pthread_exit((void *)0);
}
int main()
{
// 种子
srand((size_t)time(nullptr));
// 创建循环队列
MyRingQueue::RingQueue<int> *rq = new MyRingQueue::RingQueue<int>;
// 创建多个线程(生产者、消费者)
pthread_t pro[10], con[20];
for (int i = 0; i < 10; i++)
pthread_create(pro + i, nullptr, Producer, rq);
for (int i = 0; i < 20; i++)
pthread_create(con + i, nullptr, Consumer, rq);
for (int i = 0; i < 10; i++)
pthread_join(pro[i], nullptr);
for (int i = 0; i < 20; i++)
pthread_join(con[i], nullptr);
delete rq;
}
4、比较阻塞队列和循环队列
首先要明白生产者消费者模型
高效的地方从来都不是往缓冲区中放数据、从缓冲区中拿数据
需要关注的点在于生产数据和消费数据
,这是比较耗费时间的,阻塞队列至多支持获取一次数据获取或一次数据消费,在代码中的具体体现就是所有线程都在使用一把锁,并且每次只能 push、pop 一个数据;
而循环队列就不一样了,生产者、消费者 可以通过信号量
知晓数据获取、数据消费次数,并且由于数据获取、消费操作没有加锁,支持并发,因此效率十分高
循环队列一定优于阻塞队列吗?