Re:Linux系统篇(五十六)线程篇 · 九:基于阻塞队列的生产者消费者模型与条件变量


◆ 博主名称: 小此方-CSDN博客 大家好,欢迎来到小此方的博客。
⭐️Linux系列个人专栏: 【主题曲】Linux
⭐️此方的GitHub: github_此方
⭐️ Re系列专栏:我们思考 (Rethink) · 我们重建 (Rebuild) · 我们记录 (Record)


文章目录

  • 概要&序論
  • 一、生产者消费者模型理论解析
    • [1.1 什么是生产者消费者模型](#1.1 什么是生产者消费者模型)
    • [1.2 三种关系、两个角色、一个交易场所](#1.2 三种关系、两个角色、一个交易场所)
      • [1.2.1 3种关系](#1.2.1 3种关系)
      • [1.2.2 2种角色](#1.2.2 2种角色)
      • [1.2.3 1个交易场所](#1.2.3 1个交易场所)
    • [1.3 为何要引入生产者消费者模型](#1.3 为何要引入生产者消费者模型)
      • [1.3.1 传统串行调用的局限性](#1.3.1 传统串行调用的局限性)
      • [1.3.2 生产消费模型带来的核心优势](#1.3.2 生产消费模型带来的核心优势)
  • 二、BlockingQueue
  • 三、C++基于阻塞队列的生产消费模型源码
    • 3.1BlockQueue.hpp
    • 3.2Main.cc
    • 3.3task.hpp
    • [3.4 阻塞队列实现中的关键细节与坑点解析](#3.4 阻塞队列实现中的关键细节与坑点解析)
      • [3.4.1 条件判断的陷阱:为什么必须用 while 而不是 if](#3.4.1 条件判断的陷阱:为什么必须用 while 而不是 if)
      • [3.4.2 pthread_cond_wait的内部细节与锁的状态变迁](#3.4.2 pthread_cond_wait的内部细节与锁的状态变迁)
      • [3.4.3 先唤醒还是先解锁:Signal 与 Unlock 的顺序抉择](#3.4.3 先唤醒还是先解锁:Signal 与 Unlock 的顺序抉择)

概要&序論

  Hello大家好,我是此方。本文深入解析生产者消费者模型,阐述"321"原则与解耦高效优势 ;基于 BlockingQueue 给出 C++ 源码实现,并剖析条件变量中 while 校验、伪唤醒及锁变迁等核心细节。

一、生产者消费者模型理论解析

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

  在多线程编程中,生产者消费者模型是一种极其经典的数据共享与线程协作模式。如果把多线程通信比作一个生活中的故事,那么生产者就像是生产商品的工厂 ,消费者就像是购买商品的顾客 ,而两者之间并非直接进行一手交钱一手交货的面对面交易,而是通过超市 这个中间交易场所来进行数据交互。

  生产者消费者模型的本质,就是通过一块特定数据结构构成的内存空间作为缓冲区,将负责产生数据的线程(生产者)与负责处理数据的线程(消费者)进行解耦,实现高效的数据传输与任务调度。

1.2 三种关系、两个角色、一个交易场所

  为了更加严谨且系统地理解该模型,可以将生产者消费者模型的结构高度凝练为 "321"原则:

1.2.1 3种关系

  由于缓冲区本质上是一块被多个线程同时访问的临界资源,为了保证数据的正确性与一致性,生产者与消费者之间必须遵循以下三种基本的互斥与同步约束:

  • 生产者与生产者之间(互斥):当多个生产者同时往超市货架上摆放商品时,为了防止放置位置冲突或覆盖数据,生产者之间是互斥且存在竞争关系的。
  • 消费者与消费者之间(互斥):当货架上商品数量较少时,多个消费者同时去拿取同一件商品会引发数据不一致问题,因此消费者之间同样存在互斥关系。
  • 生产者与消费者之间(互斥与同步) :
    • 互斥:生产者向缓冲区写入数据时,消费者不能同时读取,防止读到未构建完成的残缺数据。
    • 同步:当缓冲区被放满时,生产者必须挂起等待,直到消费者取走数据;当缓冲区为空时,消费者必须挂起等待,直到生产者生产出新的数据。

1.2.2 2种角色

  • 生产者角色:由专门的执行流(线程)承担,负责获取外部数据、构建任务并将其压入缓冲区。
  • 消费者角色:由专门的执行流(线程)承担,负责从缓冲区提取数据或任务,并执行具体的业务处理逻辑。

1.2.3 1个交易场所

  • 交易场所 :在内存中表现为一块临界资源 ,通常由阻塞队列(BlockQueue )、环形缓冲区(RingBuffer)等特定数据结构来实现,用作数据缓冲。

1.3 为何要引入生产者消费者模型

1.3.1 传统串行调用的局限性

  在传统的单线程或常规函数调用中,例如在 main() 函数内调用 fun(a, b, c):

  1. main() 执行流必须暂停,等待 fun() 内部将传入的参数处理完毕。
  2. fun() 执行并返回结果后,main() 才能继续向下执行。

  这种强耦合的机制导致调用方(生产者)与被调用方(消费者)在时间线上完全串行,资源利用率低下。

1.3.2 生产消费模型带来的核心优势

  • 解耦:生产者不需要关心数据被谁消费、何时消费,只需将数据扔进缓冲区;消费者也不需要找生产者要数据,只需从缓冲区读取。双方不直接通信,降低了代码模块间的耦合度。
  • 支持忙闲不均:缓冲区起到了蓄水池和缓冲垫的作用。当工厂临时停产时,超市里依然有库存供消费者采购;当消费者较少时,生产者可以持续生产将仓库填满,平衡了两端的处理能力差异。
  • 极大地提高整体并发效率 :
    很多初学者会产生疑问:既然向交易场所(缓冲区)存取数据需要加锁互斥,导致入队列和出队列的过程是串行的,那为什么还能说该模型能提高效率?
    模型的效率提升并不体现于临界区数据的加锁存取过程 ,而是体现于临界区之外的并发处理 :
    • 生产者与消费者之间的并发性:生产者在获取外部数据或构建任务(如网络IO接收、数据解析等)的过程,可以与消费者的处理过程并发进行。
    • 消费者与消费者之间的并发性:当多个消费者线程从缓冲区拿到任务后,它们可以同时并发地去执行具体的耗时业务逻辑(如复杂的数学计算、磁盘写入等),而不需要互相等待。

二、BlockingQueue

  在多线程编程中阻塞队列(Blocking Queue)是一种常用于实现生产者和消费者模型的数据结构。其与普通的队列区别在于,当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素;当队列满时,往队列里存放元素的操作也会被阻塞,直到有元素被从队列中取出(以上的操作都是基于不同的线程来说的,线程在对阻塞队列进程操作时会被阻塞)

阻塞队列是一个容量具有上限的队列不满足读写条件的时候,就要进行阻塞对应的线程

三、C++基于阻塞队列的生产消费模型源码

3.1BlockQueue.hpp

cpp 复制代码
#pragma once
#include<iostream>
#include<pthread.h>
#include<unistd.h>
#include<queue>
#include<cstdio>
const size_t DEFULT_CAPACITY  = 5;
namespace MyBlockQueue
{
    template<class T>
    class BlockQueue
    {
    private:
        bool IsFull()
        {
            return _queue.size() == _capacity ;
        }
        bool IsEmpty()
        {
            return _queue.empty();
        }
    public:
        BlockQueue(int cap = DEFULT_CAPACITY)
            :_capacity(cap)
            ,sleep_c(0)
            ,sleep_p(0)
        {
            pthread_mutex_init(&_mutex,nullptr);
            pthread_cond_init(&_cond_c,nullptr);
            pthread_cond_init(&_cond_p,nullptr);
        }
        void Equeue(const T& args)
        {
            //生产者调用
            pthread_mutex_lock(&_mutex);
            while(IsFull())
            {
                std::cout<<"生产者进入休眠 sleep_p = "<<sleep_p<<std::endl;
                sleep_p ++ ;
                pthread_cond_wait(&_cond_p,&_mutex);
                sleep_p -- ;
            }
            _queue.push(args);
            if(sleep_c != 0)
            {
                pthread_cond_signal(&_cond_c);
            }
            pthread_mutex_unlock(&_mutex);
        }
        T Pop()
        {
            //消费者调用
            pthread_mutex_lock(&_mutex);
            while(IsEmpty())
            {
                std::cout<<"消费者进入休眠 sleep_c = "<<sleep_c<<std::endl;
                sleep_c ++ ;
                pthread_cond_wait(&_cond_c,&_mutex);
                sleep_c -- ;
            }
            T _data = _queue.front();
            _queue.pop();

            if(sleep_p != 0)
            {
                pthread_cond_signal(&_cond_p);
            }
            pthread_mutex_unlock(&_mutex);
            return _data ;
        }
        ~BlockQueue()
        {
            pthread_mutex_destroy(&_mutex);
            pthread_cond_destroy(&_cond_p);
            pthread_cond_destroy(&_cond_c);
        }
    private:
        std::queue<T> _queue ;
        size_t _capacity ;
        pthread_mutex_t _mutex ;
        pthread_cond_t _cond_p ;
        pthread_cond_t _cond_c ;
        size_t sleep_c ;
        size_t sleep_p ;
    };
}

3.2Main.cc

cpp 复制代码
#include "BlockQueue.hpp"
#include "Task.hpp"
// Producer-Consumer Problem
const size_t THREAD_SIZE = 5;
class ThreadData
{
public:
    ThreadData(MyBlockQueue::BlockQueue<task_t>* bqueue , char* name)
        :_bqueue(bqueue)
        ,_name(name)
    {}

    MyBlockQueue::BlockQueue<task_t>* _bqueue ;
    char* _name ;
};
void *Produce(void *args)
{
    MyBlockQueue::BlockQueue<task_t>* _bqueue = 
    static_cast<ThreadData*>(args)->_bqueue ;
    while(true)
    {
        std::cout<<"生产一个任务"<<std::endl;
        _bqueue->Equeue(DownLoadTask);
    }
    delete[](static_cast<ThreadData*>(args)->_name) ;
}
void *Consumer(void *args)
{
    sleep(3);
    MyBlockQueue::BlockQueue<task_t>* _bqueue = 
    static_cast<ThreadData*>(args)->_bqueue ;
    while(true)
    {
        std::cout<<"消费一个任务"<<std::endl;
        task_t task = _bqueue->Pop();
        task();
    }
    delete[](static_cast<ThreadData*>(args)->_name) ;
}
int main()
{
    std::vector<pthread_t> pnums;
    std::vector<pthread_t> cnums;
    MyBlockQueue::BlockQueue<task_t>* bqueue = new MyBlockQueue::BlockQueue<task_t>() ;
    
    // 生产者创建
    for (int i = 0; i < THREAD_SIZE; i++)
    {
        pthread_t tid = 0;
        char *name = new char[64];
        int n = snprintf(name, 64, "PThread-%d", i);
        (void)n;
        ThreadData* data = new ThreadData(bqueue , name);
        pthread_create(&tid, nullptr, Produce, data);
        pnums.push_back(tid);
    }
    // 消费者创建
    for (int i = 0; i < THREAD_SIZE; i++)
    {
        pthread_t tid = 0;
        char *name = new char[64];
        int n = snprintf(name, 64, "CThread-%d", i);
        (void)n;
        ThreadData* data = new ThreadData(bqueue , name);
        pthread_create(&tid, nullptr, Consumer, data);
        cnums.push_back(tid);
    }
    // 回收生产者
    for (auto e : pnums)
    {
        pthread_join(e,nullptr);
    }
    //回收消费者
    for (auto e : cnums)
    {
        pthread_join(e,nullptr);
    }
    return 0;
}

3.3task.hpp

cpp 复制代码
#pragma once 
#include<functional>
using task_t = std::function<void(void)>;

void DownLoadTask(void)
{
    std::cout<<"这是一个下载任务"<<std::endl;
}

3.4 阻塞队列实现中的关键细节与坑点解析

  在使用条件变量与阻塞队列实现生产者消费者模型时,存在几个非常隐蔽但极其关键的细节问题。

3.4.1 条件判断的陷阱:为什么必须用 while 而不是 if

  在某些代码实现中,容易写出如下逻辑:

cpp 复制代码
// 存在严重 Bug 的逻辑
if (IsFull()) 
{
    _psleep_num++;
    pthread_cond_wait(&_full_cond, &_mutex); // 被唤醒后直接向下执行
    _psleep_num--;
}
_q.push(in); // 若遭遇伪唤醒或广播唤醒,会向已满队列强行插入数据!

  隐患场景分析:

  1. 伪唤醒(Spurious Wakeup) :pthread_cond_wait 可能在没有任何线程显式调用 pthread_cond_signal 的情况下,因系统信号或内核调度等原因异常返回。
  2. 广播唤醒(pthread_cond_broadcast) :假设在多生产者场景下,队列已满,5 个生产者线程都在等待。此时消费者消费了 1 个 元素并调用 pthread_cond_broadcast 一次性唤醒所有 5 个生产者。

  第一个抢到锁的生产者成功插入元素,队列重新变满 。当第一个生产者释放锁后,剩下的 4 个生产者会依次拿到锁并从 pthread_cond_wait 返回。由于使用了 if 语句,这 4 个生产者不会再次检查队列状态,而是直接执行 _q.push(in),导致向已满的队列中插入过量数据,引发数据溢出或逻辑崩溃。

解决方案:

  必须将 if 替换为 while 循环校验:

cpp 复制代码
// 正确写法:循环检查条件
while (IsFull()) 
{
    _psleep_num++;
    pthread_cond_wait(&_full_cond, &_mutex);
    _psleep_num--;
}
_q.push(in); // 重新拿到锁且百分之百确定 IsFull() 为 false 后才执行插入

  只要线程被唤醒并重新拿到锁,就必须重新回到 while 开头检测条件,条件不满足则继续挂起等待。

3.4.2 pthread_cond_wait的内部细节与锁的状态变迁

  理解 pthread_cond_wait 的内部执行流程是彻底搞懂条件变量的关键:

cpp 复制代码
// 挂起前:自动释放锁 -> 挂起等待
// 唤醒后:被唤醒 -> 重新竞争锁 -> 拿到锁后返回
pthread_cond_wait(&_full_cond, &_mutex);
  1. 自动释放锁 :函数调用成功后,在挂起当前线程前,系统会自动且原子地释放 传入的 _mutex 锁,避免持有锁休眠引发死锁。
  2. 重新申请锁 :当线程被唤醒时,默认是在临界区内被唤醒的。要从 pthread_cond_wait 成功返回,当前线程必须重新申请并成功拿到 _mutex 锁。
  3. 锁竞争阻塞:如果被唤醒后申请锁失败,该线程会在互斥锁的等待队列上阻塞等待,直到成功获取到锁才会从函数返回。

3.4.3 先唤醒还是先解锁:Signal 与 Unlock 的顺序抉择

  在生产/消费完毕准备唤醒对端线程时,存在两种编写顺序:

cpp 复制代码
// 写法一:先唤醒,再解锁(推荐)
pthread_cond_signal(&_empty_cond);
pthread_mutex_unlock(&_mutex);

// 写法二:先解锁,再唤醒
pthread_mutex_unlock(&_mutex);
pthread_cond_signal(&_empty_cond);

逻辑对比与工程实践:

  • 写法一(先唤醒,再解锁):唤醒等待线程后,被唤醒线程从条件变量等待队列转移到锁的等待队列。随后当前线程解锁,被唤醒线程竞争锁成功后继续执行。
  • 写法二(先解锁,再唤醒) :先解锁,若此时有第三方线程抢先抢到锁并修改了状态,后续的 signal 仍会让等待线程转移到锁等待队列中。

  从理论上看,写法二似乎避免了"唤醒后发现锁未释放"的额外步骤;但在 Linux NPTL 实现中,内核对写法一有 wait-morphing (requeue) 优化,直接在内核层将线程从条件变量队列移入锁等待队列,不会产生虚假的上下文切换开销。为了保证代码严谨性与逻辑一致性,工程规范及 POSIX 标准更推荐写法一(先唤醒,再解锁)。


好的本期内容就到这里,如果对你有帮助,还不要忘记点赞三联支持。我是此方,我们下期再见。bye! Linux、C++、算法持续连载中,欢迎关注WeChat Official Account 【此方的技术栈】。

相关推荐
爱签AI电子合同1 小时前
电子合同上手成本怎么测?易用性维度专项测评
服务器·人工智能·智能合约·企业微信
aixingkong9213 小时前
Nvidia为例 XPU的Multi-Host、Socket Direct 、GPUDirect、RDMA 技术点分析
服务器·网络·硬件架构·硬件工程
雯宝6 小时前
mnt_init 函数
linux
律宏阔12 小时前
WSL Docker 端口明明空闲,但就是绑不上端口
linux·windows
律宏阔12 小时前
WSL 突然断网,无法 ping 内网或外网
linux·windows
穷人小水滴12 小时前
用容器编译 VirtualBox 虚拟机软件 (ArchLinux, podman)
linux·容器·virtualbox
乱码三千12 小时前
如何优雅地直连无公网 IP 的远程 GPU 服务器
linux·人工智能·程序员
yunwei3712 小时前
使用 eBPF 跟踪 Nginx 请求
linux·后端·性能优化
yunwei3712 小时前
使用 eBPF 跟踪 MySQL 查询
linux·后端·性能优化
yunwei3712 小时前
eBPF 示例教程:使用 XDP 捕获 TCP 信息
linux·后端·性能优化