Linux多线程——生产者消费者模型
目录
1、生产者消费者模型
1.1 什么是生产者消费者模型?
1.2 生产者消费者模型的特点
1.3 生产者消费者模型的优点
2. 基于阻塞队列实现生产者消费者模型
2.1 阻塞队列简介
2.2 单生产者单消费者模型
2.3 生产者和消费者线程
2.4 运行结果编辑
2.5 生产者消费者模型的优化
3. POSIX 信号量
3.1 信号量的基本知识
3.2 信号量的相关操作
4. 基于环形队列实现生产者消费者模型
4.1 环形队列
4.2 单生产单消费模型
4.3 多生产多消费模型
1、生产者消费者模型
1.1 什么是生产者消费者模型?
-
生产者消费者模型 用于解决生产者与消费者之间的强耦合问题,生产者和消费者不直接通信,而是通过一个容器(或缓冲区)来进行数据交换。
-
通过 超市 作为类比,超市充当缓冲区,工厂作为生产者,顾客作为消费者。顾客无需直接与工厂接触,也不需要去工厂采购商品,工厂与超市通过库存来交换商品。
-
超市作为缓冲区,允许生产和消费步调不一致,提高了效率。
1.2 生产者消费者模型的特点
-
生产者与生产者之间:互斥 — 生产者需要争夺有限的资源(商品)。
-
消费者与消费者之间:互斥 — 顾客争夺同样的商品时需要互相制约。
-
生产者与消费者之间:同步与互斥 — 当生产者生产商品时,如果库存已满,生产者阻塞;当库存为空时,消费者阻塞。
321原则:
-
3种关系:生产者与生产者、消费者与消费者、生产者与消费者之间的互斥与同步关系。
-
2种角色:生产者与消费者。
-
1个交易场所:通常是一个缓冲区(如阻塞队列、环形队列)。
1.3 生产者消费者模型的优点
-
解耦性:生产者和消费者的操作独立,生产者只关注是否有空间来生产,消费者只关注是否有数据可消费。
-
灵活性:可以根据不同的生产和消费策略灵活调整两者之间的协同关系,做到“忙闲不均”,即避免了生产和消费的步调完全一致,减少了资源浪费。
-
扩展性:通过解耦和合理设计,生产者、消费者和缓冲区可以灵活扩展,满足各种场景的需求。
总的来说,生产者消费者模型是一种高效、灵活且解耦的模型,适用于多线程环境中的数据共享和任务调度等场景。通过合理使用缓冲区和同步机制(如条件变量),可以提高系统的并发处理能力和效率
2. 基于阻塞队列实现生产者消费者模型
2.1 阻塞队列简介
在多线程编程中,生产者消费者问题是一个经典问题。为了实现生产者和消费者之间的同步与通信,我们可以采用阻塞队列(Blocking Queue)作为数据传输的媒介。阻塞队列是一个具有容量限制的队列,其特点是:当队列满时,生产者线程会被阻塞,直到消费者消费了数据;当队列空时,消费者线程会被阻塞,直到生产者生产了数据。
阻塞队列的基本操作与普通队列类似,依然遵循先进先出的(FIFO)原则。不同之处在于,阻塞队列的容量是有限的,且它可以为空或满,分别对应着消费者无法消费(当队列为空)和生产者无法生产(当队列为满)的情形。
通过阻塞队列,生产者和消费者线程能够有效协调工作,避免出现资源竞争的问题。
2.2 单生产者单消费者模型
首先,我们从一个简单的单生产者单消费者模型开始,构建一个简单的阻塞队列类。在实现之前,我们需要先了解一下阻塞队列的基本结构。
阻塞队列类设计
#pragma once#include <queue>
#include <mutex>
#include <pthread.h>namespace MyNamespace {#define MAX_SIZE 10 // 阻塞队列的最大容量template <typename T>
class BlockingQueue {
public:BlockingQueue(size_t cap = MAX_SIZE): capacity(cap) {// 初始化互斥锁和条件变量pthread_mutex_init(&mutex, nullptr);pthread_cond_init(&producer_cond, nullptr);pthread_cond_init(&consumer_cond, nullptr);}~BlockingQueue() {// 销毁互斥锁和条件变量pthread_mutex_destroy(&mutex);pthread_cond_destroy(&producer_cond);pthread_cond_destroy(&consumer_cond);}void Push(const T& item);void Pop(T* item);private:bool IsFull() const;bool IsEmpty() const;private:std::queue<T> queue;size_t capacity; pthread_mutex_t mutex; // 互斥锁pthread_cond_t producer_cond; // 生产者条件变量pthread_cond_t consumer_cond; // 消费者条件变量
};
}
Push 和 Pop 方法实现
生产者入队:
void Push(const T& item) {pthread_mutex_lock(&mutex); // 加锁// 阻塞直到队列有空位while (IsFull()) {pthread_cond_wait(&producer_cond, &mutex);}// 向队列中插入数据queue.push(item);// 唤醒消费者线程pthread_cond_signal(&consumer_cond);pthread_mutex_unlock(&mutex); // 解锁
}
消费者出队:
void Pop(T* item) {pthread_mutex_lock(&mutex); // 加锁// 阻塞直到队列中有数据while (IsEmpty()) {pthread_cond_wait(&consumer_cond, &mutex);}// 从队列中取出数据*item = queue.front();queue.pop();// 唤醒生产者线程pthread_cond_signal(&producer_cond);pthread_mutex_unlock(&mutex); // 解锁
}
判断队列是否为空或满:
bool IsFull() const {return queue.size() == capacity;
}bool IsEmpty() const {return queue.empty();
}
2.3 生产者和消费者线程
接下来,我们编写生产者和消费者线程,生产者每隔一段时间生产一个数据项,消费者则消费队列中的数据。
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include "BlockingQueue.hpp"void* Producer(void* args) {MyNamespace::BlockingQueue<int>* queue = static_cast<MyNamespace::BlockingQueue<int>*>(args);while (true) {int data = rand() % 100; // 生产数据queue->Push(data); // 将数据推入队列std::cout << "Producer produced: " << data << std::endl;sleep(1); // 模拟生产时间}pthread_exit(nullptr);
}void* Consumer(void* args) {MyNamespace::BlockingQueue<int>* queue = static_cast<MyNamespace::BlockingQueue<int>*>(args);while (true) {int data;queue->Pop(&data); // 从队列中取出数据std::cout << "Consumer consumed: " << data << std::endl;sleep(2); // 模拟消费时间}pthread_exit(nullptr);
}int main() {MyNamespace::BlockingQueue<int> queue;pthread_t producer_thread, consumer_thread;// 创建生产者和消费者线程pthread_create(&producer_thread, nullptr, Producer, &queue);pthread_create(&consumer_thread, nullptr, Consumer, &queue);// 等待线程结束pthread_join(producer_thread, nullptr);pthread_join(consumer_thread, nullptr);return 0;
}
2.4 运行结果
运行以上代码,您会看到生产者线程不断地生成数据并将其推入阻塞队列,而消费者线程不断地消费这些数据。由于队列的容量有限,当队列满时,生产者会被阻塞,直到消费者消费了一些数据;同样地,当队列为空时,消费者会被阻塞,直到生产者生产了数据。
2.5 生产者消费者模型的优化
在实际的生产环境中,生产者消费者模型不仅仅是简单地生产和消费数据,它的应用范围更广泛。通过合理地调整生产者和消费者的数量,或在队列中放入不同类型的任务,可以显著提高处理效率。
一种常见的优化方法是通过增加生产者和消费者线程的数量来提高并发处理能力。这样可以更高效地处理高频率的任务请求,减少单个生产者或消费者线程的瓶颈。
增加生产者和消费者线程
在某些场景下,我们需要处理大量的任务或数据,此时单一的生产者和消费者线程可能会变成系统的性能瓶颈。通过增加线程数量,特别是增加消费者线程,可以实现更高效的任务处理。
下面是一个优化后的代码示例,其中我们增加了多个生产者和消费者线程:
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <time.h>
#include "BlockingQueue.hpp"
#include "Task.hpp"void* Producer(void* args) {Yohifo::BlockQueue<Yohifo::Task<int>>* bq = static_cast<Yohifo::BlockQueue<Yohifo::Task<int>>*>(args);// 运算符集std::string opers = "+-*/%";while (true) {// 生产者每隔一秒生产一次sleep(1);// 随机生成两个数int x = rand() % 100;int y = rand() % 100;// 随机选择运算符char op = opers[rand() % opers.size()];// 生产任务Yohifo::Task<int> task(x, y, op);// 将任务推送到队列中bq->Push(task);std::cout << "Producer produced: " << x << " " << y << " " << op << std::endl;std::cout << "----------------------------" << std::endl;}pthread_exit(nullptr);
}void* Consumer(void* args) {Yohifo::BlockQueue<Yohifo::Task<int>>* bq = static_cast<Yohifo::BlockQueue<Yohifo::Task<int>>*>(args);while (true) {// 从队列中取出任务Yohifo::Task<int> task;bq->Pop(&task);// 执行任务task();std::string result = task.getResult();std::cout << "Consumer consumed a task, result: " << result << std::endl;std::cout << "============================" << std::endl;}pthread_exit(nullptr);
}int main() {srand((size_t)time(nullptr));// 创建一个阻塞队列Yohifo::BlockQueue<Yohifo::Task<int>>* bq = new Yohifo::BlockQueue<Yohifo::Task<int>>;// 创建多个生产者和消费者线程pthread_t pro[2], con[3];// 创建两个生产者线程for (int i = 0; i < 2; i++) {pthread_create(pro + i, nullptr, Producer, bq);}// 创建三个消费者线程for (int i = 0; i < 3; i++) {pthread_create(con + i, nullptr, Consumer, bq);}// 等待生产者线程完成for (int i = 0; i < 2; i++) {pthread_join(pro[i], nullptr);}// 等待消费者线程完成for (int i = 0; i < 3; i++) {pthread_join(con[i], nullptr);}// 清理队列delete bq;return 0;
}
小结
通过阻塞队列,我们能够实现生产者和消费者之间的高效同步与协调。生产者线程可以不必等待消费者,而是将任务交给阻塞队列;消费者线程则从队列中取任务并进行处理。使用条件变量和互斥锁,可以确保在多线程环境下数据的一致性和线程的安全。
3. POSIX 信号量
3.1 信号量的基本知识
互斥与同步不仅可以通过互斥锁和条件变量来实现,还可以通过信号量(sem
)和互斥锁来实现(这是POSIX标准的一部分)。
信号量的本质就是一个计数器。通过以下两种基本操作来管理资源:
-
申请到资源,计数器减1(P操作)
-
释放完资源,计数器加1(V操作)
信号量的P和V操作都是原子的。假设将信号量的值设为1,用来表示生产者消费者模型中的阻塞队列(_queue
)的使用情况:
-
当信号量的值为1时,线程可以进行生产或消费,并且信号量的值减1。
-
当信号量的值为0时,线程无法进行生产或消费,只能阻塞等待。
此时的信号量只有两种状态:0和1,这种信号量称为二元信号量,它实现了类似互斥锁的效果,即实现线程的互斥。
信号量不仅可以用于互斥,它的主要作用是描述临界资源的资源数量。例如,我们可以把阻塞队列分成N份,初始化信号量的值为N。当某一份资源就绪时,信号量减1,资源被释放时,信号量加1。通过这种方式,信号量可以像条件变量一样实现同步。
-
当信号量值为N时,阻塞队列为空,消费者无法消费。
-
当信号量值为0时,阻塞队列已满,生产者无法生产。
这种信号量用于实现互斥和同步时,称为多元信号量。
使用信号量的总结:
在使用多元信号量访问资源时,需要先申请信号量,只有申请成功才能进行资源访问,否则会进入阻塞等待,直到资源可用。
选择使用信号量的情境:
-
如果待操作的共享资源是一个整体,使用互斥锁+条件变量的方案较为合适。
-
如果共享资源是多份资源,使用信号量则更为方便。
信号量的工作机制类似于买电影票,它是一种预订机制。只要你成功购买了票,即使晚些到达,座位始终可用。买到票的本质是将对应的座位进行了预订。
对于信号量的第一层理解:申请信号量实际上是一种资源预订机制,只要申请成功,就可以访问临界资源。
3.2 信号量的相关操作
信号量的操作简单且高效,主要包括四个接口:初始化、销毁、申请、释放。
初始化信号量
#include <semaphore.h>// 初始化信号量
int sem_init(sem_t *sem, int pshared, unsigned int value);
参数1:需要初始化的信号量。sem_t
是一个联合体,包含了一个字符数组和一个long int
成员。
typedef union
{char __size[__SIZEOF_SEM_T];long int __align;
} sem_t;
-
参数2:表示当前信号量的共享状态。传递
0
表示线程间共享,非0
表示进程间共享。 -
参数3:信号量的初始值,可以设置为二元或多元信号量。
-
返回值:初始化成功返回
0
,失败返回-1
,并设置错误码。
销毁信号量
#include <semaphore.h>int sem_destroy(sem_t *sem);
-
参数:待销毁的信号量。
-
返回值:成功返回
0
,失败返回-1
,并设置错误码。
申请信号量(等待信号量)
#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);
-
主要使用
sem_wait
,表示从信号量中申请。 -
返回值:成功返回
0
,失败返回-1
,并设置错误码。 -
其他两种申请方式:
sem_trywait
尝试申请,如果没有申请到资源就放弃,sem_timedwait
每隔一段时间申请,超时则返回错误。
释放信号量(发布信号量)
#include <semaphore.h>int sem_post(sem_t *sem);
-
参数:将资源释放到哪个信号量中。
-
返回值:成功返回
0
,失败返回-1
,并设置错误码。
这些接口非常容易上手,一旦理解了它们的基本概念,就能轻松使用信号量。
4. 基于环形队列实现生产者消费者模型
4.1 环形队列
在生产者消费者模型中,交易场所是可以更换的,不仅可以使用阻塞队列,还可以使用环形队列。环形队列并非传统意义上的队列,而是通过数组模拟实现的“队列”,它的判空和判满机制比较特殊。
如何让环形队列“转”起来?
可以通过取模的方式(确保索引在一个固定范围内),从而决定数组的下标。
环形队列如何判断当前为空或满?
-
策略一:多开一个空间
通过多开一个空间,head
和tail
位于同一位置时,表示队列为空。当进行插入或获取数据时,操作的是下一块空间的数据。这样,head
指向的空间为队列满时的状态。 -
策略二:使用计数器
当计数器值为0时,表示队列为空;当计数器值为队列容量时,表示队列满。这种方式非常简单有效,适用于环形队列。
在环形队列中,生产者和消费者关注的资源不一样:生产者只关心是否有空间放数据,消费者只关心是否能从空间中取到数据。除非两者相遇,其他情况下生产者和消费者可以并发运行。
环形队列运作模式:
-
空队列时:生产者需要先生产数据,消费者阻塞。
-
满队列时:生产者阻塞,消费者进行消费。
-
其他情况:生产者和消费者可以并发运行。
辅助理解的游戏:
假设一个大圆桌上摆放了一圈空盘子,可以放苹果,也可以取苹果。张三和李四进行“苹果追逐赛”:张三(消费者)追逐李四(生产者)。当他们相遇时,生产者和消费者的状态就像队列为空或满时一样,阻塞和解锁是由信号量管理的。
通过这个游戏,我们可以总结出环形队列的运作模式:
-
环形队列为空时:消费者阻塞,生产者开始生产数据。
-
环形队列为满时:生产者阻塞,消费者进行消费。
-
其他情况:生产者和消费者可以并发进行。
4.2 单生产单消费模型
接下来实现单生产者、单消费者的生产者消费者模型,使用环形队列来管理数据。
环形队列类设计:
#pragma once#include <vector>
#include <semaphore.h>namespace Yohifo {
#define DEF_CAP 10template<class T>class RingQueue {public:RingQueue(size_t cap = DEF_CAP):_cap(cap), _pro_step(0), _con_step(0) {_queue.resize(_cap);// 初始化信号量sem_init(&_pro_sem, 0, _cap);sem_init(&_con_sem, 0, 0);}~RingQueue() {// 销毁信号量sem_destroy(&_pro_sem);sem_destroy(&_con_sem);}// 生产商品void Push(const T &inData) {// 申请信号量P(&_pro_sem);// 生产_queue[_pro_step++] = inData;_pro_step %= _cap;// 释放信号量V(&_con_sem);}// 消费商品void Pop(T *outData) {// 申请信号量P(&_con_sem);// 消费*outData = _queue[_con_step++];_con_step %= _cap;// 释放信号量V(&_pro_sem);}private:void P(sem_t *sem) {sem_wait(sem);}void V(sem_t *sem) {sem_post(sem);}private:std::vector<T> _queue;size_t _cap;sem_t _pro_sem; // 生产者信号量sem_t _con_sem; // 消费者信号量size_t _pro_step; // 生产者下标size_t _con_step; // 消费者下标};
}
在这个实现中,环形队列的大小是通过sem_init
来初始化的,生产者和消费者各自通过信号量来管理资源的访问。
生产者线程:
void* Producer(void *args) {Yohifo::RingQueue<int>* rq = static_cast<Yohifo::RingQueue<int>*>(args);while (true) {// 生产者慢一点sleep(1);// 生产商品(通过某种渠道获取数据)int num = rand() % 10;// 将商品推送至环形队列中rq->Push(num);std::cout << "Producer produced: " << num << std::endl;std::cout << "------------------------" << std::endl;}pthread_exit((void*)0);
}
消费者线程:
void* Consumer(void *args) {Yohifo::RingQueue<int>* rq = static_cast<Yohifo::RingQueue<int>*>(args);while (true) {// 从环形队列中获取商品int num;rq->Pop(&num);// 消费商品(结合某种具体业务进行处理)std::cout << "Consumer consumed: " << num << std::endl;std::cout << "------------------------" << std::endl;}pthread_exit((void*)0);
}
主程序:
int main() {srand((size_t)time(nullptr));// 创建一个环形队列Yohifo::RingQueue<int>* rq = new Yohifo::RingQueue<int>;// 创建两个线程(生产者、消费者)pthread_t pro, con;pthread_create(&pro, nullptr, Producer, rq);pthread_create(&con, nullptr, Consumer, rq);pthread_join(pro, nullptr);pthread_join(con, nullptr);delete rq;return 0;
}
4.3 多生产多消费模型
环形队列一定优于阻塞队列吗?
答案是否定的。环形队列并不总是优于阻塞队列,它们各自有不同的适用场景。实际上,如果环形队列能完全碾压阻塞队列,早就不再需要阻塞队列了。两者都属于生产者消费者模型中的常见交易场所,各自有其特定的使用场景。
以下是环形队列和阻塞队列的一些比较:
特征 | 阻塞队列(互斥锁实现) | 环形队列(信号量实现) |
---|---|---|
内部同步机制 | 使用互斥锁或类似的锁机制来实现线程安全 | 使用信号量来实现线程安全 |
阻塞操作 | 支持阻塞操作,当队列为空或已满时,线程可以等待 | 也支持阻塞操作,当队列为空或已满时,线程可以等待 |
数据覆盖 | 通常不会覆盖已有元素,新元素添加时需要等待队列有空间 | 有界的,当队列已满时,添加新元素会覆盖最早的元素 |
实现复杂度 | 实现可能较为复杂,需要处理锁的获取和释放 | 实现相对较简单,需要管理信号量 |
线程安全 | 通过锁来保证线程安全,容易引入死锁问题 | 通过信号量来保证线程安全,不易引入死锁问题 |
添加和删除操作时间复杂度 | O(1)(在队列未满或非空时) | O(1)(常数时间,除非队列已满或为空) |
应用场景 | 多线程数据传递,任务调度,广播通知等 | 循环缓存,数据轮询,循环任务调度等 |
适用场景分析
-
阻塞队列:适用于需要线程安全且不需要数据覆盖的场景,尤其在生产者和消费者之间传递数据时,通常使用互斥锁和条件变量来保证线程安全。这种队列不允许覆盖已有元素,操作简单,但也更容易因为锁的使用导致死锁等问题。
-
环形队列:适用于那些需要周期性地处理数据且允许覆盖最早数据的场景。它适合用于循环缓存或任务调度中,其中数据可以重复利用,而且对性能要求较高的环境中,环形队列的实现相对简单,可以减少锁的使用,避免死锁问题。
总结
-
环形队列和阻塞队列各有优势,在不同的场景下有不同的表现。
-
如果对资源的访问频繁且对数据覆盖容忍度较高,环形队列会有更好的性能表现。
-
如果需要确保资源的顺序性和不能覆盖数据,并且使用线程安全的机制,那么阻塞队列会更加合适。