目录
举例 -- 超市
介绍
概念
2种角色
1个交易场所
3种关系
生产者之间
消费者之间
生产者和消费者
关系
互相等待
阻塞队列
介绍
模拟实现 -- 基础版
思路
代码
pthread_cond_wait的第二个参数为什么是把锁
伪唤醒问题
介绍
代码
示例
优点
引入
介绍
模拟实现 -- 进阶版
增加生产/消费规则
生产任务(随机)
思路
代码
示例
生产任务(从键盘读入)
代码
示例
锁的封装(RAII风格)
思路
代码
模拟实现 -- 多生产多消费
代码
优点
说到消费场所,我们首先能想到的就是超市
- 超市是一个综合性比较强的交易场所,我们可以在里面买到各种商品
那商品从哪来呢?难道是超市自己生产的吗?
- 自然不是
- 是由各个供货商提供
- 也就是说,供货商提供商品给超市,消费者在超市拿到商品
那为什么消费者一定要在超市消费呢?
- 如果不在超市,那么对应的就要在供货商处消费 -- 消费者需要自行去各个厂家购买相应产品
- 首先就是消费者自己的效率问题,如果要买多种商品,就要去多个厂家
- 其次是厂家的效率问题,来一个消费者才生产一件货物(不然可能会产生货物积压的问题)
- 而且,厂家的工作时间不一定是消费者的闲暇时间
所以,超市存在的意义就是解决上述问题
- 它可以一次性向厂家要很多货物,放在超市里,等待顾客自行挑选
- 并且,可以摆放多个货物,消费者只需要在超市这一个场所中,就可以拿到不同货物
- 它可以协调生产者和消费者的忙闲时间
超市就可以看作是生产消费者模型的一种实际应用
概念
- 生产者-消费者模型(Producer-Consumer Model)是一种并发计算模型,用于解决多线程或多进程之间的协作和数据共享问题
- 其中生产者负责生成数据或任务,而消费者则负责处理这些数据或任务
- 模型的目标是协调和同步生产者和消费者的活动,以确保数据被正确处理且不发生冲突
我们可以将生产消费者模型概括为下面三个原则:
2种角色
- 自然是生产者和消费者
- 在计算机中可以看作是两个线程 / 多个线程(因为可能会存在多个生产者/消费者)
1个交易场所
- 也就是超市充当的角色
- 在计算机中是一块共享的缓冲区 / 一种数据结构,是两个角色都可以访问到的一块空间
- 一个生成数据或任务,将其放入共享的缓冲区中 ; 一个从共享缓冲区中取出数据或任务,并进行相应的处理
生产者之间
首先,因为可能存在多个生产者,自然需要维护生产者和生产者的关系
- 也就是互斥关系/竞争关系
- 同一个位置,只能有一个生产者的货物放上去
- 是不是和之前说的[只能有一个线程访问临界资源]很相似?
- 所以对应的,我们需要对生产的过程加锁保护
消费者之间
和生产者之间的关系类似
- 如果有多个消费者,他们之间也是互斥/竞争关系
- 虽然我们平时没有见到在超市里有争夺的现象,但那是因为超市里的资源多
- 如果全超市只剩下一包泡面,就会抢起来了(因为只有一个人可以拿到那包泡面)
- 在现实生活中,这样的前提是自然存在的 ; 但计算机中,需要用代码去实现
- 所以,为了保证一个货物只有一个消费者去消费,就要进行加锁保护噜
关系
这是最重要的关系了,毕竟可能存在只有一个消费者,一个生产者的情况,那上面两种关系就不存在了
- 生产者和消费者之间会存在互斥关系
- 毕竟,你不能在生产者正在生产时,去试图拿取东西吧 (你无法确定此时是否生产完成,自然拿取行为也就是不确定的)
- 反过来也是一样的
- 所以我们要保证生产和消费的过程是原子的,也就是进行加锁保护(当然,这个已经在上面提到过了)
互相等待
- 除此之外,最重要的是,生产者和消费者是有一定顺序的
- 只有当生产者生产出东西了,消费者才能去消费
- 并且,缓冲区的情况也需要被考虑
- 当缓冲区为空时,消费者直到生产者放入数据后,才可以消费
- 当缓冲区满时,生产者直到消费者取出数据后,才可以生产
- 而双方互相等待对方的行为,是不是很熟悉?很像管道通信!
- 如果将生产者和消费者各自看作一个线程,东西也就是数据
- 那么一方生产,一方消费,不就是双方在进行单向通信吗?
那消费者和生产者如何得知缓冲区的情况呢?
- 必须得访问吧
- 那就出现了之前介绍过的,抢票前需要先确定票是否就绪的问题
- 因为不知道何时就绪,线程就需要不断访问临界资源
- 但这是一种不必要的行为,因为我们可以引入条件变量来解决
- 当票就绪时,让修改资源的线程通知到等待的线程即可
- 这里也是一样
- 消费者需要货物,只有生产者最清楚是否有货物(因为货物就是他生产出来的)
- 生产者需要确定它此时是否可以生产,也就是需要空位,只有消费者最清楚是否有空位(因为空位就是它消费导致的)
- 所以,让他俩互相通知对方即可,而这样也就自然形成了顺序
介绍
- 在多线程编程中,阻塞队列(Blocking Queue)是一种常用于实现生产者和消费者模型的数据结构
- 其与普通的队列区别在于"阻塞"功能
- 当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素 ;当队列满时,往队列里存放元素的操作也会被阻塞,直到有元素被从队列中取出
- (以上的操作都是基于不同的线程来说的,线程在对阻塞队列进程操作时会被阻塞)
我们将阻塞队列封装成一个类
- 对于队列来说,核心操作就是push和pop
- 队列我们使用stl中的queue
- 除此之外,需要一把锁,用来确保生产和消费不会互相影响
- 并且,为了提高效率,我们使用两个条件变量(分别对应两个等待队列),生产者一个,消费者一个
- (如果只有一个的话,唤醒时就无法确定唤醒的是消费者还是生产者了)
实际的工作由类内函数实现,但分配任务/资源时,是分配给线程的
- 所以,创建线程时,可以直接传递类过去
- 使用时,进行调用即可
从前面的介绍也可以知道,在进行生产/消费前,需要确保资源满足某种条件
- 如果满足,就进行相应的处理
- 如果不满足,需要等待某个条件变量
- 而这个条件变量,是由对方改变的
- (比如,生产者需要确保此时有空间让它生产,而这个空间是由消费者消费产生的,所以当消费者消费完后,就可以通知生产者来生产了 ; 反过来也是一样的)
代码
#include
#include #include #include #include #include #include #include using namespace std; const int def_capacity = 10; template class BlockQueue { public: BlockQueue(int capacity = def_capacity) : capacity_(capacity) { pthread_mutex_init(&mutex_, nullptr); pthread_cond_init(&is_there_, nullptr); pthread_cond_init(&is_full_, nullptr); } ~BlockQueue() { pthread_mutex_destroy(&mutex_); pthread_cond_destroy(&is_there_); pthread_cond_destroy(&is_full_); } void push(const T &in) { // push需要保证和pop互斥,且队列不能满 pthread_mutex_lock(&mutex_); if (isfull()) { pthread_cond_wait(&is_full_, &mutex_); } bq_.push(in); // 生产完,就可以通知消费者 pthread_cond_signal(&is_there_); pthread_mutex_unlock(&mutex_); } void pop(T &out) // 和push同理 { pthread_mutex_lock(&mutex_); if (isempty()) { pthread_cond_wait(&is_there_, &mutex_); } out = bq_.front(); bq_.pop(); // 消费完,就可以通知生产者 pthread_cond_signal(&is_full_); pthread_mutex_unlock(&mutex_); } bool isfull() { return capacity_ == bq_.size(); } bool isempty() { return 0 == bq_.size(); } private: queue bq_; pthread_mutex_t mutex_; pthread_cond_t is_there_; pthread_cond_t is_full_; int capacity_; }; #include "BlockQueue.hpp" void *c_func(void *args) { //sleep(1); BlockQueue
*bq = (BlockQueue *)args; int data; while (true) { bq->pop(data); cout << "我消费了 : " << data << endl; } return nullptr; } void *p_func(void *args) { BlockQueue *bq = (BlockQueue *)args; int data = 1; while (true) { cout << "我生产了 : " << data << endl; bq->push(data++); } return nullptr; } void test1() { pthread_t tid1, tid2; BlockQueue *bq = new BlockQueue ; pthread_create(&tid1, nullptr, c_func, bq); pthread_create(&tid2, nullptr, p_func, bq); pthread_join(tid1, nullptr); pthread_join(tid2, nullptr); }
pthread_cond_wait的第二个参数为什么是把锁
- 从上面的代码可以看出来,我们的wait函数需要一个条件变量+一把锁,且这把锁就是函数此时所处的锁
为什么呢?
- 条件变量是为了指定线程要去哪个等待队列中等待
那锁呢?
- 还记得我们为什么要等待吗,是因为此时条件不满足,但不能让线程一直去访问
- 所以整出了条件变量,让线程去等待通知
- 那必然会有其他线程去修改临界资源(不修改怎么满足条件呢)
- 如果此时等待的线程还持有锁的话,其他线程就进不去临界区了,也就不存在修改,也就不会有通知
- 所以,必须要让陷入等待的线程先释放锁,再等待
- 等其他线程使临界资源就绪后,就可以通知正在等待的线程,让他们继续执行
- 并且,他们会重新持有锁,因为继续执行的位置必然在锁范围内,要是没有锁,就不合理了
- 所以,wait函数需要知道,他此时在哪个锁的范围内,有了锁,才可以进行解锁和加锁
介绍
- 我们原先的代码中,只要wait调用结束(也就是被消费者唤醒),就直接进行生产了
- 但是,只要是函数,就有调用失败的可能
- 如果此时wait调用失败,意味着在队列满了的情况下,消费者可能并没有进行消费
- 而此时直接进行push,就可能导致越界访问
- 所以,我们可以考虑将if改为while循环,保证当队列不满的情况下,才能执行push
- pop函数也是一样
代码
void push(const T &in) { // push需要保证和pop互斥,且队列不能满 pthread_mutex_lock(&mutex_); while (isfull()) { pthread_cond_wait(&is_full_, &mutex_); } bq_.push(in); // 生产完,就可以通知消费者 pthread_cond_signal(&is_there_); pthread_mutex_unlock(&mutex_); } void pop(T &out) // 和push同理 { pthread_mutex_lock(&mutex_); while (isempty()) { pthread_cond_wait(&is_there_, &mutex_); } out = bq_.front(); bq_.pop(); // 消费完,就可以通知生产者 pthread_cond_signal(&is_full_); pthread_mutex_unlock(&mutex_); }
如果不对生产和消费的过程进行限制,双方就会一直快速执行:
如果让生产先进行一会,随后再放出消费者,且消费者是慢于生产的:
void *c_func(void *args) { sleep(1); BlockQueue
*bq = (BlockQueue *)args; int data; while (true) { bq->pop(data); cout << "我消费了 : " << data << endl; sleep(2); } return nullptr; } void *p_func(void *args) { BlockQueue *bq = (BlockQueue *)args; int data = 1; while (true) { cout << "我生产了 : " << data << endl; bq->push(data++); } return nullptr; } 生产者会先生产一堆数据,随后消费者慢慢按照生产的顺序一个一个消费
如果生产慢一点:
消费者会等待生产者,生产一个,才能消费一个
- 其实,从上面的代码中,看不出来这样的设计有什么很大的作用
- 只是两个线程将某个数据运输过去了而已,并且还因为锁,必须等待对方完成,自己才能开始工作
- 这有什么意义呢?
- 实际上,是我们没有模拟出真实的情况
- 生产和消费数据都需要耗费时间,并不是简单的拷贝就完成了
- 数据的获取可能从网络中/第三方那里来,拿到数据后的处理也可能耗费一定时间
- 设计让生产者和消费者作为单独的线程来处理数据,就是为了让双方在对方进行前提事件/后续处理的时候,还可以继续生产/消费(也就是执行临界区代码),而不是等待一系列操作完成后,再进行生产/消费
- 也就是所谓的,协调了忙闲时间
- 这样可以提高程序的并发度,也就提高了效率
增加生产/消费规则
除了像上面那样,生产一个就可以消费一个,也可以制定某种规则
- 等数据>=2/3时,才会消费
- 等数据<=1/2时,才会生产
- 这些规则都可以随意制定,主要看场景如何
思路
- 既然要生产任务,我们首先要构建出一个任务
- 我们可以定义一个类,将任务需要的数据和方法都作为类成员,这样一个类就可以囊括多种任务
- 然后将这个类作为队列成员,生产时,只需要导入数据和方法即可
代码
task.hpp
#pragma once #include
#include #include using namespace std; //typedef function function_t; using function_t = function ; class Task { public: Task() {} // 方便只是为了接收传参而定义一个对象 Task(int x, int y, function_t func) : x_(x), y_(y), func_(func) { } int get_x() { return x_; } int get_y() { return y_; } private: int x_; int y_; function_t func_; }; 主文件
#include "BlockQueue.hpp" int func_add(int x, int y) { return x + y; } void *c_func(void *args) { BlockQueue
*bq = (BlockQueue *)args; while (true) { Task t; bq->pop(t); cout << "我消费了 : " << t.get_x() << " + " << t.get_y() << " = " << (t.get_x() + t.get_y()) << endl; } return nullptr; } void *p_func(void *args) { BlockQueue *bq = (BlockQueue *)args; while (true) { int x = rand() % 10 + 1; int y = rand() % 10 + 1; Task t(x, y, func_add); cout << "我生产了 : " << x << " + " << y << " = ? " << endl; bq->push(t); sleep(2); } return nullptr; } void test1() { srand(getpid() ^ (unsigned int)time(nullptr) ^ 0x1233412); pthread_t tid1, tid2; BlockQueue *bq = new BlockQueue ; pthread_create(&tid1, nullptr, c_func, bq); pthread_create(&tid2, nullptr, p_func, bq); pthread_join(tid1, nullptr); pthread_join(tid2, nullptr); }
示例
这样我们就可以自动将生成的随机数进行加法运算噜
除了随机生成,也可以自行导入数据
代码
void *p_func(void *args) { BlockQueue
*bq = (BlockQueue *)args; while (true) { int x, y; cout << "please enter x : "; cin >> x; cout << "please enter y : "; cin >> y; Task t(x, y, func_add); cout << "我生产了 : " << x << " + " << y << " = ? " << endl; bq->push(t); sleep(2); } return nullptr; }
示例
思路
- RAII风格主要是 -- 资源的获取和释放应该与对象的生命周期绑定
- 这种编程范式确保在对象生命周期结束时,资源的释放是自动进行的,从而降低了资源泄漏的风险
- 所以,我们将锁的加锁/解锁和类结合在一起,这样就不用手动操作了
代码
// RAII风格 -- 借助类的特点 class Lock_Guard { public: Lock_Guard(pthread_mutex_t *pmux) : pmux_(pmux) { pthread_mutex_lock(pmux_); // 类被定义时,就加锁 } ~Lock_Guard() { pthread_mutex_unlock(pmux_); // 出当前作用域后就销毁 } // 锁的初始化和销毁都在阻塞队列中完成了 public: pthread_mutex_t *pmux_; }; void push(const T &in) { // push需要保证和pop互斥,且队列不能满 Lock_Guard lock(&mutex_); // 被定义出来后,就会自动加锁 while (isfull()) { pthread_cond_wait(&is_full_, &mutex_); } bq_.push(in); // 生产完,就可以通知消费者 pthread_cond_signal(&is_there_); // 出这个函数后,会自动解锁 } void pop(T &out) // 和push同理 { Lock_Guard lock(&mutex_); // 被定义出来后,就会自动加锁 while (isempty()) { pthread_cond_wait(&is_there_, &mutex_); } out = bq_.front(); bq_.pop(); // 消费完,就可以通知生产者 pthread_cond_signal(&is_full_); // 出这个函数后,会自动解锁 }
代码
void test2() { srand(getpid() ^ (unsigned int)time(nullptr) ^ 0x1233412); pthread_t tid1[2], tid2[2]; BlockQueue
*bq = new BlockQueue ; pthread_create(tid1, nullptr, c_func, bq); pthread_create(tid1 + 1, nullptr, c_func, bq); pthread_create(tid2, nullptr, p_func, bq); pthread_create(tid2 + 1, nullptr, p_func, bq); pthread_join(tid1[0], nullptr); pthread_join(tid1[1], nullptr); pthread_join(tid2[0], nullptr); pthread_join(tid2[1], nullptr); } 可以看到,生产和消费各自有两个线程去执行:
- 也许你会觉得没有必要,因为加入更多的线程,会让锁资源的竞争更加激烈,调度成本也增加了
- 但其实只是让临界区的执行成本增加了点,在之外的区域效率还是变高了的
- 还记得前面我们说过的,获取数据和拿到数据后的处理都是会花费一定时间的,但这块区域是没有锁覆盖的
- 也就是说,这块区域允许多个执行流进入
- 那么,采用多生产/多消费,可以使多个执行流并发的完成这些工作,加速生产消费进程 -- 因为生产和消费是有一定顺序的,如果线程迟迟卡在其他工作上,会减少生产/消费的次数,进而拖累消费/生产
- 所以,多线程去执行,对于前序/后续工作耗费时间较长的任务来说,可以提高效率