【Linux】生产者消费者模型

👀樊梓慕:个人主页

 🎥个人专栏:《C语言》《数据结构》《蓝桥杯试题》《LeetCode刷题笔记》《实训项目》《C++》《Linux》《算法》

🌝每一个不曾起舞的日子,都是对生命的辜负


目录

前言

1.生产者消费者模型

1.1概念

 1.2特点

2.基于阻塞队列的生产者消费者模型

2.1什么是阻塞队列?

2.2queue模拟阻塞队列的生产消费模型

​编辑

3.POSIX信号量

3.1信号量是如何实现互斥和同步的?

3.2信号量的概念

3.3信号量函数

3.3.1初始化信号量sem_init

3.3.2销毁信号量sem_destroy

3.3.3等待信号量sem_wait(P操作)

3.3.4释放信号量sem_post(V操作) 

 4.基于环形队列的生产者消费者模型

4.1基本原理

4.2两个规则

4.2.1生产者和消费者不能对同一个位置进行访问

 4.2.2无论是生产者还是消费者,都不应该将对方套圈

4.3vector模拟环形队列的生产消费模型


前言

本篇文章内容:生产者消费者模型概述、基于阻塞队列BlockQueue的生产者消费者模型、POSIX信号量、基于环形队列的生产者消费者模型。


欢迎大家📂收藏📂以便未来做题时可以快速找到思路,巧妙的方法可以事半功倍。 

=========================================================================

GITEE相关代码:🌟樊飞 (fanfei_c) - Gitee.com🌟

=========================================================================


1.生产者消费者模型

1.1概念

生产者消费者模型就是通过一个容器来解决生产者和消费者的强耦合问题。

生产者和消费者彼此之间不直接通讯,而通过某种容器来进行通讯。

所以生产者生产完数据之后不用等待消费者处理,直接扔给容器,消费者不找生产者要数据,而是直接从容器里取,容器就相当于一个缓冲区,平衡了生产者和消费者的处理能力。这个容器就是用来给生产者和消费者解耦的。那么下面我们会介绍两种实现生产者消费者模型的方式:一种是基于阻塞队列的,一种是基于环形队列的。


 1.2特点

生产者消费者模型是多线程同步与互斥的一个经典场景,其特点如下:

  • 3种关系: 生产者和生产者(互斥关系)、消费者和消费者(互斥关系)、生产者和消费者(互斥关系、同步关系)。
  • 2种角色: 生产者和消费者。(通常由进程或线程承担)
  • 1个交易场所: 通常指的是内存中的一段缓冲区。(可以自己通过某种方式组织起来)

说明:

所有的生产者和消费者都会竞争式的申请锁,因此生产者和生产者、消费者和消费者、生产者和消费者之间都存在互斥关系。

同步的解释:

同步说白了就是让多线程能够协同起来工作,而不是不管不顾,比如生产者消费者模型场景下:

  • 如果让生产者一直生产,那么当生产者生产的数据将容器塞满后,生产者再生产数据就会生产失败。
  • 反之,让消费者一直消费,那么当容器当中的数据被消费完后,消费者再进行消费就会消费失败。
  • 虽然这样不会造成任何数据不一致的问题,但是这样会引起另一方的饥饿问题,是非常低效的。我们应该让生产者和消费者访问该容器时具有一定的顺序性,比如让生产者先生产,然后再让消费者进行消费。

注意: 互斥关系保证的是数据的正确性,而同步关系是为了让多线程之间协同起来。


2.基于阻塞队列的生产者消费者模型

2.1什么是阻塞队列?

在多线程编程中,阻塞队列(Blocking Queue)是一种常用于实现生产者和消费者模型的数据结构。

其与普通的队列的区别在于:

  • 当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中放入了元素。
  • 当队列满时,往队列里存放元素的操作会被阻塞,直到有元素从队列中取出。

管道也是基于阻塞队列实现的


2.2queue模拟阻塞队列的生产消费模型

利用STL库中的queue实现BlockQueue的结构。

#ifndef __BLOCK_QUEUE_HPP__
#define __BLOCK_QUEUE_HPP__

#include <iostream>
#include <string>
#include <queue>
#include <pthread.h>

template <typename T>
class BlockQueue
{
private:
  std::queue<T> _block_queue;   // 阻塞队列
  int _cap;                     // 总上限
  pthread_mutex_t _mutex;       // 保护_block_queue的互斥量
  pthread_cond_t _product_cond; // 专门给生产者提供的条件变量
  pthread_cond_t _consume_cond; // 专门给消费者提供的条件变量
  int _productor_wait_num;      // 生产者等待数
  int _consumer_wait_num;       // 消费者等待数

public:
  BlockQueue(int cap)
      : _cap(cap)
  {
    pthread_mutex_init(&_mutex, nullptr);
    pthread_cond_init(&_product_cond, nullptr);
    pthread_cond_init(&_consume_cond, nullptr);
  }

  bool isEmpty()
  {
    return _block_queue.empty();
  }

  bool isFull()
  {
    return _block_queue.size() == _cap;
  }

  void Enqueue(const T &in) // 生产者用的接口
  {
    pthread_mutex_lock(&_mutex);
    while (isFull()) //必须while重新判断,不能用if,防止伪唤醒
    {
      _productor_wait_num++;
      pthread_cond_wait(&_product_cond, &_mutex); // 当条件满足,线程唤醒,pthread_cond_wait要求线程必须重新竞争mutex锁,谁竞争成功谁成功返回,否则继续等待
      _productor_wait_num--;
    }
    // 进行生产
    _block_queue.push(std::move(in));
    // 通知消费者可以消费了
    if (_consumer_wait_num > 0) // 如果有等待的消费者直接唤醒,反之解锁就行了
      pthread_cond_signal(&_consume_cond);
    pthread_mutex_unlock(&_mutex);
  }

  void Pop(T *out) // 消费者用的接口
  {
    pthread_mutex_lock(&_mutex);
    while (isEmpty()) //必须while重新判断,不能用if,防止伪唤醒
    {
      _consumer_wait_num++;
      pthread_cond_wait(&_consume_cond, &_mutex);
      _consumer_wait_num--;
    }
    // 进行消费
    *out = _block_queue.front();
    _block_queue.pop();
    // 有位置了,通知生产者可以生产了
    if (_productor_wait_num > 0) // 如果有等待的生产者直接唤醒,反之解锁就行了
      pthread_cond_signal(&_product_cond);
    pthread_mutex_unlock(&_mutex);
  }

  ~BlockQueue()
  {
    pthread_mutex_destroy(&_mutex);
    pthread_cond_destroy(&_product_cond);
    pthread_cond_destroy(&_consume_cond);
  }
};

#endif

当你有了多线程概念,和线程安全的知识储备后其实只有以下几点需要格外注意,其他部分应该很好理解。

(1)有关pthread_cond_wait函数

我们在线程安全部分讲过pthread_cond_wait函数为什么参数要传递互斥锁。

那么今天我们再重谈这部分内容。

以生产者调用场景为例:

  1. 阻塞队列已满,没有位置可供生产了,生产者线程在_product_cond条件变量下等待。
  2. 因为等待是在临界区,所以此时该生产者线程持有着互斥锁_mutex。
  3. 但是此时必须有消费者消费了阻塞队列的数据,生产者才能生产,而消费者如果想要消费就必须访问阻塞队列,即必须持有_mutex互斥锁,所以我们说pthread_cond_wait函数在等待时必须释放掉_mutex。
  4. 释放掉后,消费者线程可以持有阻塞队列的访问权,即持有_mutex锁,此时进行消费操作,消费结束可以通知生产者进行生产了,即调用pthread_cond_signal唤醒生产者。
  5. 而唤醒生产者后,生产者必然会从pthread_cond_wait函数继续向下执行,但注意,此时是不是又回到了生产者的临界区中了,可是此时生产者为了让出阻塞队列访问权已经释放掉了_mutex锁,所以此时必须让生产者线程重新持有_mutex锁,如果是多线程,那么就会有多个线程进行竞争,谁竞争成功谁的pthread_cond_wait返回,其他线程则继续等待。

(2)判断是否满足生产消费条件时不能用if,而应该用while。

这里是为了避免两种情况的发生:

  1. pthread_cond_wait函数调用失败,如果该函数调用失败并且使用if仅判断一次的话,那么此时执行流继续向下执行,就会引发错误,使用while就可以避免不满足条件还继续执行的情况。
  2. 在多消费者的情况下,当生产者生产了一个数据后如果使用pthread_cond_broadcast函数唤醒消费者,就会一次性唤醒多个消费者,但待消费的数据只有一个,此时其他消费者就被伪唤醒了,只有一个消费者线程可以正常执行,其他的则会出现逻辑错误。

(3)唤醒之前可以做判断是否有等待的生产者或消费者。

  1. 给阻塞队列中维护两个成员变量记录等待的生产者数和消费者数_productor_wait_num,_consumer_wait_num。
  2. 唤醒前判断下是否有等待的生产者或消费者,如果有人等待,就唤醒;如果没人等待,解锁就行了。

或者说你可以自己设计一种条件,当满足了这个条件后再唤醒。


(4)如何理解阻塞队列提供的并发性?

阻塞队列提供的并发性并不体现在生产者向阻塞队列放数据和消费者从阻塞队列取数据,而是体现在消费者拥有数据后自行处理的部分。

即当消费者拥有数据后,就可以进行处理逻辑,而这个处理过程是消费者私有的,不涉及到竞争干扰,消费者处理逻辑可以是连接数据库,连接网络等,在处理的过程中,生产者可以生产,其他空闲的消费者也可以消费,因为当消费者线程从阻塞队列中拿到数据后,处理数据的过程是私有的。

这才是阻塞队列提供的并发性。


阻塞队列中存放的任务类型可以是对象,可以是包装器函数等等,大家有兴趣可以访问我的gitee,查看完整的生产者消费者模型代码:consumer_productor · 樊飞/Linux - 码云 - 开源中国 (gitee.com)icon-default.png?t=N7T8https://gitee.com/fanfei_c/linux/tree/master/consumer_productor


3.POSIX信号量

3.1信号量是如何实现互斥和同步的?

我们在学习互斥锁时了解到,互斥锁对临界资源的保护可以理解为是将临界资源当作一个整体,任何时间都只能有一个线程来访问这个临界资源

而信号量则是将临界资源分为若干份,当多个执行流需要访问临界资源时,如果这些执行流访问的是临界资源的不同区域,那么我们可以让这些执行流同时访问临界资源的不同区域,此时不会出现数据不一致等问题。


3.2信号量的概念

你可以将信号量视作计数器,是描述临界资源中资源数目的计数器。

每个执行流在进入临界区之前都应该先申请信号量(P操作),申请成功就有了操作特点的临界资源的权限,当操作完毕后就应该释放信号量(V操作)

信号量的PV操作:

  • P操作:申请信号量称为P操作,申请信号量的本质就是申请获得临界资源中某块资源的使用权限,当申请成功时临界资源中资源的数目应该减一,因此P操作的本质就是让计数器减一。
  • V操作:释放信号量称为V操作,释放信号量的本质就是归还临界资源中某块资源的使用权限,当释放成功时临界资源中资源的数目就应该加一,因此V操作的本质就是让计数器加一。

信号量其实也是一种临界资源,而PV操作本质就是对信号量进行操作,所以信号量也需要被保护,但是信号量本质就是用于保护临界资源的,我们不可能再用信号量去保护信号量,所以信号量的PV操作必须是原子操作

注意: 内存当中变量的++--操作并不是原子操作,因此信号量不可能只是简单的对一个全局变量进行++--操作。

信号量的本质是计数器,但不意味着只有计数器,信号量还包括一个等待队列,即当执行流在申请信号量时,可能此时信号量的值为0,也就是说信号量描述的临界资源已经全部被申请了,此时该执行流就应该在该信号量的等待队列当中进行等待,直到有信号量被释放时再被唤醒。


3.3信号量函数

3.3.1初始化信号量sem_init

int sem_init(sem_t *sem, int pshared, unsigned int value);

参数说明:

  • sem:需要初始化的信号量。
  • pshared:传入0值表示线程间共享,传入非零值表示进程间共享。
  • value:信号量的初始值(计数器的初始值)。

返回值说明:

  • 初始化信号量成功返回0,失败返回-1。

注意: POSIX信号量和System V信号量作用相同,都是用于同步操作,达到无冲突的访问共享资源目的,但POSIX信号量可以用于线程间同步。


3.3.2销毁信号量sem_destroy

int sem_destroy(sem_t *sem);

 参数说明:

  • sem:需要销毁的信号量。

返回值说明:

  • 销毁信号量成功返回0,失败返回-1。

3.3.3等待信号量sem_wait(P操作)

int sem_wait(sem_t *sem);

参数说明:

  • sem:需要等待的信号量。

返回值说明:

  • 等待信号量成功返回0,信号量的值减一。
  • 等待信号量失败返回-1,信号量的值保持不变。

3.3.4释放信号量sem_post(V操作) 

int sem_post(sem_t *sem);

参数说明:

  • sem:需要释放的信号量。

返回值说明:

  • 释放信号量成功返回0,信号量的值加一。
  • 释放信号量失败返回-1,信号量的值保持不变。

 4.基于环形队列的生产者消费者模型

4.1基本原理

对于生产者和消费者而言,他们所关注的资源是不同的:

  • 生产者关心还有没有空间生产;
  • 消费者关心还有没有数据消费;

又因为信号量本质就是一种计数器,所以我们可以使用两个信号量来分别代表空间资源数目和数据资源数目。

空间资源初始化一定为环形队列的容量大小,数据资源初始化一定为0,因为最开始没有数据,全是空间。

根据信号量的概念,我们可以知道:

对于生产者而言,生产需要等待空间资源,释放数据资源,即对空间资源_room_sem进行P操作,对数据资源_data_sem进行V操作。

  • 如果_room_sem的值不为0,则信号量申请成功,此时生产者可以进行生产操作。
  • 如果_room_sem的值为0,则信号量申请失败,此时生产者需要在_room_sem的等待队列下进行阻塞等待,直到环形队列当中有新的空间后再被唤醒。

对于消费者而言,消费需要等待数据资源,释放空间资源,即对数据资源_data_sem进行P操作,对空间资源_room_sem进行V操作。

  • 如果_data_sem的值不为0,则信号量申请成功,此时消费者可以进行消费操作。
  • 如果_data_sem的值为0,则信号量申请失败,此时消费者需要在_data_sem的等待队列下进行阻塞等待,直到环形队列当中有新的数据后再被唤醒。

4.2两个规则

4.2.1生产者和消费者不能对同一个位置进行访问

如果生产者和消费者访问了同一个位置,那么就会出现数据不一致问题。

 4.2.2无论是生产者还是消费者,都不应该将对方套圈

如果套圈,就失去了环形队列设计的意义和价值。 

  • 生产者从消费者的位置开始一直按顺时针方向进行生产,如果生产者生产的速度比消费者消费的速度快,那么当生产者绕着消费者生产了一圈数据后再次遇到消费者,此时生产者就不应该再继续生产了,因为再生产就会覆盖还未被消费者消费的数据。
  • 同理,消费者从生产者的位置开始一直按顺时针方向进行消费,如果消费者消费的速度比生产者生产的速度快,那么当消费者绕着生产者消费了一圈数据后再次遇到生产者,此时消费者就不应该再继续消费了,因为再消费就会消费到缓冲区中保存的废弃数据。

那么信号量是如何保证生产者消费者都符合以上两个规则的呢? 

即为什么加入了_room_sem和_data_sem两个信号量,就能确保生产者消费者都遵循该环形队列的运行逻辑呢?

因为只有当生产者和消费者指向同一个位置并访问时,才会导致数据不一致的问题,而此时生产者和消费者在对环形队列进行写入或读取数据时,只有两种情况会指向同一个位置:

  • 环形队列为空时。
  • 环形队列为满时。

但是在这两种情况下,生产者和消费者不会同时对环形队列进行访问:

  • 当环形队列为空的时,消费者一定不能进行消费,因为此时数据资源为0。
  • 当环形队列为满的时,生产者一定不能进行生产,因为此时空间资源为0。

也就是说,当环形队列为空和满时,我们已经通过信号量保证了生产者和消费者的串行化过程。而除了这两种情况之外,生产者和消费者指向的都不是同一个位置,因此该环形队列当中不可能会出现数据不一致的问题。并且大部分情况下生产者和消费者指向并不是同一个位置,因此大部分情况下该环形队列可以让生产者和消费者并发的执行,妙哉!


4.3vector模拟环形队列的生产消费模型

利用STL库中的vector模拟实现环形队列的结构。

#ifndef __RING_QUEUE_HPP__
#define __RING_QUEUE_HPP__

#include <iostream>
#include <string>
#include <vector>
#include <semaphore.h>

template <typename T>
class RingQueue
{
private:
  std::vector<T> _ring_queue;//vector模拟环形队列
  int _cap; // 环形队列的容量上限

  // 2. 生产和消费的位置(下标)
  int _productor_step;
  int _consumer_step;

  // 3. 定义信号量
  sem_t _room_sem; // 空间资源
  sem_t _data_sem; // 数据资源

private:
  void P(sem_t &sem)
  {
    sem_wait(&sem);
  }
  void V(sem_t &sem)
  {
    sem_post(&sem);
  }

public:
  RingQueue(int cap) : _cap(cap), _ring_queue(cap)
  {
    sem_init(&_room_sem, 0, _cap);
    sem_init(&_data_sem, 0, 0);
  }
  // 生产
  void Enqueue(const T &in)
  {
    P(_room_sem);
    // 执行到这,一定有空间
    _ring_queue[_productor_step++] = in;
    _productor_step %= _cap;
    V(_data_sem);
  }
  void Pop(T *out)
  {
    P(_data_sem);
    *out = _ring_queue[_consumer_step++];
    _consumer_step %= _cap;
    V(_room_sem);
  }

  ~RingQueue()
  {
    sem_destroy(&_room_sem);
    sem_destroy(&_data_sem);
  }
};

#endif

=========================================================================

如果你对该系列文章有兴趣的话,欢迎持续关注博主动态,博主会持续输出优质内容

🍎博主很需要大家的支持,你的支持是我创作的不竭动力🍎

🌟~ 点赞收藏+关注 ~🌟

=========================================================================

评论 68
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

樊梓慕

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值