【linux线程(三)】生产者消费者模型(条件变量阻塞队列版本、信号量环形队列版本)详细剖析

🎬 个人主页:HABuo

📖 个人专栏:《C++系列》 《Linux系列》《数据结构》《C语言系列》《Python系列》《YOLO系列》

⛰️ 如果再也不能见到你,祝你早安,午安,晚安


目录

📚一、生产者消费者模型概念

📚二、阻塞队列版本的生成消费模型

📚三、POSIX信号量

📚四、信号量版本的生产消费模型

📚五、总结


前言:

本篇博客我们接续上篇博客线程的讲解,上篇博客我们认识了线程互斥和条件变量相关知识,本篇博客我们来认生产消费模型,并详细讲述两个版本的生产消费模型的实现!

本章重点:

生产消费模型的概念及理解、阻塞队列版本生产消费模型代码实现、信号量的概念及使用、信号量环形队列版本的生产消费模型代码实现


📚一、生产者消费者模型概念

什么是生产者消费者模型?

生产者与消费者模型是并发编程中最经典、最基础的问题之一,它描述了多个线程或进程如何通过一个共享缓冲区安全地协作:生产者线程负责生成数据,消费者线程负责处理数据,两者通过一个有限大小的缓冲区进行数据传递正确实现该模型需要解决同步(确保缓冲区非空时才消费、非满时才生产)和互斥(防止多个线程同时操作缓冲区)两方面的问题

可以总结为321原则:

3种关系:生产者和生产者(互斥),消费者和消费者(互斥),生产者和消费者(互斥保证共享资源的安全性,同步保证在关键条件(满/空)-产品(数据)下"互相等待、互相通知",从而配合得刚刚好。)
2种角色:生产者线程,消费者线程
1个交易场所:一段特定结构的缓冲区

我们举一个例子:

想象一个厨房和餐厅

  • 厨房 有一个出菜台(缓冲区),最多只能放 5 盘菜。

  • 厨师(生产者)做好菜就放在出菜台上。

  • 服务员(消费者)从出菜台取菜,端给客人。

🟢 同步------ 条件等待与通知

当出菜台满(5 盘)时, 厨师必须停下来等待,直到服务员取走一盘,台子不再满,才能继续做菜放上去。

当出菜台空(0盘)时, 服务员必须停下来等待,直到厨师做好新菜放上去,才能继续取菜。

这种"条件满足才行动,否则等待"就是同步。它让生产者和消费者在关键条件(满/空)下互相等待、互相通知,节奏自然协调。

🔒 互斥------ 防止冲突

出菜台是共享的,如果多个厨师同时放菜,或厨师放菜的同时服务员取菜,就会发生冲突(比如两双手同时伸向同一个盘子,数据混乱)。

因此,任何时刻只能有一个人操作出菜台 。这就是互斥

特点:

  1. 生产线程和消费线程进行解耦
  2. 支持生产和消费的一段时间的忙闲不均的问题
  3. 提高效率

不仅如此,生产者消费者模型还支持多个线程一起进入,阻塞队列属于临界资源,所以同一时刻,不管是生产者还是消费者,都只允许一个线程进入到阻塞队列进行push或pop操作.除此之外,我们还需要两个条件变量push_cond和pop_cond,当阻塞队列已满时,push_cond条件变量会让生产者线程等待消费者线程在队列中取走数据,而当阻塞队列为空时,pop_cond条件变量会让消费者线程等待生产者往队列中加入数据,一来一回,也就是push操作会唤醒pop_cond条件变量,而pop操作会唤醒push_cond条件变量

  • 多个厨师(多生产者)

    只要出菜台没满,他们可以轮流放菜;一旦满了,所有厨师都等待,直到有空位。互斥保证他们不会同时放菜。

  • 多个服务员(多消费者)

    只要出菜台有菜,他们可以轮流取菜;一旦空了,所有服务员都等待,直到有新菜。互斥保证他们不会同时取菜。

📚二、阻塞队列版本的生成消费模型

具体步骤如下:

一、类设计(模板类)

  • 模板参数 :支持任意数据类型 T

  • 成员变量

    • std::queue<T> q:底层队列。

    • pthread_mutex_t mutex:互斥锁,保护临界区。

    • pthread_cond_t p_cond:生产者条件变量,队列满时生产者等待。

    • pthread_cond_t c_cond:消费者条件变量,队列空时消费者等待。

    • int maxcap:队列最大容量。

    • static const int defaultnum = 20:默认最大容量。

  • 构造与析构

    • 构造函数:初始化锁、条件变量;若未传入容量则使用 defaultnum

    • 析构函数:销毁锁、条件变量。

二、核心操作

1. push(const T& item) ------ 生产者生产数据

  1. 加锁pthread_mutex_lock(&mutex)

  2. 等待条件 (队列满):
    while (q.size() == maxcap)
    pthread_cond_wait(&p_cond, &mutex)

    (使用 while 避免伪唤醒)

  3. 生产数据q.push(item)

  4. 唤醒消费者pthread_cond_signal(&c_cond)

  5. 解锁pthread_mutex_unlock(&mutex)

2. T pop() ------ 消费者消费数据

  1. 加锁pthread_mutex_lock(&mutex)

  2. 等待条件 (队列空):
    while (q.empty())
    pthread_cond_wait(&c_cond, &mutex)

    (使用 while 避免伪唤醒)

  3. 消费数据T item = q.front(); q.pop();

  4. 唤醒生产者pthread_cond_signal(&p_cond)

  5. 解锁pthread_mutex_unlock(&mutex)

  6. 返回return item

三、关键要点

  • 双条件变量:分别控制生产者和消费者,避免"唤醒错误线程"导致程序崩溃。

  • 循环条件判断 :使用 while 而非 if 防止伪唤醒。

  • 资源管理:构造时初始化锁和条件变量,析构时销毁。

完整代码如下:

cpp 复制代码
#include <iostream>
#include <queue>
#include <pthread.h>
const int defaultmaxcap = 500;//用来初始化队列最大容量值
template <class T>
class BlockQueue
{
public:
    BlockQueue(const int& maxcap = gmaxcap) :_maxcap(maxcap)
    {
        pthread_mutex_init(&_mutex, nullptr);
        pthread_cond_init(&_pcond, nullptr);
        pthread_cond_init(&_ccond, nullptr);
    }
    void push(const T& in) // 输入型参数,const &
    {
        pthread_mutex_lock(&_mutex);
        // 1. 判断
        while (_q.size() == _maxcap) //充当条件判断的语法必须是while,不能用if,这在条件变量中我们详细介绍了伪唤醒的问题
        {
            pthread_cond_wait(&_pcond, &_mutex); //因为生产条件不满足,无法生产,此时我们的生产者进行等待
        }
        // 2. 走到这里一定是没有满
        _q.push(in);
        // 3. 绝对能保证,阻塞队列里面一定有数据
        // pthread_cond_signal:这个函数,可以放在临界区内部,也可以放在外部
        pthread_cond_signal(&_ccond); 
        pthread_mutex_unlock(&_mutex);
    }
    void pop(T* out) // 输出型参数:*, // 输入输出型:&
    {
        pthread_mutex_lock(&_mutex);
        //1. 判断
        while (_q.empty())
        {
            pthread_cond_wait(&_ccond, &_mutex);
        }
        // 2. 走到这里我们能保证,一定不为空
        *out = _q.front();
        _q.pop();
        // 3. 绝对能保证,阻塞队列里面,至少有一个空的位置!
        pthread_cond_signal(&_pcond); 
        pthread_mutex_unlock(&_mutex);
    }
    ~BlockQueue()
    {
        pthread_mutex_destroy(&_mutex);
        pthread_cond_destroy(&_pcond);
        pthread_cond_destroy(&_ccond);
    }
private:
    std::queue<T> _q;
    int _maxcap; // 队列中元素的上限
    pthread_mutex_t _mutex;
    pthread_cond_t _pcond; // 生产者对应的条件变量
    pthread_cond_t _ccond; // 消费者对应的条件变量
};

对于这段代码的测试代码包括单生产单消费、多生产多消费,我将其放置在Gitee中,有兴趣的同学可以去拷贝下来做一些测试:

阻塞队列版本生产消费模型完整测试代码

在这里,我们来探讨一个问题:

生产消费模型的效率具体体现在哪里?容器不仍然是只允许一个生产者或者一个消费者生产数据和消费数据吗?那提升效率在哪里?

我们还用厨房的例子来拆解一下:

生产者与消费者可以并行工作

关键点在于:生产数据(做菜) 和 消费数据(端菜) 这两个动作本身,并不需要互斥。互斥锁只保护 "放菜"和"取菜" 这两个极短的操作(修改缓冲区指针、计数器等)。真正耗时的部分,是可以并行的。

  • 厨师 做一盘菜可能要 5 分钟(生产耗时)。

  • 服务员 把菜端给客人、回来,可能要 3 分钟(消费耗时)。

如果没有生产者-消费者模型,最差的做法是:厨师做一盘菜,等服务员取走,再做下一盘。这样总时间就是 5 + 3 = 8 分钟一盘菜。

有了模型:厨师可以一直做菜,做到出菜台放满为止;同时服务员可以一直取菜端菜。只要出菜台没满,厨师就不需要等服务员;只要出菜台没空,服务员就不需要等厨师。

  • 当厨师在做第 1 盘菜时,服务员可能正在端上一盘菜。

  • 当厨师做完第 2 盘时,服务员可能刚好回来取走第 1 盘。

这样,生产过程和消费过程重叠进行,整体吞吐量远远大于串行

对缓冲区的访问(放/取)必须互斥,一次只能一个人碰容器。但这部分操作非常短(比如几十纳秒到几微秒)。真正占用时间的是:

  • 生产者生成数据(做菜)

  • 消费者处理数据(端菜)

这些操作在互斥锁之外进行,多个生产者和消费者可以同时干自己的活。例如:

  • 三个厨师可以同时做三盘菜(三个线程并行生产)。

  • 两个服务员可以同时端两盘菜(两个线程并行消费)。

  • 只有当某个厨师要把做好的菜放到台子上时,才会短暂地拿锁、放菜、解锁;同样,服务员取菜也是短暂拿锁、取菜、解锁。

所以,并发度体现在"生产"和"消费"的过程上,而不是对容器的操作上

为什么不能直接让多个线程同时读写容器?

如果允许同时放菜和取菜,可能出现数据不一致(比如两个厨师同时把菜放在同一个位置,或者厨师放菜的同时服务员取走同一个盘子)。所以必须用锁保证原子性。这是保证正确性的必要代价,而这个代价相对于生产/消费的耗时通常很小

总结为以下几点

生产者-消费者模型提升效率的关键

  1. 解耦生产与消费 ,允许它们并行工作,而不是串行等待。

  2. 缓冲区 起到 "缓冲"作用,平滑速率波动,避免一方阻塞另一方。

  3. 互斥锁只保护对缓冲区本身的极短操作,生产数据和消费数据这些耗时工作完全可以并发执行。

  4. 通过增加生产者和消费者的数量,可以成倍提升系统的整体吞吐量。

📚三、POSIX信号量

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

思考下述问题:

生产消费模型中,**生产者关心的是容器是否满了,而消费者关心的是容器是否有数据。**但是在它加锁进入临界区之前它是不知道其中的情况的,如果数据满了生产者就要阻塞在那里,如果空了消费者就要阻塞在那里,能不能有个东西让它在进入之前就先知道我资源的数量,好让它们满了或空了先做其它事情,这样效率不就进一步提升了吗!

什么是信号量?
信号量的本质是一个计数器,在使用资源前需要预定资源,也就是让信号量减一.类似于去电影院看电影需要提前买票预定座位,并且在访问完资源释放信号量,也就是让信号量加一

所以我们就可以在生产消费模型中引入信号量:

生产者关心队列是否为满,刚开始队列是空,那么生产者的信号量就是n(队列的长度),而消费者关心队列是否有资源,刚开始队列为空,那么消费者的信号量就是0.并且在生产者push一个数据到队列后,生产者的信号量需要减一,而消费者的信号量会加一,同理,消费者从队列拿走一个数据后,生产者的信号量会加一,而消费者的信号量会减一

信号量的使用方法分为以下几个接口:

初始化信号量

销毁信号量

等待信号量(信号量减一)

发布信号量(信号量加一)

📚四、信号量版本的生产消费模型

  • 生产者关心空间资源,使用信号量pspace_sem_,起始值为cap
  • 消费者关心数据资源,使用信号量cdata_sem_,起始值为0
  • 期望:生产者不能让消费者套圈(为空时要先让生产者先运行)
  • 期望:消费者不能超过生产者(为满时要让消费者先运行)
  • 需要两把锁,一把给生产者之间当互斥锁,另外一把给消费者用

具体步骤如下:

一、类设计(模板类)

  • 模板参数 :支持任意数据类型 T

  • 成员变量

    • std::vector<T> ringqueue_:底层环形队列。

    • sem_t cdata_sem_:消费者信号量(记录已有数据个数)。

    • sem_t pspace_sem_:生产者信号量(记录剩余空间个数)。

    • int c_step_:消费者下标。

    • int p_step_:生产者下标。

    • int cap_:队列最大容量。

    • static const int defaultnum = 5:默认容量。

  • 私有辅助函数(封装P/V操作):

    • void P(sem_t* sem):调用 sem_wait

    • void V(sem_t* sem):调用 sem_post

二、构造与析构

1. 构造函数 RingQueue(int cap = defaultnum)

  1. 初始化列表

    • ringqueue_.resize(cap):预分配空间。

    • cap_(cap)

    • c_step_(0), p_step_(0)

  2. 信号量初始化

    • sem_init(&pspace_sem_, 0, cap):剩余空间初始为 cap

    • sem_init(&cdata_sem_, 0, 0):已有数据初始为 0。

2. 析构函数 ~RingQueue()

  • sem_destroy(&pspace_sem_)

  • sem_destroy(&cdata_sem_)

三、核心操作

1. Push(const T& in) ------ 生产者生产数据

  1. P(pspace_sem_):申请一个空闲位置(若无则阻塞)。

  2. 生产数据
    ringqueue_[p_step_] = in;
    p_step_ = (p_step_ + 1) % cap_;

  3. V(cdata_sem_):增加一个数据资源,唤醒消费者。

2. Pop(T* out) ------ 消费者消费数据

  1. P(cdata_sem_):申请一个数据资源(若无则阻塞)。

  2. 消费数据
    *out = ringqueue_[c_step_];
    c_step_ = (c_step_ + 1) % cap_;

  3. V(pspace_sem_):增加一个空闲位置,唤醒生产者。

完整代码如下:

cpp 复制代码
#include <vector>
#include <semaphore.h>
static int defaultnum = 5;
template<class T>
class RingQueue
{
private:
    void P(sem_t& sem)   {
        sem_wait(&sem);
    }
    void V(sem_t& sem)   {
        sem_post(&sem);
    }
    void Lock(pthread_mutex_t& mutex)    {
        pthread_mutex_lock(&mutex);
    }
    void Unlock(pthread_mutex_t& mutex) {
        pthread_mutex_unlock(&mutex);
    }
public:
    RingQueue(int cap = defaultnum) :ringqueue_(cap), cap_(cap), c_step_(0), p_step_(0)
    {
        sem_init(&pspace_sem_, 0, cap_);
        sem_init(&cdata_sem_, 0, 0);
        pthread_mutex_init(&c_mutex_, nullptr);
        pthread_mutex_init(&p_mutex_, nullptr);
    }
    void Push(const T& in)
    {
        P(pspace_sem_);
        Lock(p_mutex_);
        ringqueue_[p_step_] = in;
        p_step_++;
        p_step_ %= cap_;
        Unlock(p_mutex_);
        V(cdata_sem_);
    }
    void Pop(T* out)
    {
        P(cdata_sem_);
        Lock(c_mutex_);
        *out = ringqueue_[c_step_];
        c_step_++;
        c_step_ %= cap_;
        Unlock(c_mutex_);
        V(pspace_sem_);
    }
    ~RingQueue()
    {
        sem_destroy(&cdata_sem_);
        sem_destroy(&pspace_sem_);
        pthread_mutex_destroy(&c_mutex_);
        pthread_mutex_destroy(&p_mutex_);
    }
private:
    std::vector<T> ringqueue_;
    int cap_;      //容量
    int c_step_;   //消费者的位置
    int p_step_;   //生产者的位置
    sem_t cdata_sem_;  //消费者所需要的剩余数据信号量
    sem_t pspace_sem_; //生产者所需要的剩余空间信号量
    pthread_mutex_t c_mutex_; //消费者锁
    pthread_mutex_t p_mutex_; //生产者锁
};

对于这段代码的测试代码包括单生产单消费、多生产多消费,我将其放置在Gitee中,有兴趣的同学可以去拷贝下来做一些测试:

信号量版本环形队列生产消费模型完整测试代码


📚五、总结

本篇博客我们认识了生产消费模型,并基于条件变量以及信号量实现了不同版本的生产消费模型。

小结一下:

什么是生产者消费者模型?

生产者线程负责生成数据,消费者线程负责处理数据,两者通过一个有限大小的缓冲区进行数据传递正确实现该模型需要解决同步(确保缓冲区非空时才消费、非满时才生产)和互斥(防止多个线程同时操作缓冲区)两方面的问题

321原则:

3种关系:生产者和生产者(互斥),消费者和消费者(互斥),生产者和消费者(互斥保证共享资源的安全性,同步保证在关键条件(满/空)-产品(数据)下"互相等待、互相通知",从而配合得刚刚好。)
2种角色:生产者线程,消费者线程
1个交易场所:一段特定结构的缓冲区

生产消费模型效率体现在:在多生产多消费过程中生产过程和消费过程可以并发的执行!

条件变量阻塞队列与信号量环形队列版本的生产消费模型代码

相关推荐
Milu_Jingyu2 小时前
Windows与Ubuntu文件共享详细指南
linux·windows·ubuntu
运维行者_2 小时前
使用 Applications Manager 实现 AWS 云监控:保障业务应用高效运行
大数据·运维·服务器·网络·数据库·云计算·aws
bestblueheart2 小时前
C语言怎么学?系统学习路线图分享
c语言·指针·计算机基础·学习路线·编程思想
安科士andxe2 小时前
深度解析|安科士100G QSFP28 30km光模块核心技术,破解中长距传输痛点
运维·服务器·网络
01传说2 小时前
nginx部署教程实战
运维·nginx
小肝一下2 小时前
每日两道力扣,day2
c++·算法·leetcode·职场和发展
Java面试题总结2 小时前
Linux根分区爆满(占用81%)排查与解决实战
linux·运维·服务器
Bert.Cai2 小时前
Linux touch命令详解
linux·运维
想要入门的程序猿2 小时前
VTK与PCL源码编译(Ubuntu 20.04.6)
linux·运维·服务器