生产消费者模型(引入--超市),321原则,阻塞队列实现+优点(代码,伪唤醒问题,条件变量接口wait中锁的作用),进阶版实现(生产任务,RAII风格),多生产多消费实现+优点

目录

举例 -- 超市

介绍

概念

2种角色

1个交易场所

3种关系

生产者之间

消费者之间 

生产者和消费者

关系

互相等待

阻塞队列

介绍

模拟实现 -- 基础版

思路

代码

pthread_cond_wait的第二个参数为什么是把锁

伪唤醒问题

介绍

代码

示例 

优点 

引入

介绍

模拟实现 -- 进阶版

增加生产/消费规则

生产任务(随机)

思路

代码

示例

生产任务(从键盘读入) 

代码

示例

锁的封装(RAII风格)

思路

代码

模拟实现 -- 多生产多消费 

代码

优点


举例 -- 超市

说到消费场所,我们首先能想到的就是超市

  • 超市是一个综合性比较强的交易场所,我们可以在里面买到各种商品

那商品从哪来呢?难道是超市自己生产的吗?

  • 自然不是
  • 是由各个供货商提供
  • 也就是说,供货商提供商品给超市,消费者在超市拿到商品

那为什么消费者一定要在超市消费呢?

  • 如果不在超市,那么对应的就要在供货商处消费 -- 消费者需要自行去各个厂家购买相应产品
  • 首先就是消费者自己的效率问题,如果要买多种商品,就要去多个厂家
  • 其次是厂家的效率问题,来一个消费者才生产一件货物(不然可能会产生货物积压的问题)
  • 而且,厂家的工作时间不一定是消费者的闲暇时间

所以,超市存在的意义就是解决上述问题

  • 它可以一次性向厂家要很多货物,放在超市里,等待顾客自行挑选
  • 并且,可以摆放多个货物,消费者只需要在超市这一个场所中,就可以拿到不同货物
  • 它可以协调生产者和消费者的忙闲时间

超市就可以看作是生产消费者模型的一种实际应用 

介绍

概念

  • 生产者-消费者模型(Producer-Consumer Model)是一种并发计算模型,用于解决多线程或多进程之间的协作和数据共享问题
  • 其中生产者负责生成数据或任务,而消费者则负责处理这些数据或任务
  • 模型的目标是协调和同步生产者和消费者的活动,以确保数据被正确处理且不发生冲突

我们可以将生产消费者模型概括为下面三个原则

2种角色

  • 自然是生产者和消费者
  • 在计算机中可以看作是两个线程 / 多个线程(因为可能会存在多个生产者/消费者)

1个交易场所

  • 也就是超市充当的角色
  • 在计算机中是一块共享的缓冲区 / 一种数据结构,是两个角色都可以访问到的一块空间
  • 一个生成数据或任务,将其放入共享的缓冲区中 ; 一个从共享缓冲区中取出数据或任务,并进行相应的处理

3种关系

生产者之间

首先,因为可能存在多个生产者,自然需要维护生产者和生产者的关系

  • 也就是互斥关系/竞争关系
  • 同一个位置,只能有一个生产者的货物放上去
  • 是不是和之前说的[只能有一个线程访问临界资源]很相似?
  • 所以对应的,我们需要对生产的过程加锁保护

消费者之间 

和生产者之间的关系类似

  • 如果有多个消费者,他们之间也是互斥/竞争关系
  • 虽然我们平时没有见到在超市里有争夺的现象,但那是因为超市里的资源多
  • 如果全超市只剩下一包泡面,就会抢起来了(因为只有一个人可以拿到那包泡面)
  • 在现实生活中,这样的前提是自然存在的 ; 但计算机中,需要用代码去实现
  • 所以,为了保证一个货物只有一个消费者去消费,就要进行加锁保护噜

生产者和消费者

关系

这是最重要的关系了,毕竟可能存在只有一个消费者,一个生产者的情况,那上面两种关系就不存在了

  • 生产者和消费者之间会存在互斥关系
  • 毕竟,你不能在生产者正在生产时,去试图拿取东西吧 (你无法确定此时是否生产完成,自然拿取行为也就是不确定的)
  • 反过来也是一样的
  • 所以我们要保证生产和消费的过程是原子的,也就是进行加锁保护(当然,这个已经在上面提到过了)
互相等待
  • 除此之外,最重要的是,生产者和消费者是有一定顺序的
  • 只有当生产者生产出东西了,消费者才能去消费
  • 并且,缓冲区的情况也需要被考虑
  • 当缓冲区为空时,消费者直到生产者放入数据后,才可以消费
  • 当缓冲区满时,生产者直到消费者取出数据后,才可以生产
  • 而双方互相等待对方的行为,是不是很熟悉?很像管道通信!

  • 如果将生产者和消费者各自看作一个线程,东西也就是数据
  • 那么一方生产,一方消费,不就是双方在进行单向通信吗?

那消费者和生产者如何得知缓冲区的情况呢?

  • 必须得访问吧
  • 那就出现了之前介绍过的,抢票前需要先确定票是否就绪的问题
  • 因为不知道何时就绪,线程就需要不断访问临界资源
  • 但这是一种不必要的行为,因为我们可以引入条件变量来解决
  • 当票就绪时,让修改资源的线程通知到等待的线程即可
  • 这里也是一样
  • 消费者需要货物,只有生产者最清楚是否有货物(因为货物就是他生产出来的)
  • 生产者需要确定它此时是否可以生产,也就是需要空位,只有消费者最清楚是否有空位(因为空位就是它消费导致的)
  • 所以,让他俩互相通知对方即可,而这样也就自然形成了顺序

阻塞队列

介绍

  • 在多线程编程中,阻塞队列(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函数也是一样

生产消费者模型(引入--超市),321原则,阻塞队列实现+优点(代码,伪唤醒问题,条件变量接口wait中锁的作用),进阶版实现(生产任务,RAII风格),多生产多消费实现+优点_第1张图片

代码
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_);
    }

示例 

如果不对生产和消费的过程进行限制,双方就会一直快速执行:

生产消费者模型(引入--超市),321原则,阻塞队列实现+优点(代码,伪唤醒问题,条件变量接口wait中锁的作用),进阶版实现(生产任务,RAII风格),多生产多消费实现+优点_第2张图片

如果让生产先进行一会,随后再放出消费者,且消费者是慢于生产的:

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;
}

生产消费者模型(引入--超市),321原则,阻塞队列实现+优点(代码,伪唤醒问题,条件变量接口wait中锁的作用),进阶版实现(生产任务,RAII风格),多生产多消费实现+优点_第3张图片

生产者会先生产一堆数据,随后消费者慢慢按照生产的顺序一个一个消费

如果生产慢一点:

生产消费者模型(引入--超市),321原则,阻塞队列实现+优点(代码,伪唤醒问题,条件变量接口wait中锁的作用),进阶版实现(生产任务,RAII风格),多生产多消费实现+优点_第4张图片

消费者会等待生产者,生产一个,才能消费一个

优点 

引入

  • 其实,从上面的代码中,看不出来这样的设计有什么很大的作用
  • 只是两个线程将某个数据运输过去了而已,并且还因为锁,必须等待对方完成,自己才能开始工作
  • 这有什么意义呢?

介绍

  • 实际上,是我们没有模拟出真实的情况
  • 生产和消费数据都需要耗费时间,并不是简单的拷贝就完成了
  • 数据的获取可能从网络中/第三方那里来,拿到数据后的处理也可能耗费一定时间
  • 设计让生产者和消费者作为单独的线程来处理数据,就是为了让双方在对方进行前提事件/后续处理的时候,还可以继续生产/消费(也就是执行临界区代码),而不是等待一系列操作完成后,再进行生产/消费
  • 也就是所谓的,协调了忙闲时间
  • 这样可以提高程序的并发度,也就提高了效率

模拟实现 -- 进阶版

增加生产/消费规则

除了像上面那样,生产一个就可以消费一个,也可以制定某种规则

  • 等数据>=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);
}
示例

生产消费者模型(引入--超市),321原则,阻塞队列实现+优点(代码,伪唤醒问题,条件变量接口wait中锁的作用),进阶版实现(生产任务,RAII风格),多生产多消费实现+优点_第5张图片

这样我们就可以自动将生成的随机数进行加法运算噜

生产任务(从键盘读入) 

除了随机生成,也可以自行导入数据

代码
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;
}
示例

生产消费者模型(引入--超市),321原则,阻塞队列实现+优点(代码,伪唤醒问题,条件变量接口wait中锁的作用),进阶版实现(生产任务,RAII风格),多生产多消费实现+优点_第6张图片

锁的封装(RAII风格)

思路
  • 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);
}

可以看到,生产和消费各自有两个线程去执行:

生产消费者模型(引入--超市),321原则,阻塞队列实现+优点(代码,伪唤醒问题,条件变量接口wait中锁的作用),进阶版实现(生产任务,RAII风格),多生产多消费实现+优点_第7张图片

优点

  • 也许你会觉得没有必要,因为加入更多的线程,会让锁资源的竞争更加激烈,调度成本也增加了
  • 但其实只是让临界区的执行成本增加了点,在之外的区域效率还是变高了的
  • 还记得前面我们说过的,获取数据和拿到数据后的处理都是会花费一定时间的,但这块区域是没有锁覆盖的
  • 也就是说,这块区域允许多个执行流进入
  • 那么,采用多生产/多消费,可以使多个执行流并发的完成这些工作,加速生产消费进程 -- 因为生产和消费是有一定顺序的,如果线程迟迟卡在其他工作上,会减少生产/消费的次数,进而拖累消费/生产
  • 所以,多线程去执行,对于前序/后续工作耗费时间较长的任务来说,可以提高效率

你可能感兴趣的:(linux,开发语言,c++,linux)