【C++】:使用 mutex + deque 实现一个简单的 LockedQueue

一、为什么需要线程安全队列

在 C++ 中,我们经常使用 std::queuestd::deque 等容器保存数据。例如:

复制代码
std::deque<int> queue;
queue.push_back(1);
queue.push_back(2);

在单线程程序中这样使用没有什么问题,但是如果多个线程同时操作同一个队列,就可能产生数据竞争。

例如现在有两个线程:

复制代码
std::thread t1([&]() { queue.push_back(1); });
std::thread t2([&]() { queue.push_back(2); });

两个线程可能同时进入 push_back(),共同修改 queue 内部的数据结构。

需要注意的是:

复制代码
std::deque
std::queue
std::vector

这些 STL 容器本身并不会自动帮我们解决多个线程同时读写的问题。

因此最直接的解决方案就是:

复制代码
普通队列
    ↓
加入 mutex
    ↓
每次操作队列之前先加锁
    ↓
操作结束后释放锁
    ↓
线程安全队列

例如:

复制代码
std::mutex mutex;
std::deque<int> queue;

线程操作队列的时候:

复制代码
mutex.lock();
queue.push_back(1);
mutex.unlock();

这样同一时刻只能有一个线程操作队列。

假设线程1已经获得锁:

复制代码
线程1:获得 mutex
        ↓
     push_back()
        ↓
      释放锁

线程2此时也想操作队列:

复制代码
线程2:尝试获得 mutex
        ↓
      获取失败
        ↓
        等待
        ↓
线程1释放 mutex
        ↓
线程2获得 mutex

这就是线程安全队列最基础的实现思路。

二、LockedQueue 的基本结构

可以把线程安全队列封装成一个模板类:

复制代码
#include <deque>
#include <mutex>

template<class T, typename StorageType = std::deque<T>>
class LockedQueue
{
private:
    std::mutex _lock;       // 保护队列的互斥锁
    StorageType _queue;     // 真正保存数据的容器
    bool _canceled;         // 队列是否已经取消

public:
    LockedQueue() : _canceled(false) {}
};

这里最核心的其实只有两个成员:

复制代码
std::mutex _lock;
StorageType _queue;

其中 _queue 负责保存数据,而 _lock 负责保护 _queue

模板:

复制代码
template<class T, typename StorageType = std::deque<T>>

表示这个队列保存的数据类型由 T 决定,同时默认使用:

复制代码
std::deque<T>

作为底层容器。

例如:

复制代码
LockedQueue<int> queue;

此时内部实际上可以理解成:

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

如果创建:

复制代码
LockedQueue<std::string> queue;

那么内部就是:

复制代码
std::deque<std::string> _queue;

因此 LockedQueue 可以保存不同类型的数据。

整个结构可以简单理解成:

复制代码
LockedQueue<int>

┌─────────────────────┐
│ mutex _lock         │
│        ↓            │
│ ┌─────────────────┐ │
│ │ 1 │ 2 │ 3 │ 4   │ │
│ └─────────────────┘ │
│      _queue         │
└─────────────────────┘

只要所有对 _queue 的操作都经过 _lock,就能够避免多个线程同时修改队列。

三、实现线程安全的入队操作

首先实现最基本的入队操作:

复制代码
void add(const T& item)
{
    _lock.lock();           // 操作队列之前先加锁
    _queue.push_back(item); // 将数据加入队尾
    _lock.unlock();         // 操作完成后释放锁
}

例如:

复制代码
LockedQueue<int> queue;
queue.add(10);
queue.add(20);
queue.add(30);

队列中的数据就是:

复制代码
队头                队尾
 ↓                    ↓
10  →  20  →  30

其中:

复制代码
_queue.push_back(item);

负责真正添加数据。

而:

复制代码
_lock.lock();
_lock.unlock();

负责保证同一时刻只能有一个线程执行这段操作。

假设两个线程同时调用:

复制代码
queue.add(1);
queue.add(2);

实际执行过程可能是:

复制代码
线程1
  ↓
获得锁
  ↓
push_back(1)
  ↓
释放锁

线程2
  ↓
获得锁
  ↓
push_back(2)
  ↓
释放锁

因此两个线程不会同时修改 _queue

不过在实际 C++ 开发中,一般更加推荐使用 RAII 的方式管理锁:

复制代码
void add(const T& item)
{
    std::lock_guard<std::mutex> lock(_lock);
    _queue.push_back(item);
}

这里:

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

创建时自动执行加锁,函数结束时自动释放锁。

可以理解为:

复制代码
进入函数
   ↓
创建 lock_guard
   ↓
mutex.lock()
   ↓
操作 queue
   ↓
函数结束
   ↓
lock_guard 析构
   ↓
mutex.unlock()

与手动:

复制代码
_lock.lock();
_queue.push_back(item);
_lock.unlock();

相比,lock_guard 最大的好处就是不用担心中途 return 或发生异常导致忘记释放锁。

所以简单的临界区代码通常推荐:

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

四、实现线程安全的出队操作

只有入队还不够,还需要能够安全地从队列中取出数据。

可以实现一个 next()

复制代码
bool next(T& result)
{
    std::lock_guard<std::mutex> lock(_lock);

    if (_queue.empty()) return false;

    result = _queue.front(); // 取出队头元素
    _queue.pop_front();      // 删除队头元素
    return true;
}

这里的逻辑非常重要。

首先:

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

获得互斥锁。

然后判断:

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

如果队列已经没有数据,就直接返回 false

如果有数据:

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

先把队头元素保存到 result 中。

然后:

复制代码
_queue.pop_front();

删除队头元素。

最后:

复制代码
return true;

表示成功取到了数据。

例如队列:

复制代码
10 → 20 → 30

执行:

复制代码
int value;
queue.next(value);

首先:

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

此时:

复制代码
value = 10

然后:

复制代码
_queue.pop_front();

队列变成:

复制代码
20 → 30

因此 next() 实际上完成了:

复制代码
获取队头元素
     +
删除队头元素

为什么必须把:

复制代码
empty()
front()
pop_front()

全部放在同一把锁里面?

因为如果分开加锁,就可能出现这种情况:

复制代码
线程1:检查队列不为空
              ↓
           释放锁

线程2:取走最后一个元素

线程1:继续 front()

此时线程1再访问队头就会出现问题。

所以:

复制代码
if (_queue.empty()) return false;
result = _queue.front();
_queue.pop_front();

应该看成一个完整操作,整个过程都由同一把锁保护。

这也是多线程编程中非常重要的一点:

不仅仅是单独保护某一行代码,更重要的是保护一个完整的逻辑操作。

有了 next() 之后,就可以这样不断读取队列:

复制代码
int value;
while (queue.next(value))
{
    std::cout << value << std::endl;
}

只要队列里面还有数据:

复制代码
queue.next(value)

就返回 true

当队列为空时返回 false,循环结束。

五、多线程测试 LockedQueue

现在把前面的内容组合起来,实现一个简单的线程安全队列:

复制代码
#include <deque>
#include <mutex>

template<class T, typename StorageType = std::deque<T>>
class LockedQueue
{
private:
    std::mutex _lock;       // 保护队列
    StorageType _queue;     // 保存数据
    bool _canceled;         // 取消标志

public:
    LockedQueue() : _canceled(false) {}

    // 添加元素
    void add(const T& item)
    {
        std::lock_guard<std::mutex> lock(_lock);
        _queue.push_back(item);
    }

    // 获取并删除队头元素
    bool next(T& result)
    {
        std::lock_guard<std::mutex> lock(_lock);

        if (_queue.empty()) return false;

        result = _queue.front();
        _queue.pop_front();
        return true;
    }

    // 判断队列是否为空
    bool empty()
    {
        std::lock_guard<std::mutex> lock(_lock);
        return _queue.empty();
    }

    // 取消队列
    void cancel()
    {
        std::lock_guard<std::mutex> lock(_lock);
        _canceled = true;
    }

    // 判断队列是否已经取消
    bool cancelled()
    {
        std::lock_guard<std::mutex> lock(_lock);
        return _canceled;
    }
};

接下来创建两个生产线程向队列添加数据,再创建两个线程读取数据:

复制代码
#include <iostream>
#include <thread>
#include "LockedQueue.h"

int main()
{
    LockedQueue<int> queue;

    // 生产者线程1
    std::thread producer1([&]()
    {
        queue.add(1);
        queue.add(2);
        queue.add(3);
        queue.add(4);
    });

    // 生产者线程2
    std::thread producer2([&]()
    {
        queue.add(5);
        queue.add(6);
        queue.add(7);
        queue.add(8);
    });

    // 消费者线程1
    std::thread consumer1([&]()
    {
        int value;
        while (queue.next(value))
        {
            std::cout << std::this_thread::get_id() << " : pop " << value << std::endl;
        }
    });

    // 消费者线程2
    std::thread consumer2([&]()
    {
        int value;
        while (queue.next(value))
        {
            std::cout << std::this_thread::get_id() << " : pop " << value << std::endl;
        }
    });

    producer1.join();
    producer2.join();
    consumer1.join();
    consumer2.join();

    return 0;
}

这里一共创建了四个线程:

复制代码
producer1 ──→ 添加 1 2 3 4 ──┐
                              │
                              ↓
                         LockedQueue
                              ↑
                              │
producer2 ──→ 添加 5 6 7 8 ──┘

                              ↓
                     ┌────────┴────────┐
                     ↓                 ↓
                 consumer1         consumer2
                  取出数据           取出数据

两个生产者线程:

复制代码
producer1
producer2

不断调用:

复制代码
queue.add();

向队列中添加数据。

两个消费者线程:

复制代码
consumer1
consumer2

不断调用:

复制代码
queue.next();

获取数据。

因为 add()next() 内部都使用了同一把:

复制代码
std::mutex _lock;

所以无论有多少线程操作这个队列,同一时刻都只能有一个线程真正访问 _queue

需要注意一个细节,这个简单版本还有一个明显的问题:

复制代码
while (queue.next(value))

当消费者执行 next() 时,如果队列刚好为空,它会直接返回 false

也就是说:

复制代码
消费者发现队列为空
        ↓
next() 返回 false
        ↓
消费者线程结束

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

例如:

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

      ↓

生产者线程
      ↓
添加数据

这样生产者后面加入的数据就没人处理了。

因此当前这个 LockedQueue 解决的是:

多个线程同时操作队列时的数据安全问题。

但是它还没有解决:

队列为空时,消费者应该如何等待生产者的问题。

如果让消费者不断这样检查:

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

又会产生大量无意义的循环,占用 CPU。

更好的办法是:

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

队列没数据
   ↓
消费者进入等待状态

生产者添加数据
   ↓
通知消费者
   ↓
消费者被唤醒
   ↓
继续取数据

而实现这个过程就需要 C++ 多线程中的另一个重要工具:

复制代码
std::condition_variable

这也会进一步形成经典的:

复制代码
生产者 ------→ 线程安全队列 ------→ 消费者

生产者消费者模型。

总结

这一部分实现的 LockedQueue 本质上并不复杂,它的核心结构就是:

复制代码
std::deque
    +
std::mutex
    =
简单线程安全队列

其中最重要的几个操作是:

复制代码
_queue.push_back(item); // 入队
_queue.front();         // 获取队头
_queue.pop_front();     // 删除队头

而所有这些操作外面都通过:

复制代码
std::mutex

进行保护。

其中:

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

利用 RAII 自动完成加锁和解锁,是简单临界区中非常常见的写法。

到这里解决的是:

复制代码
多个线程同时操作队列
        ↓
mutex 保证同一时间只有一个线程访问
        ↓
避免数据竞争

但是还存在:

复制代码
队列为空
   ↓
消费者应该怎么办?

这个问题。

因此在线程安全队列的基础上,下一步就可以继续加入:

复制代码
std::condition_variable

让消费者在队列为空时进入等待,在生产者加入数据之后再将消费者唤醒,从而实现一个更加完整的生产者消费者队列

0voice · GitHub

相关推荐
(Charon)1 小时前
【C++】线程安全队列(三):MPSC无锁队列、atomic与链表节点实现
c++·安全·链表
星轨初途1 小时前
LeetCode 热题 100——day10 和为 K 的子数组
开发语言·c++·算法·leetcode
sycmancia1 小时前
Qt——自定义控件温度计
开发语言·qt
有点。2 小时前
C++广度优先搜索(二)-练习题
c++·算法·宽度优先
Q741_1472 小时前
TcpDump 使用笔记
网络·c++·笔记·测试工具·tcpdump
程序猿编码2 小时前
不用PyTorch,不用CUDA,我用C++手写了一个能跑GPT和Whisper的推理库
c++·pytorch·gpt·大模型·whisper
MC皮蛋侠客2 小时前
TDengine C++ 系列(1):全景与最小闭环——从时序数据到第一个 C++ 读写
大数据·c++·tdengine
UQR2 小时前
一、Java基础高频面试题
java·开发语言
MoonBit月兔2 小时前
MoonBit 受邀亮相 SPLASH/ISSTA 2026,与 Java、Rust、Eiffel、D 语言核心贡献者同场交流
开发语言·后端·rust