Linux应用编程基础07-生产者消费者模型(多线程)

生产者消费者模型(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 一个数据;

而循环队列就不一样了,生产者、消费者 可以通过信号量知晓数据获取、数据消费次数,并且由于数据获取、消费操作没有加锁,支持并发,因此效率十分高

循环队列一定优于阻塞队列吗?

相关推荐
传而习乎1 分钟前
Linux:CentOS 7 解压 7zip 压缩的文件
linux·运维·centos
我们的五年11 分钟前
【Linux课程学习】:进程程序替换,execl,execv,execlp,execvp,execve,execle,execvpe函数
linux·c++·学习
IT果果日记32 分钟前
ubuntu 安装 conda
linux·ubuntu·conda
Python私教35 分钟前
ubuntu搭建k8s环境详细教程
linux·ubuntu·kubernetes
羑悻的小杀马特1 小时前
环境变量简介
linux
小陈phd1 小时前
Vscode LinuxC++环境配置
linux·c++·vscode
是阿建吖!1 小时前
【Linux】进程状态
linux·运维
明明跟你说过2 小时前
Linux中的【tcpdump】:深入介绍与实战使用
linux·运维·测试工具·tcpdump
Komorebi.py3 小时前
【Linux】-学习笔记05
linux·笔记·学习
Mr_Xuhhh3 小时前
重生之我在学环境变量
linux·运维·服务器·前端·chrome·算法