《从零入门Linux系统篇(五十六):线程篇·九——生产者消费者模型:从条件变量到BlockingQueue实现》

这篇文章,我们把生产者消费者模型彻底拆开讲透。先摆出它的"321"原则,说清它凭什么能解耦、又凭什么高效;再落到代码上,用C++实现一个BlockingQueue;最后钻进细节里,把条件变量那些绕不开的坎,while校验、伪唤醒、锁的变迁,一个个剖开看明白。

目录

一、认识生产者消费者模型------从并发问题到线程协作

[1.1 什么是生产者消费者模型](#1.1 什么是生产者消费者模型)

[1.2 一个模型怎么拆开------三种关系、两个角色与一个交易场所](#1.2 一个模型怎么拆开——三种关系、两个角色与一个交易场所)

[1.2.1 三种关系------生产、消费与协作](#1.2.1 三种关系——生产、消费与协作)

[1.2.2 两个角色------生产者与消费者](#1.2.2 两个角色——生产者与消费者)

[1.2.3 一个交易场所------数据如何在两者之间流转](#1.2.3 一个交易场所——数据如何在两者之间流转)

[1.3 为什么需要生产者消费者模型](#1.3 为什么需要生产者消费者模型)

[1.3.1 传统串行处理为什么效率有限](#1.3.1 传统串行处理为什么效率有限)

[1.3.2 生产者消费者模型解决了什么问题](#1.3.2 生产者消费者模型解决了什么问题)

二、BlockingQueue------连接生产者与消费者的中间环节

三、用阻塞队列实现生产者消费者模型

[3.1 BlockQueue.hpp:阻塞队列的实现](#3.1 BlockQueue.hpp:阻塞队列的实现)

[3.2 Main.cc:生产者与消费者的运行入口](#3.2 Main.cc:生产者与消费者的运行入口)

[3.3 Task.hpp:任务对象的定义](#3.3 Task.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:唤醒与解锁的顺序如何选择)


一、认识生产者消费者模型------从并发问题到线程协作

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

多线程编程里,生产者消费者模型是个绕不开的经典范式,专门解决数据共享和线程协作。把它搁进生活里,就像一出戏:**生产者是埋头造货的工厂,消费者是掏钱买货的顾客。**两边并不直接一手交钱一手交货,中间隔着个超市,交易全在超市里完成。

这个模型的本质,就是拿一块由特定数据结构构成的内存空间当缓冲区,把产生数据的线程(生产者)和处理数据的线程(消费者)拆开、解耦。数据高效地流动,任务有条不紊地调度,两头各忙各的,谁也不卡谁。

1.2 一个模型怎么拆开------三种关系、两个角色与一个交易场所

要把这个模型理解得既严谨又系统,可以把它高度凝练成一套"321"原则。

1.2.1 三种关系------生产、消费与协作

缓冲区本质上是一块被多个线程同时访问的临界资源。为了保证数据的正确与一致,生产者与消费者之间必须守住三种约束:

生产者与生产者之间------互斥。 多个生产者同时往货架上摆商品,稍不留神就可能位置冲突,或者把别人刚放好的数据覆盖掉。所以生产者之间也是互斥的,同样存在竞争。

消费者与消费者之间------互斥。 货架上商品少的时候,多个消费者同时伸手去拿同一件,数据一致性就崩了。消费者之间,同样得互斥。

生产者与消费者之间------互斥与同步并存。

  • 互斥:生产者往缓冲区里写的时候,消费者不能同时读。不然读到的可能是还没构建完的残缺数据,半成品,没法用。

  • 同步:缓冲区满了,生产者就得挂起等待,直到消费者取走数据腾出空间;缓冲区空了,消费者也得挂起等待,直到生产者送来新货。

1.2.2 两个角色------生产者与消费者

生产者:由专门的执行流(线程)承担,负责抓取外部数据、构建任务,然后塞进缓冲区。

消费者:同样由专门的执行流(线程)承担,负责从缓冲区里取数据或任务,执行具体的业务逻辑。

1.2.3 一个交易场所------数据如何在两者之间流转

交易场所:在内存里,它就是一块临界资源,通常由阻塞队列(BlockingQueue)、环形缓冲区(RingBuffer)这类特定数据结构来实现,充当数据的中转缓冲。

1.3 为什么需要生产者消费者模型
1.3.1 传统串行处理为什么效率有限

先看看老路子是怎么走的。在单线程或常规函数调用里,main()里调一个fun(a, b, c),会发生什么?

  • main()的执行流必须停下来,干等着fun()把参数处理完。
  • 等fun()跑完、结果返回,main()才敢继续往下走。

这种强耦合的机制,把调用方(生产者)和被调用方(消费者)在时间线上死死绑成了一串。一个动,另一个才能动;一个停,另一个也得干等。资源利用率,自然高不到哪去。

1.3.2 生产者消费者模型解决了什么问题

换成生产者消费者模型,局面立刻不一样了。

解耦:生产者不用操心数据被谁消费、什么时候消费,只管往缓冲区里扔。消费者也不用追着生产者要数据,只管从缓冲区里取。两边不直接照面,模块之间的耦合度一下就降下来了。

支持忙闲不均:缓冲区在这里扮演了蓄水池和缓冲垫的角色。工厂临时停产?没关系,超市里还有库存顶着。消费者少?也无所谓,生产者可以铆足劲生产,把仓库填满。两端的处理能力差异,被缓冲区抹平了。

极大提高整体并发效率:这里很多初学者会犯嘀咕,往缓冲区存取数据,不还得加锁互斥吗?入队、出队的过程明明是串行的,凭什么说它高效?

关键在于:效率的提升,根本不体现在临界区里那点加锁存取上,而体现在临界区之外。

  • 生产者与消费者之间的并发:生产者在获取外部数据、构建任务,比如网络IO接收、数据解析,这些活,可以和消费者的处理过程同时进行。

  • 消费者与消费者之间的并发:多个消费者从缓冲区拿到任务后,可以同时并发地去执行那些耗时业务逻辑,复杂计算、磁盘写入,各跑各的,谁也不用等谁。

锁只锁住了"交接"那一瞬间,真正耗时的活儿,全在锁外面并行跑着。这才是这个模型效率翻倍的真正秘密。

二、BlockingQueue------连接生产者与消费者的中间环节

在多线程编程里,阻塞队列(Blocking Queue)是撑起生产者消费者模型的主力数据结构。它跟普通队列最大的区别,就在"阻塞"这两个字上:

  • 队列空了,还想取元素?对不起,取不了。操作会被阻塞,一直等到有生产者往里头塞进新元素为止。

  • 队列满了,还想放元素?也不行。操作同样被阻塞,一直等到有消费者从里头取走元素、腾出空间为止。

当然,这些阻塞都发生在不同线程之间。一个线程在阻塞队列上操作时,一旦条件不满足,它自己就会被挂起。

一句话概括:阻塞队列是一个容量有上限的队列。读写条件不满足的时候,对应的线程就得乖乖阻塞,等条件成熟再动。

三、用阻塞队列实现生产者消费者模型

理论讲完了,接下来动手写代码。下面这个BlockQueue.hpp,就是我们自己实现的阻塞队列。它用互斥锁保安全,用两个条件变量分别管理生产者和消费者的等待与唤醒,是一个标准的生产者消费者模型底座。

3.1 BlockQueue.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.2 Main.cc:生产者与消费者的运行入口

这段Main.cc是生产者消费者模型的主程序,把BlockQueue真正跑起来。它创建了5个生产者线程和5个消费者线程,生产者往队列里丢任务,消费者从队列里取任务并执行。

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.3 Task.hpp:任务对象的定义

先看这个任务定义的头文件:

cpp 复制代码
#pragma once
#include <functional>

using task_t = std::function<void(void)>;

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

短短几行,却把生产者消费者模型里"任务"这个角色定死了。

task_t是一个类型别名,本质上是std::function<void(void)>,一个不接受参数、也不返回任何东西的可调用对象。有了它,生产者往队列里塞的就不只是冷冰冰的数据,而是"一段待执行的逻辑"。消费者从队列里取出来的,也不是单纯的数字或字符串,而是一个可以立刻task()调用的动作。

DownLoadTask就是一个具体的任务实现,简单到只打印一行字。但它示范了任务的统一形态:不管未来是下载、计算、写日志还是发网络请求,只要签名对得上,都能塞进这个task_t里,被生产者丢进队列,再由消费者取出来执行。

这层抽象,正是生产者消费者模型"解耦"二字的物理落点:生产者只管造任务,消费者只管跑任务,中间隔着一个队列,谁也不知道对方是谁。

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

用条件变量和阻塞队列搭生产者消费者模型,有几个细节藏得极深,但个个致命。

3.4.1 为什么条件判断必须使用while而不是if

有些实现里,会写出这么一段逻辑:

cpp 复制代码
// 存在严重 Bug 的逻辑
if (IsFull()) 
{
    _psleep_num++;
    pthread_cond_wait(&_full_cond, &_mutex); // 被唤醒后直接往下走
    _psleep_num--;
}
_q.push(in); // 万一碰上伪唤醒或广播唤醒,就会往已满的队列里硬塞数据!

问题出在哪?两种场景,都能让它当场翻车。

场景一:伪唤醒(Spurious Wakeup)。 pthread_cond_wait有可能在没有任何线程调用pthread_cond_signal的情况下,因为系统信号或内核调度之类的原因,莫名其妙地返回。没人叫它,它自己醒了。这时候条件根本没变,你却以为可以往下走了。

场景二:广播唤醒(pthread_cond_broadcast)。 假设多生产者场景下,队列已经满了,5个生产者线程全都堵在等待上。这时消费者取走1个元素,调用pthread_cond_broadcast,一口气把 5 个生产者全叫醒。

第一个抢到锁的生产者,成功插入元素,队列重新变满。等它释放锁,剩下4个生产者依次拿到锁,从pthread_cond_wait里返回。可因为用的是if,它们压根不会再检查队列状态,直接执行 _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);

**自动释放锁:**函数调用成功后,在挂起当前线程之前,系统会自动且原子地释放传入的_mutex锁。这一手很关键,锁不释放,别的线程就进不来,条件永远改变不了,持有锁休眠就是死锁的温床。

**重新申请锁:**线程被唤醒时,默认是站在临界区外被叫醒的。要从pthread_cond_wait成功返回,它必须重新申请并成功拿到_mutex锁。不是醒了就算数,醒了还得重新排队抢锁。

**锁竞争阻塞:**如果被唤醒后申请锁失败,说明锁被别人抢走了。这时候线程不会干瞪眼,而是乖乖在互斥锁的等待队列上阻塞,直到成功获取到锁,才从函数返回。

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标准都更推荐写法一:先唤醒,再解锁。 逻辑更一致,代码更严谨,还不用担心第三方线程插队捣乱。


如果这篇文章对你有帮助,别忘了点个赞、点个收藏、点个关注。你的每一次反馈,都是我继续硬核输出的最大动力。

相关推荐
张小姐的猫1 小时前
【Linux】网络编程 —— 五种IO模型
linux·运维·服务器·网络·c++·人工智能·php
程序员-Benothing1 小时前
Shell脚本调试与最佳实践:set -eux、shellcheck实战
linux·运维·服务器
海宇AI1 小时前
零信任架构实战:基于海宇活体识别V步骤1构建自动化直播开播鉴权网关
运维·人工智能·架构·自动化
code_slave(码畜)1 小时前
微服务架构落地:基础服务 —— 报表服务(上篇:定位、边界与整体架构)
java·spring boot·spring cloud·微服务·架构
重生之小比特1 小时前
【C++进阶】02 Linux基本指令
java·数据库·c++
(Charon)1 小时前
【C++面试】手写智能指针(一):从unique_ptr理解独占所有权与移动语义
开发语言·c++
李游Leo2 小时前
HarmonyOS 7 + Spatial Recon Kit-C++:3DGS 输入帧内参与位姿的版本一致性门禁【鸿蒙心迹】
c++·3d·harmonyos
qetfw2 小时前
Debian iSCSI Target 与 open-iscsi:LVM 后端、ACL 与客户端验证
linux·服务器·debian·php