👦个人主页:Weraphael
✍🏻作者简介:目前正在学习c++和算法
✈️专栏:Linux
🐋 希望大家多多支持,咱一起进步!😁
如果文章有啥瑕疵,希望大佬指点一二
如果文章对你有帮助的话
欢迎 评论💬 点赞👍🏻 收藏 📂 加关注😍
目录
一、 什么是PC模型
生产者消费者模式(Producer-Consumer Model
,也称PC
模型):就是通过一个容器来解决生产者和消费者的强耦合问题。是专门高效的解决多线程或多进程间协作问题。
强耦合指的是系统中各个组件或模块之间依赖关系非常紧密和直接,一个组件的改变可能会直接影响到其他组件,导致系统难以维护和扩展。
因此,生产者和消费者彼此之间可以不用直接通讯,而通过容器来进行通讯。所以生产者生产完数据之后不用等待消费者处理,直接扔给容器,消费者不找生产要数据,而是直接从容器里取。这个容器本质就是一个缓冲区,平衡了生产者和消费者的处理能力。而这个容器就是用来给生产者和消费者解耦的。
【补充】
低耦合:模块之间依赖关系尽可能少,不然牵一发而动全身。(减少类内部一个成员方法调用另一个成员方法)
高内聚:每个模块尽可能独立完成自己的功能,不依赖于模块外部的代码。(尽可能类的每个成员方法只完成一件事)
项目建议要遵循
高内聚低耦合
的设计原则,这样便于维护。
举一个例子:在我们现实的生活中是存在PC
模型的,超市就是一个典型的例子。
- 顾客 —> 消费者
- 超市 —> 容器
- 供应商 —> 生产者
为什么要将超市作为顾客和供应商的中间环节?顾客直接去供应商那边买不就行了吗?
-
这是因为顾客直接从供应商处购买可能会花费更多时间和精力。比方说一个顾客要买不同牌子的冰红茶(统一/康师傅),那么他来来回回要找到
2
家供应商。相比之下,超市提供了一个节省时间和成本的选择,消费者可以一次性购买多种商品,减少往返和寻找产品的时间。 -
另外,顾客的需求量非常小,每次购买时供应商都需要从零开始制作,效率非常低。相比之下,供应商可以提前准备大量商品并存放在超市,但不会塞满,因为超市是有容量的,此时超市就成为一个缓冲区,顾客就不用等供应商从零开始制作。即使顾客在超市中找不到想要的商品,也可以通过超市向供应商反馈需求。
-
...
总体来看,得益于超市做缓冲区,使得整个生产和消费的过程十分高效。除此之外,供应商专注于生产商品的质量;而消费者则关心的是如何获得需要的产品,无需关心生产者制造产品的过程。因此,超市解决生产者与消费者间的强耦合关系(联系不紧密)。
另外,供应商可以提前准备大量商品存放在超市(缓冲区),消费者任何时间都可以去超市消费。因此,生产者消费者模型的一个优点是它支持忙闲不均的情况。即生产者和消费者可以按照各自的节奏工作。
回到计算机世界:
- 生产者 —> 线程
- 消费者 —> 线程
- 超市 —> 有特定结构和大小的缓冲区(常见的有阻塞队列和环形队列)
- 商品 —> 数据
供货商要做的最终目的是要把商品交给消费者,而在计算机中就是将数据由另一个线程交给另一个线程。所以理论上,生产者消费者模型本质是执行流在做通信。
注:通信现在不是我们研究的重点(通信知识可看往期博客),这里我们更加关注的是:执行流是如何安全高效的通信。
这时我们会发现:消费者线程和生产者线程都会访问这块缓冲区。因此,缓冲区是一个共享资源!而我们知道:共享资源在被多执行流并发访问的时,会有并发问题。
因此,要想研究并发问题,本质是在研究生产者和生产者 、消费者和消费者 、生产者和消费者这三者之间的关系(互斥 or 同步
):
-
生产者和生产者:互斥关系。避免多执行流同时访问共享资源,不然会导致数据不一致问题(保证数据安全)。
-
消费者和消费者:互斥关系。避免多执行流同时访问共享资源,不然会导致数据不一致问题(保证数据安全)。
-
生产者和消费者:互斥关系和同步关系。① 避免多执行流同时访问共享资源,不然会导致数据不一致问题(保证数据安全)。 ② 如果让生产者一直生产,那么当生产者生产的数据将缓冲区塞满后,生产者再生产数据就会生产失败。反之,让消费者一直消费,那么当缓冲区当中的数据被消费完后,消费者再进行消费就会消费失败。因此,要确保生产者和消费者在对共享资源的操作上能够协调合作,避免生产者在缓冲区满了还继续在生产;或者缓冲区没有数据时,消费者还在读取数据。
二、生产者消费者模型的特点
- 面试题:谈谈你所理解的
CP
模型。答:
CP
模型是处理多线程或多进程间协作的高效方法,3
、2
、1
你只要记住321原则
—> 3
指的是3
种关系,2
指的是2
种角色,1
指的是1
个交易场所。
注意:321原则
并非众所周知的概念,仅供辅助记忆。
3
种关系:
- 生产者与生产者:互斥
- 消费者与消费者:互斥
- 生产者与消费者:互斥与同步
2
种角色:
- 生产者
- 消费者
1
个交易场所:
- 通常是一个特定的缓冲区(阻塞队列、环形队列)
三、生产者消费者模型的优点
- 支持忙闲不均。生产者可以按照自己的速度生成数据或任务,并将其放入缓冲区中,而不必等待消费者立刻处理。同样地,消费者可以根据自身的能力和需要从缓冲区中取出数据进行处理,而不会影响生产者的工作节奏。
- 解耦。生产者在生产时,无需关注消费者的状态,只需关注交易场所中是否有空闲位置;同理,消费者在消费时,无需关注生产者的状态,只需关注交易场所中是否有就绪数据。
生产、消费 的过程是加锁的、那么就注定了只有一方的一个执行才能生产数据/消费数据,即串行执行,可能有的人无法get
到生产者消费者模型的高效,这是因为没有对生产者消费者模型进行一个全面的理解。
不知道你又没发现一个问题:生产者的数据从哪来?
可以从用户、网络等方式。那么注定了生产者生产的数据是要花时间来获取的!同理,消费者从缓冲区中拿数据,可是拿完数据就完了吗?当然不是!消费者还要拿这些数据进行加工处理!加工处理也是需要时间的!
即
- 消费者在进行业务处理的同时,生产者可以直接向缓冲区中生产数据。
- 生产者在进行获取(网络)数据的同时,消费者可以直接向队列中取已有数据。
又因为生产者和消费者之间的关系是解耦的。即生产者不必关心消费者的消费情况,消费者也不需要关心生产者的生产情况。所以,生产者和消费者是天然并行的。
四、 实现基于阻塞队列的生产者消费者模型
4.1 前言
阻塞队列(Blocking Queue
)是一种常用于实现生产者和消费者模型的数据结构。与普通的队列区别在于:
- 当阻塞队列为空时,消费者线程会被阻塞,直到生产者线程向阻塞队列中被放入了元素。
- 当队列满时,生产者线程会被阻塞,直到消费者线程从阻塞队列队列中取出元素。
总结: 阻塞队列的大小是固定的,也就说它存在容量的概念。
看到以上阻塞队列的描述,我们很容易想到的就是管道。至于如何处理队空和队满的特殊情况,就需要借助互斥、同步相关知识了,具体在代码中体现。
4.2 单生产单消费模型
为了方便理解,下面我们以单生产者、单消费者为例进行实现。
其中的BlockingQueue
就是生产者消费者模型当中的交易场所,我们可以用C++
的STL
库当中的queue
容器进行实现。
创建
BlockQueue.hpp
头文件
说明: 以.hpp
为后缀的文件也是头文件。该头文件同时包含类的定义与实现,调用者只需include
该.hpp
文件即可。
#pragma once
#include <pthread.h>
#include <queue>
#include <iostream>
template <class T>
class BlockQueue
{
// 静态成员变量属于整个类而不是类对象
static int DEFAULT;
public:
// 默认构造
BlockQueue()
{}
// 构造
BlockQueue(int maxcap = DEFAULT)
: _cap(DEFAULT)
{
// 初始化锁
pthread_mutex_init(&_mtx, nullptr);
// 初始化条件变量
pthread_cond_init(&c_cond, nullptr);
pthread_cond_init(&p_cond, nullptr);
}
// 析构
~BlockQueue()
{
// 销毁锁
pthread_mutex_destroy(&_mtx);
// 销毁条件变量
pthread_cond_destroy(&c_cond);
pthread_cond_destroy(&p_cond);
}
// 生产数据
void push(const T &in)
{
pthread_mutex_lock(&_mtx);
// 如果阻塞队列满了,就不能生产数据了
if (_q.size() == _cap)
{
// 那么线程需要阻塞,不能生产了
pthread_cond_wait(&p_cond, &_mtx); // 自动释放锁,线程处于阻塞状态
}
// 来到这有两种情况:
// 1. 队列没满
// 2. 生产者线程被唤醒 (被唤醒表示满足了某个条件,即阻塞队列没满)
_q.push(in);
// 执行上一条语句后,阻塞队列一定有数据,说明可以唤醒消费者线程
pthread_cond_signal(&c_cond); // 该语句放到临界区里外都可以
pthread_mutex_unlock(&_mtx);
}
// 取数据
T pop()
{
pthread_mutex_lock(&_mtx);
// 阻塞队列没有数据不能消费,即不能取数据
if (_q.size() == 0)
{
// 注意:生产者和消费者不能用同一个条件变量
// 否则唤醒的是生产者线程还是消费者线程不确定
pthread_cond_wait(&c_cond, &_mtx);
}
// 来到这有两种情况:
// 1. 阻塞队列有数据
// 2. 消费者线程被唤醒 (被唤醒表示满足了某个条件,即阻塞队列有数据)
T out = _q.front();
_q.pop();
// 消费者执行上一条语句,说明阻塞队列一定还没满,说明可以唤醒生产者线程
pthread_cond_signal(&p_cond); // 该语句放到临界区里外都可以
pthread_mutex_unlock(&_mtx);
return out;
}
private:
std::queue<T> _q; // 队列,共享资源
size_t _cap; // 阻塞队列的容量
pthread_mutex_t _mtx; // 互斥锁。保护共享资源
pthread_cond_t c_cond; // 消费者线程条件变量,实现同步
pthread_cond_t p_cond; // 生产者线程条件变量,实现同步
};
template <class T>
int BlockQueue<T>::DEFAULT = 5; // 初始化静态成员变量
相关说明(代码注释也有详细的说明):
-
成员变量设计:
- 复用
STL
中的queue
容器。而队列未来的数据类型是什么我们并不知道,因此BlockQueue
类可以设计成类模板。 - 阻塞队列是由固定大小的。里设置
BlockQueue
存储数据的上限为5
,当阻塞队列中存储了五个数据时,生产者就不能进行生产了,此时生产者就应该被阻塞。 - 由于我们实现的是单生产者、单消费者的生产者消费者模型,因此我们只需要维护生产者和消费者之间的同步与互斥关系即可。但是在设计同步关系时,条件变量要设计
2
个,一个给消费者使用,另一个给生产使用,因为未来生产者和消费者如果使用同一个线程等待队列,那我们唤醒是不确定会唤醒生产者线程还是消费者线程。
- 复用
-
CP
模型最重要的是放数据和取数据。因此,取数据pop()
和放数据push
是整个CP
模型中最重要的两个接口。阻塞队列是会被生产者和消费者同时访问的临界资源,因此我们在实现pop()
和push()
需要用同一把互斥锁将其保护起来。-
生产者线程要向阻塞队列当中
push
数据,前提是阻塞队列里面有空间,若阻塞队列已经满了,那么此时该生产者线程就需要进行等待,直到阻塞队列中有空间时再将其唤醒。 -
消费者线程要从阻塞队列当中
pop
数据,前提是阻塞队列里面有数据,若阻塞队列为空,那么此时该消费者线程就需要进行等待,直到阻塞队列中有新的数据时再将其唤醒。 -
那生产者线程和消费者线程该由谁唤醒呢?可以把背后交给对方,即当阻塞队列满了的时候,那么生产者线程就应该唤醒消费者线程,赶紧去消费;当阻塞队列为空的时候,那么消费者线程就就应该唤醒生产者,快点去生产。
-
注意:条件等待函数
pthread_cond_wait()
要写在临界区中。因为我们是否要阻塞一个线程是要显示判断阻塞队列是否为满或是否为空,而阻塞资源是临界资源,判断也是访问临界资源。因此,条件等待是必须要写在临界区中的。
-
在主函数,只需要创建一个生产者线程和一个消费者线程,让生产者线程不断生产
[1, 10]
的随机数,消费者线程从阻塞队列中不断打印数据。
#include "BlockQueue.hpp"
#include <unistd.h>
#include <ctime>
// 消费者
void *Consumer(void *args)
{
BlockQueue<int> *bq = static_cast<BlockQueue<int> *>(args);
while (true)
{
// 消费者每隔一秒消费一次
sleep(1);
// 消费行为 - 将数据放到阻塞队列里
int data = bq->pop();
std::cout << "消费了一个数据: " << data << std::endl;
std::cout << "------------------------" << std::endl;
}
}
// 生产者
void *Producter(void *args)
{
BlockQueue<int> *bq = static_cast<BlockQueue<int> *>(args);
int data = 0;
while (true)
{
// 生产行为 - 获取阻塞队列的数据
int data = rand() % 10 + 1; // 数据范围[1, 10]
usleep(10); // 模拟生产者获取数据。休眠10微秒,即0.00001秒
bq->push(data);
std::cout << "生产了一个数据: " << data << std::endl;
std::cout << "------------------------" << std::endl;
}
}
int main()
{
// 随机数种子
srand(time(nullptr));
// 阻塞队列
BlockQueue<int> *bq = new BlockQueue<int>();
// c - 消费者线程
// p - 生产者线程
pthread_t c, p;
pthread_create(&c, nullptr, Consumer, (void *)bq);
pthread_create(&p, nullptr, Producter, (void *)bq);
// 主线程负责等待cp线程
pthread_join(c, nullptr);
pthread_join(p, nullptr);
return 0;
}
注意:生产者线程要向阻塞队列中push
数据,消费者线程也要从阻塞队列中pop
数据,因此这个阻塞队列,即共享资源必须要让这两个线程同时看到,所以我们在创建生产者线程和消费者线程时,需要将该阻塞队列作为线程执行例程的参数进行传入。
为了观察到阻塞队列的特点,我们可以通过 睡眠 的方式模拟效果。
- 消费者每隔一秒消费一次,生产者疯狂生产(代码如上)。
应该观察到的现象是 生产者很快就把阻塞队列填满了,只能阻塞等待,1 秒之后,消费者进行消费,消费结束后唤醒生产者,两者进入协同状态:生产者生产一个数据、消费者消费一个数据。
- 生产者每隔一秒生产一次,消费者不断消费。
预期结果为刚开始阻塞队列为空,消费者无法进行消费,只能阻塞等待,一秒后,生产者生产了一个数据,并立即通知消费者进行消费,两者协同工作,消费者消费的就是生产者刚刚生产的数据。
以上是生产一个,消费一个,即每次生产一个都要通知消费者来消费,每消费一个就要通知生产者来生产;但如果想要生产一批,消费一批呢?
思路:定义一个区间[low_water, high_water]
,如果BlockQueue
里的数据个数低于low_water
,说明要让生产者来生产了;如果BlockQueue
里的数据个数高于high_water
,说明要让消费者来消费。
#pragma once
#include <pthread.h>
#include <queue>
#include <iostream>
template <class T>
class BlockQueue
{
// 静态成员变量属于整个类而不是类对象
static int DEFAULT;
public:
// 默认构造
BlockQueue()
{}
// 构造
BlockQueue(int maxcap = DEFAULT)
: _cap(DEFAULT)
{
// 初始化锁
pthread_mutex_init(&_mtx, nullptr);
// 初始化条件变量
pthread_cond_init(&c_cond, nullptr);
pthread_cond_init(&p_cond, nullptr);
low_water = maxcap / 3;
high_water = maxcap * 2 / 3;
}
// 析构
~BlockQueue()
{
// 销毁锁
pthread_mutex_destroy(&_mtx);
// 销毁条件变量
pthread_cond_destroy(&c_cond);
pthread_cond_destroy(&p_cond);
}
// 生产数据
void push(const T &in)
{
pthread_mutex_lock(&_mtx);
// 如果阻塞队列满了,就不能生产数据了
if (_q.size() == _cap)
{
// 那么线程需要阻塞,不能生产了
pthread_cond_wait(&p_cond, &_mtx); // 自动释放锁,线程处于阻塞状态
}
// 来到这有两种情况:
// 1. 队列没满
// 2. 生产者线程被唤醒 (被唤醒表示满足了某个条件,即阻塞队列没满)
_q.push(in);
if (_q.size() > high_water)
{
// 执行上一条语句后,阻塞队列一定有数据,说明可以唤醒消费者线程
pthread_cond_signal(&c_cond); // 该语句放到临界区里外都可以
}
pthread_mutex_unlock(&_mtx);
}
// 取数据
T pop()
{
pthread_mutex_lock(&_mtx);
// 阻塞队列没有数据不能消费,即不能取数据
if (_q.size() == 0)
{
// 注意:生产者和消费者不能用同一个条件变量
// 否则唤醒的是生产者线程还是消费者线程不确定
pthread_cond_wait(&c_cond, &_mtx);
}
// 来到这有两种情况:
// 1. 阻塞队列有数据
// 2. 消费者线程被唤醒 (被唤醒表示满足了某个条件,即阻塞队列有数据)
T out = _q.front();
_q.pop();
if (_q.size() < low_water)
{
// 消费者执行上一条语句,说明阻塞队列一定还没满,说明可以唤醒生产者线程
pthread_cond_signal(&p_cond); // 该语句放到临界区里外都可以
}
pthread_mutex_unlock(&_mtx);
return out;
}
private:
std::queue<T> _q; // 队列,共享资源
size_t _cap; // 阻塞队列的容量
pthread_mutex_t _mtx; // 互斥锁。保护共享资源
pthread_cond_t c_cond; // 消费者线程条件变量,实现同步
pthread_cond_t p_cond; // 生产者线程条件变量,实现同步
int low_water;
int high_water;
};
template <class T>
int BlockQueue<T>::DEFAULT = 20; // 初始化静态成员变量
// BlockQueue<int>::myStaticVar = 10; // 通过类名访问静态成员变量并赋值
【程序结果】
但我们写以上代码的意义是什么呢?未来我们是要让多线程去协同完成任务的,所以,谁规定阻塞队列的类型只能是基本类型呢?当然也可以是类!也就是类模板的好处!
在
Task.hpp
中写一个任务类,实现简单的计算器+-*/
#pragma once
#include <iostream>
std::string oper = "+-*/";
enum
{
DivZero = 1,
Unknow
};
class Task
{
public:
Task()
{}
Task(int data1, int data2, char op)
: _data1(data1), _data2(data2), _op(op), _result(0), _exitcode(0)
{
}
// 执行任务
void run()
{
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;
default:
_exitcode = Unknow;
break;
}
}
// 得到任务 + 结果
std::string GetRusult()
{
std::string r = std::to_string(_data1);
r += _op;
r += std::to_string(_data2);
r += "=";
r += std::to_string(_result);
r += "[code: ";
r += std::to_string(_exitcode);
r += "]";
return r;
}
// 获得任务
std::string GetTask()
{
std::string r = std::to_string(_data1);
r += _op;
r += std::to_string(_data2);
r += "= ?";
return r;
}
private:
int _data1;
int _data2;
char _op; // 运算符
int _result; // 运算结果
int _exitcode; // 运算结果是否正确,0表示正确,1表示不正确
};
- 主函数
main.cc
#include "BlockQueue.hpp"
#include <unistd.h>
#include "Task.hpp"
#include <ctime>
// 消费者
void *Consumer(void *args)
{
BlockQueue<Task> *bq = static_cast<BlockQueue<Task> *>(args);
while (true)
{
// 消费行为 - 从阻塞队列里取数据
Task t = bq->pop();
// 计算
t.run();
std::cout << "处理任务:" << t.GetTask()
<< " 运算结果是: " << t.GetRusult()
<< std::endl;
std::cout << "------------------------" << std::endl;
}
}
// 生产者
void *Producter(void *args)
{
int len = oper.size();
BlockQueue<Task> *bq = static_cast<BlockQueue<Task> *>(args);
while (true)
{
// 生产行为 - 获取阻塞队列的数据
int data1 = rand() % 10 + 1; // 数据范围[1, 10]
int data2 = rand() % 10 + 1;
char op = oper[rand() % len];
Task t(data1, data2, op);
usleep(10); // 模拟生产者获取数据。休眠10微秒,即0.00001秒
// 生产者每隔一秒生产一次
sleep(1);
bq->push(t);
std::cout << "生产了一个任务: " << t.GetTask() << std::endl;
std::cout << "------------------------" << std::endl;
}
}
int main()
{
// 随机数种子
srand(time(nullptr));
// 阻塞队列
BlockQueue<Task> *bq = new BlockQueue<Task>();
// c - 消费者线程
// p - 生产者线程
pthread_t c, p;
pthread_create(&c, nullptr, Consumer, (void *)bq);
pthread_create(&p, nullptr, Producter, (void *)bq);
// 主线程负责等待cp线程
pthread_join(c, nullptr);
pthread_join(p, nullptr);
return 0;
}
【程序结果】
所以CP
模型最终的目的是让多线程并发来协作,来完成任务的。
4.3 多生产多消费模型
以上单生产单消费模型,在多生产多消费模型中使用会有什么问题呢?
- 假设一开始阻塞队列被生产者写满了,那么所有的生产者就会在线程等待队列中等待被唤醒。
- 接下来消费者正常去消费,有且只消费了一个
- 然后接下来要去唤醒生产者线程,可是唤醒的时候并不是单单唤醒一个,而是使用广播的形式唤醒了生产者等待队列的所有线程。被唤醒的生产者线程并不是都继续向下执行,而是去竞争锁资源,假设生产者
1
竞争成功执行临界区代码。
- 接下来就是唤醒消费者线程,不过在唤醒的时候,是所有的消费者线程和刚刚被唤醒的生产者线程在竞争锁资源,在这个过程中,有可能生产者会拿到锁资源,那么在已满的阻塞队列下再生产资源,就会出现错误。所以这种情况就称为伪唤醒。
这就好比食堂里有很多人等待出餐,当食堂阿姨仅做好一份饭后,就通知所有同学过来取餐,直接导致其他同学白跑一趟;带入程序中,直接影响就是 生产者/消费者在队列满/队列空 的情况下,仍然进行了数据生产/数据消费
所以,把条件判断改成while
来循环判断,直到条件满足后,才向后运行。做到防止被伪唤醒的情况。
#pragma once
#include <pthread.h>
#include <queue>
#include <iostream>
template <class T>
class BlockQueue
{
// 静态成员变量属于整个类而不是类对象
static int DEFAULT;
public:
// 构造
BlockQueue(int maxcap = DEFAULT)
: _cap(DEFAULT)
{
// 初始化锁
pthread_mutex_init(&_mtx, nullptr);
// 初始化条件变量
pthread_cond_init(&c_cond, nullptr);
pthread_cond_init(&p_cond, nullptr);
}
// 析构
~BlockQueue()
{
// 销毁锁
pthread_mutex_destroy(&_mtx);
// 销毁条件变量
pthread_cond_destroy(&c_cond);
pthread_cond_destroy(&p_cond);
}
// 生产数据
void push(const T &in)
{
pthread_mutex_lock(&_mtx);
// 如果阻塞队列满了,就不能生产数据了
while (_q.size() == _cap) // 防止伪唤醒
{
// 那么线程需要阻塞,不能生产了
pthread_cond_wait(&p_cond, &_mtx); // 自动释放锁,线程处于阻塞状态
}
// 来到这有两种情况:
// 1. 队列没满
// 2. 生产者线程被唤醒 (被唤醒表示满足了某个条件,即阻塞队列没满)
_q.push(in);
// 执行上一条语句后,阻塞队列一定有数据,说明可以唤醒消费者线程
pthread_cond_signal(&c_cond); // 该语句放到临界区里外都可以
pthread_mutex_unlock(&_mtx);
}
// 取数据
T pop()
{
pthread_mutex_lock(&_mtx);
// 阻塞队列没有数据不能消费,即不能取数据
while (_q.size() == 0) // 防止伪唤醒
{
// 注意:生产者和消费者不能用同一个条件变量
// 否则唤醒的是生产者线程还是消费者线程不确定
pthread_cond_wait(&c_cond, &_mtx);
}
// 来到这有两种情况:
// 1. 阻塞队列有数据
// 2. 消费者线程被唤醒 (被唤醒表示满足了某个条件,即阻塞队列有数据)
T out = _q.front();
_q.pop();
// 消费者执行上一条语句,说明阻塞队列一定还没满,说明可以唤醒生产者线程
pthread_cond_signal(&p_cond); // 该语句放到临界区里外都可以
pthread_mutex_unlock(&_mtx);
return out;
}
private:
std::queue<T> _q; // 队列,共享资源
size_t _cap; // 阻塞队列的容量
pthread_mutex_t _mtx; // 互斥锁。保护共享资源
pthread_cond_t c_cond; // 消费者线程条件变量,实现同步
pthread_cond_t p_cond; // 生产者线程条件变量,实现同步
};
template <class T>
int BlockQueue<T>::DEFAULT = 5; // 初始化静态成员变量
接下来我们来验证一下,在主函数中创建3
个消费者和5
个生产者
4.4 相关代码
实现基于阻塞队列实现生产者消费者模型的相关代码:点击跳转
五、实现基于环形队列实现生产者消费者模型
5.1 POSIX 信号量
POSIX
信号量和SystemV
信号量的概念和作用相同,都是用于同步操作,达到无冲突的访问共享资源目的。 但POSIX
信号量可以用于线程间同步。这里是System V
的相关知识 —> 点击跳转
-
我们将可能会被多个执行流同时访问的资源叫做临界资源,临界资源需要进行保护否则会出现数据不一致等问题。
-
当我们仅用一个互斥锁对临界资源进行保护时,相当于我们将这块临界资源看作一个整体,同一时刻只允许一个执行流对这块临界资源进行访问。
-
但实际我们可以将这块临界资源再分割为多个区域,当多个执行流需要访问临界资源时,如果这些执行流访问的是临界资源的不同区域,那么我们可以让这些执行流同时访问临界资源的不同区域,此时不会出现数据不一致等问题。
综上,引出了信号量:
信号量:也叫做信号灯,其本质就是一个计数器,是描述临界资源中资源数目的多少,信号量能够更细粒度的对临界资源进行管理。
信号量的PV
操作:
P
操作:我们将申请信号量称为P
操作,申请信号量的本质就是申请获得临界资源中某块资源的使用权限,当申请成功时临界资源中资源的数目应该减一,因此,P
操作的本质就是让计数器减一。V
操作:我们将释放信号量称为V
操作,释放信号量的本质就是归还临界资源中某块资源的使用权限,当释放成功时临界资源中资源的数目就应该加一,因此,V
操作的本质就是让计数器加一。
另外, 每个执行流在进入临界区之前都应该先申请信号量,只有成功申请到信号量的线程才可以对特定的临界资源具有操作权限,当操作完毕后就应该释放信号量。
而多个执行流为了访问临界资源会竞争式的申请信号量,因此信号量是会被多个执行流同时访问的,也就是说信号量本质也是临界资源。但信号量本质就是用于保护临界资源的,我们不可能再用信号量去保护信号量。所以,信号量的PV
操作必须是原子操作。
注意: 内存当中变量的++
、--
操作并不是原子操作,因此信号量不可能只是简单的对一个全局变量进行++
、--
操作。
比如我们可以把阻塞队列切割成N
份,初始化信号量的值为N
,当某一份资源就绪时,信号量--
,资源被释放后,信号量++
,如此一来可以像条件变量一样实现同步。
- 当
信号量 == N
时,阻塞队列已经满了,生产者无法生产。 - 当
信号量 == 0
时,阻塞队列已经空了,消费者无法消费。
另外,信号量不止可以用于同步,也可以用于互斥。在上面我们把阻塞队列切割为N
份,那么现在我们可以将阻塞队列整体看成1
份,即信号量的值设为 1
。
-
当
信号量 == 1
时,表示只有一个线程可以访问阻塞队列,对应操作信号量--
。其他线程将会被阻塞,直到这个线程释放资源,对应操作信号量++
。 -
当
信号量 == 0
时,表示队列正被一个线程占用,其他线程需要等待。
以上用来实现 互斥和同步的信号量都只有两种状态,像这种只有两种状态的信号量称为二元信号量。
- 在实现 互斥和同步 时,该如何选择?
结合业务场景进行分析,如果待操作的共享资源是一个整体,比较适合使用 互斥锁+条件变量 的方案。但如果共享资源是多份资源,使用 信号量 就比较方便。
另外,其实信号量的工作机制类似于买电影票,是一种预订机制,只要你买到票了,即使你晚点到达电影院,你的位置也始终可用,买到票的本质是将对应的座位进行了预订。因此,申请信号量实际是一种资源预订机制。
当执行流在申请信号量时
-
如果申请成功,那么执行流就一定可以访问临界资源。如果将信号量实际带入我们之前写的生产者消费者模型代码中,判断临界资源是否就绪是不需要写在临界区中,因为信号量本身就已经是资源的计数器了,即申请信号量时,其实间接的在做判断了。
-
如果此时信号量的值为
0
,也就是说信号量描述的临界资源已经全部被申请了,此时该执行流就应该在该信号量的等待队列当中进行等待,直到有信号量被释放时再被唤醒。
注意: 信号量的本质是计数器,但不意味着只有计数器,信号量还包括一个等待队列。
5.2 信号量相关操作
有了之前互斥锁、条件变量的使用基础,信号量的接口学习是释放简单的,依旧是只有四个接口:初始化、销毁、申请、释放。
注意:使用信号量相关函数需要包含头文件#include <semaphore.h>
- 初始化
#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);
说明:
sem
: 指向sem_t
结构体的指针,用于表示信号量。pshared
: 指示信号量是否在进程间共享。0
表示信号量在同一进程的线程间共享,非零
表示信号量在不同进程间共享。value
: 初始化信号量的值,即计数器。- 返回值:初始化成功返回
0
,失败返回-1
,并设置errno
以指示错误类型。。
- 销毁
#include <semaphore.h>
int sem_destroy(sem_t *sem);
说明:
sem
: 指向要销毁的sem_t
结构体的指针。- 返回值:成功返回
0
,失败返回-1
并设置errno
以指示错误类型。
使用注意事项:
-
销毁前: 在调用
sem_destroy
之前,所有使用该信号量的线程或进程应该已经完成了对信号量的使用,并且该信号量不应该再被其他线程或进程使用。 -
共享信号量: 如果信号量在多个进程间共享,则所有进程都需要在销毁信号量之前结束对它的使用。
-
线程安全: 在多线程环境中,确保没有其他线程正在操作该信号量时调用
sem_destroy
。
销毁信号量的主要目的是释放相关的系统资源。在销毁信号量后,它不能再用于同步或互斥操作。
- 等待信号量(申请信号量)
#include <semaphore.h>
int sem_wait(sem_t *sem);
说明:
sem
: 表示从哪个信号量中申请。- 功能:申请到信号量,就会将其信号量减一,其实就是
P
操作。如果信号量的计数已经是0
,它会阻塞调用线程。 - 返回值:成功返回
0
,失败返回-1
并设置errno
以指示错误类型。常见的错误包括EINVAL
(信号量无效)或EINTR
(操作被信号中断)
- 发布信号量(释放信号量)
#include <semaphore.h>
int sem_post(sem_t *sem);
说明:
-
参数:将资源释放到哪个信号量中。
-
功能:当成功调用这个函数,表明资源使用完毕,可以归还资源,即将信号量的值加一,本质就是
V
操作。 -
返回值:成功返回
0
,失败返回-1
,并设置错误码。
接下来直接用信号量实现生产者消费者模型。
5.3 实现基于环形队列的生产消费模型
5.3.1 环形队列
生产者消费者模型中的交易场所是可更换的(任意的数据结构),不仅可以使用阻塞队列,还可以使用环形队列。
环形队列可以使用一个用固定大小的数组来实现的队列数据结构,它的特点是可以高效地利用空间,并且支持循环操作。环形队列的判空和判满操作不同于传统的队列。
环形队列如何判空和判满呢?
- 对于判空的情况:生产者
tail
和消费者head
指向的是同一个位置。 - 而对于判满的情况,我们可以模拟一遍,生产者先在空格上放元素,再让生产者tail走到下一个位置,最后模拟完我们发现,生产者
tail
和消费者head
还是指向的是同一个位置。那么我们就无法判断是空还是满。
为了区分这两种状态,通常有以下二种方案:
-
方案一:使用一个计数器。当计数器的值为
0
时,表示当前为空,当计数器的值为数组大小时,表示队列为满。 -
方案二:多开一个空间。即让
head
和tail
之间始终保持一个空位,以此区分满和空状态。当head
和tail
指向同一个空间时,表示当前队列为空;对于判满的话,先对下一个位置做判断,即如果(tail + 1) % queue.size() == head
说明队列为满,否则生产数据后tail++
。
这两种策略都可以确保环形队列正确判空和判满,至于这里肯定是选择方案一,因为信号量本身就是一个天然的计数器。
在环形队列中,生产者和消费者关心的资源不一样:
- 生产者只关心是否有空间放数据。
- 消费者只关心是否能从空间中取到数据。
因此,除非两者相遇,即只要二者访问环形队列中不同的数据块,那么可以达到生产的同时可以消费,消费的同时可以生产,即生产者、消费者可以并发运行(同时访问环形队列)。因为它们没有访问同一个临界资源,不会造成并发问题。
两者错位时正常进行生产消费就好了,但两者相遇时需要特殊处理,也就是处理 空、满 两种情况,这就是 环形队列 的运转模式。
接下来引入一个小游戏,来辅助理解基于环形队列的单生产单消费模型的运作模式。
假设存在一个大圆桌,上面摆放了一圈空盘子,现在“我”和“你”打算玩一个游戏,“我”不断往盘子上放苹果,而“你”不断往盘子拿苹果。注意:一个盘子只能有一个苹果。
游戏基本规则:
- 指向同一个位置时,不能同时访问,即只能一个人访问。这也正常,多执行流访问同一块资源会导致数据不一致问题。
- “你”不能超过我,即拿苹果的人不能超过放苹果的人。
- 双方不能套圈。
游戏开始时,我和你同处在同一个地方,根据第一条游戏规则,双方不能同时进行苹果拾取/苹果放置,此时双方都被阻塞了。根据常识,如果想要运转起来,必须是放苹果的人先访问,然后拿苹果的人才能开始拿嘛。
所以可以得出结论:环形队列为空时,生产者和消费者相遇,生产者需要先生产数据,消费者阻塞。
想象这个场景,“我”不断往盘子上放苹果,然后“你”跟在我的后面拿,是不是双方都能同时对圆桌中的格子进行操作,这效率是不是非常的高。所以,环形队列不为空、不为满时,生产者、消费者可以同时进行并发操作。
游戏刚开始不久,“你”的女神在旁边观赛,而你的目光也被她吸引了,这时拾取苹果的速度不断减慢,而“我”见状火力全开,不断放置苹果,很快“我”就追上“你”了,此时我们两个相遇了,场上已经摆满了苹果,而规定一个盘子只能放置一个苹果,那么“我”无法在放置苹果,只能阻塞等待“你”进行苹果拾取操作。
所以可以提出结论:环形队列为满的情况,生产者和消费者相遇,生产者不能再生产,消费者需要进行消费。
当“你”从理想世界回到现实世界后,“你”开始一直拿苹果,注意“我”此时一直站在原地,当“你”和“我”相遇的时候,此时桌上的盘子都没有苹果,那这不就又回到了一开始的状态。
游戏到这里就可以结束了,因为已经足够总结出 环形队列 的运作模式了
- “我” -> 生产者
- “你” -> 消费者
- 大圆桌 -> 环形队列
- 空盘 -> 无数据,可生产
- 苹果 -> 有数据,可消费
运作模式
- 环形队列为空时,生产者和消费者相遇,此时消费者阻塞(没有数据可拿),只能由生产者进行生产,生产完后,消费者可以消费。即队列为空,一定是生产者先执行。
- 环形队列为满时,生产者和消费者相遇,此时生产者阻塞(空间不够),只能由消费者进行消费,消费完商品后,生产者可以生产商品。即队列为满,一定是消费者先执行。
- 总之,生产者只有在空和满时相遇。其他情况下,生产者、消费者可以并发运行,各干各的事,互不影响。
接下来我们将信号量和环形队列的运作模式相结合
可以使用 信号量来标识资源的使用情况,由于生产者和消费者关注的资源并不相同,所以需要使用两个信号量来进行操作:
- 生产者信号量:生产者更关注还有多少空间可以生产数据,即标识当前有多少可用空间。
- 消费者信号量:消费者更关注还有多少数据可以进行消费,即标识当前有多少数据。
显然,刚开始的时候,生产者信号量初始值为环形队列的大小N
,消费者信号量初始值为0
。
如果说搞两个条件变量 是阻塞队列的精髓,那么搞两个信号量就是环形队列的精髓。
生产者、消费者对于信号量的申请伪代码:
// 无论是生产者还是消费者
// 只有申请到自己的信号量资源后
// 才进行生产/消费
// 生产者
void Producer()
{
// 申请信号量。申请成功信号量-1
// 即可用空间 - 1
sem_wait(&psem);
// 生产商品
// ...
// 释放信号量,释放成功信号量+1
// 消费者信号量即可用数据 + 1
sem_post(&csem);
}
// 消费者
void Consumer()
{
// 申请信号量(可用数据 - 1)
sem_wait(&csem);
// 消费商品
// ...
// 释放信号量(可用空间 + 1)
sem_post(&psem);
}
5.3.2 单生产单消费模型
RingQueue.hpp
#pragma once
#include <iostream>
#include <vector>
#include <semaphore.h>
template <class T>
class RingQueue
{
public:
RingQueue()
{}
RingQueue(int Default_cap = 5)
: ringQueue(Default_cap), _cap(Default_cap), c_step(0), p_step(0)
{
sem_init(&c_sem, 0, 0);
sem_init(&p_sem, 0, _cap);
}
~RingQueue()
{
sem_destroy(&c_sem);
sem_destroy(&p_sem);
}
// 生产数据
void push(const T &in)
{
// 1. 先申请信号量资源 - P操作
sem_wait(&p_sem);
// 2. 生产数据
ringQueue[p_step] = in;
// 3. 数据多了一个 - V操作
sem_post(&c_sem);
// 维持环形特征
p_step++;
p_step %= _cap;
}
// 消费数据
void Pop(T *out)
{
// 1. 先申请信号量资源 - P操作
sem_wait(&c_sem);
// 2. 消费数据
*out = ringQueue[c_step];
// 3. 空间多了一个 - V操作
sem_post(&p_sem);
// 维持环形特征
c_step++;
c_step %= _cap;
}
private:
std::vector<T> ringQueue; // 数组模拟环形队列
int _cap; // 环形队列的容量
int c_step; // 消费者下标
int p_step; // 生产者下标
sem_t c_sem; // 消费者关注的数据资源
sem_t p_sem; // 生产者关注的空间资源
};
main.cc
#include "RingQueue.hpp"
#include <unistd.h>
#include <ctime>
using namespace std;
// 消费者
void *Consumer(void *args)
{
RingQueue<int> *rq = static_cast<RingQueue<int> *>(args);
while (true)
{
// 1. 消费数据
int data = 0;
rq->Pop(&data);
// 2. 处理数据
cout << "我是消费者, 我消费的数据是: " << data << endl;
sleep(1);
}
}
// 生产者
void *Producter(void *args)
{
// 生产者休眠3秒,观察是否会出现同步现象
sleep(3);
RingQueue<int> *rq = static_cast<RingQueue<int> *>(args);
int data = 0;
while (true)
{
// 1. 获取数据
int data = rand() % 10 + 1;
// 2. 生产数据
rq->push(data);
cout << "我是生产者,我生产的数据是: " << data << endl;
sleep(1);
}
return 0;
}
int main()
{
// 随机数种子
srand(time(nullptr));
// 阻塞队列
RingQueue<int> *rq = new RingQueue<int>();
// c - 消费者线程
// p - 生产者线程
pthread_t c, p;
// 生产者线程和消费者线程需要看到同一个环形队列
pthread_create(&c, nullptr, Consumer, (void *)rq);
pthread_create(&p, nullptr, Producter, (void *)rq);
// 主线程负责等待cp线程
pthread_join(c, nullptr);
pthread_join(p, nullptr);
return 0;
}
以上真的会出现同步现象吗?为了验证,我们可以将生产者休眠3
秒。那么消费者也会休眠3
秒,因为没有数据,无法消费。
或者我们可以先让生产一批数据,然后消费者再依次消费。
【程序结果】
以上单生产单消费模型并没有使用锁,那么为什么多执行流在并发访问时不会出现数据不一致的问题呢?
因为只有当生产者和消费者指向同一个位置并访问时,才会导致数据不一致的问题,而此时生产者和消费者在对环形队列进行写入或读取数据时,只有两种情况会指向同一个位置:
- 环形队列为空时。
- 环形队列为满时。
但是在这两种情况下,生产者和消费者不会同时对环形队列进行访问:
- 当环形队列为空的时,消费者一定不能进行消费,因为此时数据个数的信号量为
0
。 - 当环形队列为满的时,生产者一定不能进行生产,因为此时空间资源的信号量为
0
。
也就是说,当环形队列为空和满时,我们已经通过信号量保证了生产者和消费者的串行化过程。而除了这两种情况之外,生产者和消费者指向的都不是同一个位置,因此该环形队列当中不可能会出现数据不一致的问题。并且大部分情况下生产者和消费者指向并不是同一个位置,因此大部分情况下该环形队列可以让生产者和消费者并发的执行。
5.3.3 多生产多消费模型
根据321
原则,多生产多消费无非就是增加了 消费者与消费者、生产者与生产者 间的 互斥 关系,加锁就行了,为什么呢?
比方说:在多个生产者线程同时调用push
函数的场景中,虽然sem_wait
本身是原子操作,但它的作用仅是保证信号量的计数正确。信号量仅用于控制对临界资源的访问,并不直接防止多个线程同时访问同一个临界资源。那么这就会导致数据不一致问题。消费者和消费者之间也是同样的道理。
那么,现在问题是需要几把锁?
答案是两把。因为所以的生产者只关注空间,而所有的消费者则关注是否有数据,所以需要给生产者和消费者各配一把锁。
- 阻塞队列 中为什么只需要一把锁?
因为阻塞队列中的共享资源是一整个队列,生产者和消费者访问的是同一份资源,所以一把锁就够了
#pragma once
#include <iostream>
#include <vector>
#include <semaphore.h>
#include <pthread.h>
template <class T>
class RingQueue
{
public:
RingQueue()
{}
RingQueue(int Default_cap = 5)
: ringQueue(Default_cap), _cap(Default_cap), c_step(0), p_step(0)
{
sem_init(&c_sem, 0, 0);
sem_init(&p_sem, 0, _cap);
pthread_mutex_init(&c_mutex, nullptr);
pthread_mutex_init(&p_mutex, nullptr);
}
~RingQueue()
{
sem_destroy(&c_sem);
sem_destroy(&p_sem);
pthread_mutex_destroy(&c_mutex);
pthread_mutex_destroy(&p_mutex);
}
// 生产数据
void push(const T &in)
{
// 1. 先申请信号量资源 - P操作
sem_wait(&p_sem);
// 加锁
pthread_mutex_lock(&p_mutex);
// 2. 生产数据
ringQueue[p_step] = in;
// 维持环形特征
p_step++;
p_step %= _cap;
// 解锁
pthread_mutex_unlock(&p_mutex);
// 3. 数据多了一个 - V操作
sem_post(&c_sem);
}
// 消费数据
void Pop(T *out)
{
// 1. 先申请信号量资源 - P操作
sem_wait(&c_sem);
// 加锁
pthread_mutex_lock(&c_mutex);
// 2. 消费数据
*out = ringQueue[c_step];
// 维持环形特征
c_step++;
c_step %= _cap;
// 解锁
pthread_mutex_unlock(&c_mutex);
// 3. 空间多了一个 - V操作
sem_post(&p_sem);
}
private:
std::vector<T> ringQueue; // 数组模拟环形队列
int _cap; // 环形队列的容量
int c_step; // 消费者下标
int p_step; // 生产者下标
sem_t c_sem; // 消费者关注的数据资源
sem_t p_sem; // 生产者关注的空间资源
pthread_mutex_t c_mutex; // 消费者的锁
pthread_mutex_t p_mutex; // 生产者的锁
};
现在有一个疑问:加锁操作是写在信号量申请之前还是之后,写在之前肯定是没有问题的,顺带将信号量给保护起来了;单写在之后好像也没啥毛病,因为申请信号量操作本身就是原子的。
很明显,加锁操作写在信号量申请操作之后是最好的。
- 站在技术角度:因为我们说过,加锁的原则是尽量保证临界区代码越少越好。申请信号量操作本身就是原子的,没必要加锁保护。
- 站在逻辑角度:如果先申请锁,只有成功获得锁的线程才能继续申请信号量,因此锁的申请和信号量的申请时间是串行的。也就是说,线程必须先获得锁才能申请信号量;但如果信号量的申请在加锁之前进行,线程可以在等待信号量时同时竞争锁资源。这样,当一个线程成功申请到信号量并获得锁时,它可以立即执行临界区的操作,而其他线程则继续尝试申请信号量。这种做法使得申请锁和申请信号量的时间可以并行进行,从而提高系统的并发度。
main.cc
#include "RingQueue.hpp"
#include <unistd.h>
#include <ctime>
#include <string>
using namespace std;
struct ThreadData
{
RingQueue<int> *rq;
string threadname;
};
// 消费者
void *Consumer(void *args)
{
ThreadData *td = static_cast<ThreadData *>(args);
RingQueue<int> *rq = td->rq;
string name = td->threadname;
while (true)
{
// 1. 消费数据
int data = 0;
rq->Pop(&data);
// 2. 处理数据
cout << "我是消费者, 我消费的数据是: " << data << " who: " << name << endl;
sleep(1);
}
}
// 生产者
void *Producter(void *args)
{
ThreadData *td = static_cast<ThreadData *>(args);
RingQueue<int> *rq = td->rq;
string name = td->threadname;
while (true)
{
// 1. 获取数据
int data = rand() % 10 + 1;
// 2. 生产数据
rq->push(data);
cout << "我是生产者,我生产的数据是: " << data << " who: " << name << endl;
// sleep(1);
}
return 0;
}
// 单生产单消费模型
int main()
{
// 随机数种子
srand(time(nullptr));
// 阻塞队列
RingQueue<int> *rq = new RingQueue<int>();
// c - 消费者线程
// p - 生产者线程
pthread_t c[5], p[3]; // 5个消费者 3个生产者
for (int i = 0; i < 3; i++)
{
ThreadData *td = new ThreadData;
td->rq = rq;
td->threadname = "Productor-" + to_string(i);
// 生产者线程和消费者线程需要看到同一个环形队列
pthread_create(p + i, nullptr, Producter, (void *)td);
}
for (int i = 0; i < 5; i++)
{
ThreadData *td = new ThreadData;
td->rq = rq;
td->threadname = "Consumer-" + to_string(i);
pthread_create(c + i, nullptr, Consumer, (void *)td);
}
// 主线程负责等待cp线程
for (int i = 0; i < 3; i++)
{
pthread_join(p[i], nullptr);
}
for (int i = 0; i < 5; i++)
{
pthread_join(c[i], nullptr);
}
return 0;
}
【程序结果】
当然了,环形队列的数据块的数据类型也可以是类类型
main.cc
#include "RingQueue.hpp"
#include "Task.hpp"
#include <unistd.h>
#include <ctime>
#include <string>
using namespace std;
struct ThreadData
{
RingQueue<Task> *rq;
string threadname;
};
// 消费者
void *Consumer(void *args)
{
ThreadData *td = static_cast<ThreadData *>(args);
RingQueue<Task> *rq = td->rq;
string name = td->threadname;
while (true)
{
// 1. 消费数据
Task t;
rq->Pop(&t);
// 2. 处理数据
t.GetRusult();
cout << "我是消费者, 我获取的任务是: " << t.GetTask()
<< " who: " << name
<< " 结果是: " << t.GetRusult() << endl;
sleep(1);
}
}
// 生产者
void *Producter(void *args)
{
int len = oper.size();
ThreadData *td = static_cast<ThreadData *>(args);
RingQueue<Task> *rq = td->rq;
string name = td->threadname;
while (true)
{
// 1. 获取数据
int data1 = rand() % 10 + 1;
usleep(10);
int data2 = rand() % 10 + 1;
char op = oper[rand() % len];
Task t(data1, data2, op);
// 2. 生产数据
rq->push(t);
cout << "我是生产者,我生产的任务是: " << t.GetTask() << " who: " << name << endl;
// sleep(1);
}
return 0;
}
int main()
{
// 随机数种子
srand(time(nullptr));
// 阻塞队列
RingQueue<Task> *rq = new RingQueue<Task>();
// c - 消费者线程
// p - 生产者线程
pthread_t c[5], p[3]; // 5个消费者 3个生产者
for (int i = 0; i < 3; i++)
{
ThreadData *td = new ThreadData;
td->rq = rq;
td->threadname = "Productor-" + to_string(i);
// 生产者线程和消费者线程需要看到同一个环形队列
pthread_create(p + i, nullptr, Producter, (void *)td);
}
for (int i = 0; i < 5; i++)
{
ThreadData *td = new ThreadData;
td->rq = rq;
td->threadname = "Consumer-" + to_string(i);
pthread_create(c + i, nullptr, Consumer, (void *)td);
}
// 主线程负责等待cp线程
for (int i = 0; i < 3; i++)
{
pthread_join(p[i], nullptr);
}
for (int i = 0; i < 5; i++)
{
pthread_join(c[i], nullptr);
}
return 0;
}
【程序结果】
5.4 相关代码
实现基于环形队列实现生产者消费者模型的相关代码:点击跳转
六、总结
特性 | 环形队列(Circular Buffer) | 阻塞队列(Blocking Queue) |
---|---|---|
数据结构 | 固定大小的数组,首尾相连形成环。 | 可以是链表或动态数组实现。 |
容量 | 固定大小,初始化时设定。 | 可以是固定大小,也可以动态调整(取决于具体实现)。 |
入队操作 | 通过循环索引添加元素,时间复杂度O(1)。 | 如果队列满,生产者线程会被阻塞,直到有空间可用。 |
出队操作 | 通过循环索引移除元素,时间复杂度O(1)。 | 如果队列空,消费者线程会被阻塞,直到有元素可用。 |
性能 | 高效的O(1)时间复杂度,因不需要移动元素。 | 可能有性能开销,由于线程阻塞和唤醒机制。 |
同步机制 | 需要额外的同步机制(如互斥锁)来处理并发。 | 提供内建的线程同步机制,简化多线程编程。 |
适用场景 | 生产者和消费者速率接近,固定大小缓冲区,高效循环访问。 | 需要灵活的容量调整和自动线程同步的场景。 |
处理容量 | 处理容量固定,可能导致空间浪费或不足。 | 处理容量灵活,可以动态调整,适应不同负载需求。 |
实现复杂性 | 实现简单,但需处理队列满或空的逻辑。 | 实现复杂,需处理线程的阻塞和唤醒,可能增加编程复杂度。 |