
◆ 博主名称: 小此方-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):
- main() 执行流必须暂停,等待 fun() 内部将传入的参数处理完毕。
- 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); // 若遭遇伪唤醒或广播唤醒,会向已满队列强行插入数据!
隐患场景分析:
- 伪唤醒(Spurious Wakeup) :
pthread_cond_wait可能在没有任何线程显式调用pthread_cond_signal的情况下,因系统信号或内核调度等原因异常返回。 - 广播唤醒(
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);
- 自动释放锁 :函数调用成功后,在挂起当前线程前,系统会自动且原子地释放 传入的
_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 标准更推荐写法一(先唤醒,再解锁)。
好的本期内容就到这里,如果对你有帮助,还不要忘记点赞三联支持。我是此方,我们下期再见。bye! Linux、C++、算法持续连载中,欢迎关注WeChat Official Account 【此方的技术栈】。