【C++】线程安全队列(二):生产者消费者模型与 condition_variable 阻塞等待

一、从线程安全队列到生产者消费者模型

上一篇中使用:

复制代码
std::mutex

配合:

复制代码
std::deque

实现了一个简单的线程安全队列。

它解决的核心问题是:

复制代码
多个线程同时操作队列
        ↓
使用 mutex 进行保护
        ↓
同一时刻只有一个线程能够修改队列
        ↓
避免数据竞争

但是这种简单队列还有一个问题。

假设消费者不断从队列中取数据:

复制代码
int value;
while (queue.next(value))
{
    // 处理数据
}

如果消费者执行到这里时队列恰好为空:

复制代码
消费者
  ↓
检查队列
  ↓
队列为空
  ↓
next() 返回 false
  ↓
消费者结束

可是生产者可能只是暂时还没有来得及添加数据。

例如:

复制代码
消费者启动
   ↓
发现队列为空
   ↓
直接退出

过了一会

生产者
   ↓
添加任务

这样新加入的数据就没有消费者处理了。

一种简单粗暴的方法是让消费者一直检查:

复制代码
while (true)
{
    if (!queue.empty())
    {
        // 获取任务
    }
}

但是这种方式会导致线程不停循环:

复制代码
检查队列
   ↓
没有数据
   ↓
继续检查
   ↓
还是没有
   ↓
继续检查......

即使没有任何任务,CPU 也一直在工作,这种情况通常称为忙等待(Busy Waiting)

更合理的方式应该是:

复制代码
队列有数据
   ↓
消费者取数据

队列为空
   ↓
消费者睡眠等待

生产者加入数据
   ↓
通知消费者

消费者被唤醒
   ↓
继续取数据

这就是 std::condition_variable 的主要作用。

因此一个比较完整的生产者消费者队列通常需要:

复制代码
std::queue<T> _queue;               // 保存数据
std::mutex _queueLock;              // 保护队列
std::condition_variable _condition; // 控制线程等待和唤醒

三者之间可以简单理解为:

复制代码
                    ┌──────────────┐
生产者 ──Push()──→  │    queue     │  ──Pop()──→ 消费者
                    └──────────────┘
                           ↑
                         mutex
                           ↑
                condition_variable
                  等待 / 唤醒线程

mutex 负责解决:

多个线程能不能同时修改队列?

condition_variable 解决的是:

队列没有数据时,消费者应该怎么办?

二、ProducerConsumerQueue 的基本结构

先来看一个生产者消费者队列需要哪些成员变量:

复制代码
#include <condition_variable>
#include <mutex>
#include <queue>
#include <atomic>

template<typename T>
class ProducerConsumerQueue
{
private:
    std::mutex _queueLock;              // 保护队列
    std::queue<T> _queue;               // 保存生产者产生的数据
    std::condition_variable _condition; // 控制消费者等待和唤醒
    std::atomic<bool> _shutdown;         // 队列是否停止工作

public:
    ProducerConsumerQueue() : _shutdown(false) {}
};

其中 _queue 是真正保存数据的地方:

复制代码
std::queue<T> _queue;

例如:

复制代码
ProducerConsumerQueue<int> queue;

内部就相当于:

复制代码
std::queue<int> _queue;

队列遵循 FIFO:

复制代码
First In First Out
先进先出

例如:

复制代码
push(10)
push(20)
push(30)

队头              队尾
 ↓                  ↓
10  →  20  →  30

消费者第一次取出:

复制代码
10

第二次取出:

复制代码
20

然后是:

复制代码
30

第二个成员:

复制代码
std::mutex _queueLock;

用来保护 _queue

生产者执行:

复制代码
_queue.push(value);

消费者执行:

复制代码
_queue.pop();

这些操作不能被多个线程同时执行,因此需要 _queueLock

第三个成员:

复制代码
std::condition_variable _condition;

它负责消费者线程的:

复制代码
等待
  +
唤醒

最后:

复制代码
std::atomic<bool> _shutdown;

用于表示整个队列是否准备结束。

初始状态:

复制代码
_shutdown = false;

表示:

复制代码
队列正常运行

调用取消操作以后:

复制代码
_shutdown = true;

表示:

复制代码
队列准备停止

这几个成员组合起来,就形成了一个比较典型的生产者消费者队列:

复制代码
ProducerConsumerQueue

┌──────────────────────────────┐
│                              │
│ std::queue<T> _queue         │
│          ↑                   │
│ std::mutex _queueLock        │
│                              │
│ condition_variable           │
│     等待 / 唤醒              │
│                              │
│ atomic<bool> _shutdown       │
│     控制退出                 │
│                              │
└──────────────────────────────┘

三、Push:生产者添加数据并唤醒消费者

生产者向队列添加数据可以实现为:

复制代码
void Push(const T& value)
{
    std::lock_guard<std::mutex> lock(_queueLock); // 获取互斥锁
    _queue.push(value);                           // 数据加入队列
    _condition.notify_one();                      // 唤醒一个等待中的消费者
}

首先:

复制代码
std::lock_guard<std::mutex> lock(_queueLock);

给队列加锁。

然后:

复制代码
_queue.push(value);

将数据加入队列。

例如:

复制代码
queue.Push(10);

队列变成:

复制代码
┌────┐
│ 10 │
└────┘

再次:

复制代码
queue.Push(20);

变成:

复制代码
10 → 20

真正值得注意的是最后一句:

复制代码
_condition.notify_one();

它的意思不是"通知队列",而是:

唤醒一个正在 _condition 上等待的线程。

假设现在消费者因为队列为空进入等待:

复制代码
消费者线程
    ↓
队列为空
    ↓
condition_variable
    ↓
进入等待状态

此时生产者:

复制代码
queue.Push(100);

执行过程:

复制代码
生产者获得锁
    ↓
push(100)
    ↓
队列中出现数据
    ↓
notify_one()
    ↓
唤醒一个消费者

消费者被唤醒以后继续尝试获取数据:

复制代码
                    queue
                      ↑
生产者 ──push()──→ [100]
                      │
               notify_one()
                      ↓
                   消费者
                      ↓
                    唤醒

这里有两个比较容易混淆的函数:

复制代码
notify_one();
notify_all();

notify_one()

唤醒一个等待线程。

notify_all()

唤醒所有等待线程。

生产者每次通常只添加一个任务,因此:

复制代码
_condition.notify_one();

一般就足够了。

例如三个消费者都在等待:

复制代码
消费者1:等待
消费者2:等待
消费者3:等待

生产者加入一个任务:

复制代码
queue.Push(100);

调用:

复制代码
notify_one();

可能只有消费者2被唤醒:

复制代码
消费者1:继续等待
消费者2:被唤醒 → 获取100
消费者3:继续等待

这样避免了明明只有一个任务,却把所有消费者全部叫醒。

另外,有些代码中可能会看到:

复制代码
_queue.push(std::move(value));

如果 Push() 的参数本身是:

复制代码
const T& value

那么这里的 std::move(value) 实际得到的是 const T&&,很多类型最终仍然会发生拷贝,而不是真正的移动。

如果希望真正支持移动,可以额外提供:

复制代码
void Push(T&& value)
{
    std::lock_guard<std::mutex> lock(_queueLock);
    _queue.push(std::move(value));
    _condition.notify_one();
}

不过理解生产者消费者模型时,暂时把重点放在:

复制代码
push
 +
notify_one

这一组操作上即可。

四、Pop 与 WaitAndPop:普通取数据和阻塞等待的区别

消费者取数据可以有两种方式。

第一种是普通的 Pop()

复制代码
bool Pop(T& value)
{
    std::lock_guard<std::mutex> lock(_queueLock);

    if (_queue.empty() || _shutdown) return false;

    value = _queue.front();
    _queue.pop();

    return true;
}

首先获取锁:

复制代码
std::lock_guard<std::mutex> lock(_queueLock);

然后判断:

复制代码
if (_queue.empty() || _shutdown) return false;

只要:

复制代码
队列为空

或者:

复制代码
队列已经停止

就不继续取数据。

如果存在数据:

复制代码
value = _queue.front();
_queue.pop();

先获取队头:

复制代码
value = _queue.front();

再删除队头:

复制代码
_queue.pop();

例如:

复制代码
10 → 20 → 30

执行:

复制代码
queue.Pop(value);

之后:

复制代码
value = 10

队列:
20 → 30

这种 Pop() 有一个特点:

没数据就直接返回,不会等待。

真正体现生产者消费者模型的是 WaitAndPop()

复制代码
void WaitAndPop(T& value)
{
    std::unique_lock<std::mutex> lock(_queueLock);

    while (_queue.empty() && !_shutdown) _condition.wait(lock);

    if (_queue.empty() || _shutdown) return;

    value = _queue.front();
    _queue.pop();
}

这里首先出现:

复制代码
std::unique_lock<std::mutex> lock(_queueLock);

为什么这里不用前面的:

复制代码
std::lock_guard<std::mutex>

而要使用:

复制代码
std::unique_lock<std::mutex>

因为后面需要执行:

复制代码
_condition.wait(lock);

condition_variable::wait() 在等待过程中需要:

复制代码
释放 mutex
   ↓
线程睡眠
   ↓
收到通知
   ↓
重新获取 mutex

这种"中途释放锁、醒来以后重新加锁"的操作需要 unique_lock 配合完成。

最关键的代码就是:

复制代码
while (_queue.empty() && !_shutdown) _condition.wait(lock);

可以先把条件翻译成人话:

复制代码
队列为空
并且
程序还没有关闭

那么:

复制代码
_condition.wait(lock);

消费者进入等待。

假设当前:

复制代码
_queue.empty() == true
_shutdown == false

那么消费者执行:

复制代码
进入 wait
   ↓
释放 mutex
   ↓
消费者线程睡眠

为什么 wait() 必须把锁释放?

因为如果消费者睡着以后还一直拿着 _queueLock

复制代码
消费者拿着锁睡觉
        ↓
生产者 Push()
        ↓
想获得 _queueLock
        ↓
拿不到
        ↓
无法添加数据

这样生产者永远无法把数据放进去,消费者也永远等不到数据。

所以:

复制代码
_condition.wait(lock);

内部非常重要的一件事就是:

等待时自动释放互斥锁。

完整流程可以理解为:

复制代码
消费者执行 WaitAndPop()
        ↓
获得 _queueLock
        ↓
检查队列
        ↓
队列为空
        ↓
wait(lock)
        ↓
释放 _queueLock
        ↓
消费者睡眠
        ↓
--------------------------
        ↓
生产者获得 _queueLock
        ↓
push()
        ↓
notify_one()
        ↓
--------------------------
        ↓
消费者被唤醒
        ↓
重新竞争 _queueLock
        ↓
获得锁
        ↓
重新检查条件
        ↓
front()
        ↓
pop()

这里还有一个非常重要的细节:

复制代码
while (_queue.empty() && !_shutdown)

为什么是 while,而不是:

复制代码
if (_queue.empty() && !_shutdown)

因为条件变量存在虚假唤醒(Spurious Wakeup)

线程被唤醒,并不意味着:

复制代码
队列一定有数据

因此醒来之后还必须重新检查:

复制代码
_queue.empty()

所以通常应该写成:

复制代码
while (条件不满足) condition.wait(lock);

C++ 还提供了更加常见的谓词版本:

复制代码
_condition.wait(lock, [this]() { return !_queue.empty() || _shutdown; });

这句可以理解为:

一直等待,直到队列中有数据,或者整个队列准备退出。

实际上它内部的思想和:

复制代码
while (_queue.empty() && !_shutdown) _condition.wait(lock);

基本一致。

使用谓词以后,WaitAndPop() 可以写成:

复制代码
void WaitAndPop(T& value)
{
    std::unique_lock<std::mutex> lock(_queueLock);

    _condition.wait(lock, [this]() { return !_queue.empty() || _shutdown; });

    if (_queue.empty() || _shutdown) return;

    value = _queue.front();
    _queue.pop();
}

这也是实际编写条件变量代码时很常见的一种写法。

五、Cancel:如何让正在等待的消费者安全退出

最后还需要解决一个问题:

如果程序准备结束,但是消费者还在 wait() 里面睡觉怎么办?

假设现在有三个消费者:

复制代码
消费者1 → wait()
消费者2 → wait()
消费者3 → wait()

此时程序准备退出。

如果什么都不做,这些线程可能仍然处于等待状态。

因此需要提供:

复制代码
void Cancel()

通知整个队列结束。

一个基本实现为:

复制代码
void Cancel()
{
    {
        std::lock_guard<std::mutex> lock(_queueLock);
        _shutdown = true;
    }

    _condition.notify_all();
}

首先:

复制代码
_shutdown = true;

告诉消费者:

复制代码
队列已经停止工作

然后:

复制代码
_condition.notify_all();

把所有正在等待的消费者全部唤醒。

为什么这里不是:

复制代码
notify_one();

因为程序准备退出时,希望:

复制代码
消费者1
消费者2
消费者3

全部醒来并退出,而不是只叫醒其中一个。

执行过程:

复制代码
Cancel()
   ↓
_shutdown = true
   ↓
notify_all()
   ↓
┌─────────┬─────────┬─────────┐
↓         ↓         ↓
消费者1   消费者2   消费者3
唤醒      唤醒      唤醒
↓         ↓         ↓
发现 shutdown == true
↓         ↓         ↓
退出      退出      退出

把前面的内容组合起来,一个简单的生产者消费者队列可以写成:

复制代码
#include <atomic>
#include <condition_variable>
#include <mutex>
#include <queue>

template<typename T>
class ProducerConsumerQueue
{
private:
    std::mutex _queueLock;              // 保护队列
    std::queue<T> _queue;               // 保存数据
    std::condition_variable _condition; // 控制等待和唤醒
    std::atomic<bool> _shutdown;         // 是否停止

public:
    ProducerConsumerQueue() : _shutdown(false) {}

    // 生产者添加数据
    void Push(const T& value)
    {
        {
            std::lock_guard<std::mutex> lock(_queueLock);
            _queue.push(value);
        }

        _condition.notify_one();
    }

    // 普通取数据:没有数据直接返回 false
    bool Pop(T& value)
    {
        std::lock_guard<std::mutex> lock(_queueLock);

        if (_queue.empty() || _shutdown) return false;

        value = _queue.front();
        _queue.pop();
        return true;
    }

    // 阻塞取数据:没有数据就等待
    bool WaitAndPop(T& value)
    {
        std::unique_lock<std::mutex> lock(_queueLock);

        _condition.wait(lock, [this]() { return !_queue.empty() || _shutdown; });

        if (_shutdown && _queue.empty()) return false;

        value = _queue.front();
        _queue.pop();
        return true;
    }

    // 停止队列并唤醒所有消费者
    void Cancel()
    {
        {
            std::lock_guard<std::mutex> lock(_queueLock);
            _shutdown = true;
        }

        _condition.notify_all();
    }
};

然后可以创建一个简单的生产者消费者程序:

复制代码
#include <iostream>
#include <thread>

int main()
{
    ProducerConsumerQueue<int> queue;

    // 消费者:没有数据时会阻塞等待
    std::thread consumer([&]()
    {
        int value;

        while (queue.WaitAndPop(value))
        {
            std::cout << "consumer : " << value << std::endl;
        }
    });

    // 生产者:不断向队列中添加数据
    std::thread producer([&]()
    {
        queue.Push(10);
        queue.Push(20);
        queue.Push(30);
        queue.Push(40);

        queue.Cancel();
    });

    producer.join();
    consumer.join();

    return 0;
}

整个程序的工作流程就是:

复制代码
消费者启动
   ↓
WaitAndPop()
   ↓
队列为空
   ↓
wait() 睡眠
   ↓
════════════════════
   ↓
生产者 Push(10)
   ↓
notify_one()
   ↓
════════════════════
   ↓
消费者被唤醒
   ↓
取出10
   ↓
继续 WaitAndPop()

因此相比上一篇简单的:

复制代码
deque + mutex

这一篇又增加了:

复制代码
condition_variable

最终形成:

复制代码
             mutex
               ↓
生产者 → 线程安全队列 → 消费者
   │                       ↑
   └──── notify_one ───────┘
                           │
                        wait()

这一部分最需要掌握的其实就是下面几组对应关系:

复制代码
std::mutex                    // 保护共享队列
std::lock_guard<std::mutex>   // 简单加锁
std::unique_lock<std::mutex>  // 配合 condition_variable 使用

以及:

复制代码
_condition.wait(lock);   // 消费者等待
_condition.notify_one(); // 唤醒一个消费者
_condition.notify_all(); // 唤醒所有消费者

最终把整个生产者消费者模型概括成一句话就是:

生产者负责向队列中放数据,消费者负责从队列中取数据;mutex 保证队列访问安全,condition_variable 负责在没有数据时让消费者休眠,并在新数据到来后将其唤醒。

理解这一套流程之后,再继续学习线程池会非常自然,因为线程池中的:

复制代码
提交任务
   ↓
任务队列
   ↓
工作线程等待
   ↓
有任务后唤醒
   ↓
执行任务

本质上就是生产者消费者模型的一种典型应用。

0voice · GitHub

相关推荐
liwulin05063 小时前
【PYTHON】使用Selenium + ChromeDriver以及XPATH语法
开发语言·python·selenium
JavaPub-rodert3 小时前
我又把自己的 Go 后台管理系统升级了一遍:文件管理、2GB 上传、私有文件预览、Docker 镜像全安排上了
开发语言·docker·golang·shiyuadmin
动力 continue4 小时前
Python 元类与异常类:类的“制造工厂”与“报错定制师”
开发语言·python·制造
quantdash_cc4 小时前
基于 QuantDash 5 分钟 K 线的网格交易策略参数网格搜索寻优实战
android·开发语言·pandas·量化·quantdash
147API4 小时前
Python批量生成蒸馏数据,并发、重试、幂等和断点续跑
开发语言·jvm·python
青 春 记 忆4 小时前
零基础入门python04:用注册校验理解字典、集合和条件判断
开发语言·vscode·python·python3.11
kels88994 小时前
实战排坑:黄金实时API开发,XAUUSD Tick报文异常处理实践
开发语言·python·websocket·网络协议·信息可视化
阿pin4 小时前
Java随笔-JDK7 HashMap头插法为何能导致死循环?
java·开发语言·hashmap
FBI HackerHarry浩4 小时前
Pandas 库中用于分类变量独热编码(One-Hot Encoding)的函数 pd.get_dummies() 函数
开发语言·人工智能·python·pandas