【C++】线程安全队列(三):MPSC无锁队列、atomic与链表节点实现

一、什么是 MPSC 队列

前面实现线程安全队列时,主要使用:

复制代码
std::mutex

保护共享队列。

例如生产者添加数据:

复制代码
std::lock_guard<std::mutex> lock(_queueLock);
_queue.push(value);

消费者获取数据:

复制代码
std::lock_guard<std::mutex> lock(_queueLock);
value = _queue.front();
_queue.pop();

这种方式很好理解,但是每次操作共享队列都需要:

复制代码
加锁
 ↓
访问队列
 ↓
解锁

当多个线程频繁访问队列时,就会出现多个线程竞争同一把锁的情况。

于是还可以考虑另一种实现思路:

不使用传统的 mutex 保护整个队列,而是利用 std::atomic 提供的原子操作完成线程之间的同步。

这就会涉及无锁队列(Lock-Free Queue)

本篇先介绍其中非常典型的一种:

复制代码
MPSC Queue

MPSC 全称:

复制代码
Multi Producer Single Consumer

即:

复制代码
Multi Producer
多个生产者

Single Consumer
单个消费者

结构可以理解为:

复制代码
生产者1 ───┐
           │
生产者2 ───┼──→ MPSC Queue ───→ 消费者
           │
生产者3 ───┘

也就是说:

复制代码
多个线程可以同时向队列写数据
            +
只有一个线程负责从队列取数据

例如日志系统中:

复制代码
网络线程 ─────┐
              │
数据库线程 ───┼──→ 日志队列 ───→ 日志写入线程
              │
工作线程 ─────┘

多个线程都会产生日志,但是可以只安排一个线程真正将日志写入文件。

这种场景就非常适合 MPSC 模型。

和前面的生产者消费者队列相比:

复制代码
mutex队列:

生产者
   ↓
 mutex
   ↓
 queue
   ↓
 mutex
   ↓
消费者

MPSC 无锁队列则更倾向于:

复制代码
生产者
   ↓
atomic
   ↓
链表节点
   ↓
atomic
   ↓
消费者

这里的"无锁"主要指队列核心同步过程不依赖:

复制代码
std::mutex

而是使用:

复制代码
std::atomic

完成线程之间的同步。

二、MPSC队列的核心结构:Node、head和tail

先来看队列内部最关键的数据结构:

复制代码
template<typename T>
class MPSCQueue
{
private:
    struct Node
    {
        T* Data;                  // 当前节点保存的数据
        std::atomic<Node*> Next;  // 指向下一个节点

        Node() : Data(nullptr), Next(nullptr) {}

        explicit Node(T* data) : Data(data), Next(nullptr) {}
    };

    std::atomic<Node*> _head; // 生产者操作的位置
    std::atomic<Node*> _tail; // 消费者操作的位置
};

std::queue 不同,这里的队列底层实际上是一条单向链表

复制代码
Node
┌─────────────┐
│ Data        │
│ Next ───────────→
└─────────────┘

每一个节点包含两个东西:

复制代码
T* Data;

负责保存真正的数据。

以及:

复制代码
std::atomic<Node*> Next;

保存下一个节点的地址。

为什么 Next 不是普通的:

复制代码
Node* Next;

而是:

复制代码
std::atomic<Node*> Next;

因为生产者和消费者可能同时访问节点之间的连接关系。

如果使用普通指针:

复制代码
Node* Next;

多个线程之间直接读写可能产生数据竞争。

因此这里将指针本身声明为原子变量:

复制代码
std::atomic<Node*> Next;

整个链表大概是:

复制代码
┌──────┐      ┌──────┐      ┌──────┐
│ Node │ ───→ │ Node │ ───→ │ Node │ ───→ nullptr
│ 100  │      │ 200  │      │ 300  │
└──────┘      └──────┘      └──────┘

除了节点以外,还有两个重要指针:

复制代码
std::atomic<Node*> _head;
std::atomic<Node*> _tail;

可以先简单理解成:

复制代码
_head:生产者主要操作的位置
_tail:消费者主要操作的位置

但这个实现初始化时不会直接让队列完全为空,而是先创建一个哑节点(Dummy Node)

复制代码
MPSCQueue() : _head(new Node()), _tail(_head.load(std::memory_order_relaxed))
{
    Node* front = _head.load(std::memory_order_relaxed);
    front->Next.store(nullptr, std::memory_order_relaxed);
}

刚创建队列时:

复制代码
             head
              ↓
        ┌───────────┐
tail ─→ │ DummyNode │ ─→ nullptr
        └───────────┘

也就是:

复制代码
_head
  ↓
Dummy
  ↑
_tail

这个 Dummy 节点并不保存真正需要处理的数据。

它主要用于统一入队和出队操作,减少对:

复制代码
第一个元素
最后一个元素
空队列

这些特殊情况的额外判断。

例如真正加入数据以后:

复制代码
Dummy → Node100 → Node200 → Node300

Dummy 节点始终可以作为队列结构中的一个过渡节点。

这种:

复制代码
Dummy Node
哑节点 / 虚拟头节点

在链表、队列以及很多无锁数据结构中都非常常见。

三、Enqueue:多个生产者如何同时添加数据

MPSC 最关键的地方就是:

多个生产者如何在不使用 mutex 的情况下安全入队?

入队函数如下:

复制代码
void Enqueue(T* input)
{
    Node* node = new Node(input);                                             // 创建新节点
    Node* prevHead = _head.exchange(node, std::memory_order_acq_rel);         // 原子交换head
    prevHead->Next.store(node, std::memory_order_release);                    // 将旧head连接到新节点
}

代码只有三句,但是这三句就是整个 MPSC 入队操作的核心。

首先:

复制代码
Node* node = new Node(input);

为新数据创建一个节点。

例如:

复制代码
queue.Enqueue(new Count(100));

首先创建:

复制代码
┌─────────────┐
│ Data = 100  │
│ Next=null   │
└─────────────┘

接下来:

复制代码
Node* prevHead = _head.exchange(node, std::memory_order_acq_rel);

这里的:

复制代码
exchange()

是一个非常重要的原子操作。

假设:

复制代码
_head → Node100

现在执行:

复制代码
_head.exchange(Node200);

它会完成两件事情:

复制代码
第一步:
把 _head 修改为 Node200

第二步:
返回原来的 Node100

所以:

复制代码
Node* prevHead = _head.exchange(node);

执行之后:

复制代码
prevHead → Node100

_head → Node200

关键就在于:

exchange() 整个过程是原子的。

假设有两个生产者:

复制代码
Producer1
Producer2

同时执行:

复制代码
_head.exchange(node);

不会出现两个线程把 _head 修改乱掉的情况。

原子变量会保证这些操作表现出一个确定的先后顺序。

例如最开始:

复制代码
_head → Dummy

生产者1加入:

复制代码
Node100

执行:

复制代码
Node* prevHead = _head.exchange(Node100);

得到:

复制代码
prevHead → Dummy
_head    → Node100

然后执行:

复制代码
prevHead->Next.store(Node100);

形成:

复制代码
Dummy → Node100

接下来生产者2添加:

复制代码
Node200

执行:

复制代码
Node* prevHead = _head.exchange(Node200);

得到:

复制代码
prevHead → Node100
_head    → Node200

然后:

复制代码
prevHead->Next.store(Node200);

最终:

复制代码
Dummy → Node100 → Node200
                       ↑
                     _head

如果继续添加 Node300:

复制代码
Dummy → Node100 → Node200 → Node300
                                 ↑
                               _head

因此生产者每次实际上做的是:

复制代码
创建新节点
    ↓
原子交换 _head
    ↓
获得旧 head
    ↓
旧 head 的 Next 指向新节点

也就是:

复制代码
Node* prevHead = _head.exchange(node, std::memory_order_acq_rel);
prevHead->Next.store(node, std::memory_order_release);

这里为什么不用:

复制代码
_head = node;

因为多个生产者可能同时修改 _head

普通赋值并不能完成:

复制代码
读取旧值
+
写入新值
+
返回旧值

这一整套不可分割的操作。

而:

复制代码
exchange()

可以原子地完成这一过程。

这也是无锁代码中非常常见的写法。

这里还涉及两个内存序:

复制代码
std::memory_order_acq_rel
std::memory_order_release

可以先简单理解成:

复制代码
exchange(..., acq_rel)

既具有 acquire 的读取语义,也具有 release 的发布语义。

而:

复制代码
Next.store(node, std::memory_order_release);

表示生产者完成节点内容之后,再把这个节点正式发布到链表中。

这样消费者后面配合:

复制代码
load(std::memory_order_acquire)

读取时,就能够正确看到生产者已经准备好的节点数据。

所以生产者一侧可以概括为:

复制代码
准备数据
   ↓
release 发布
   ↓
消费者 acquire 获取

四、Dequeue:单消费者如何从队列中取出数据

再来看消费者:

复制代码
bool Dequeue(T*& result)
{
    Node* tail = _tail.load(std::memory_order_relaxed);
    Node* next = tail->Next.load(std::memory_order_acquire);

    if (!next) return false;

    result = next->Data;
    _tail.store(next, std::memory_order_release);

    delete tail;

    return true;
}

由于 MPSC 的特点是:

复制代码
多个 Producer
一个 Consumer

所以真正执行 Dequeue() 的只有一个消费者线程。

首先:

复制代码
Node* tail = _tail.load(std::memory_order_relaxed);

获取当前 _tail

假设现在:

复制代码
_tail
  ↓
Dummy → Node100 → Node200

那么:

复制代码
tail

指向:

复制代码
Dummy

然后:

复制代码
Node* next = tail->Next.load(std::memory_order_acquire);

获取下一个节点。

此时:

复制代码
tail
 ↓
Dummy → Node100
          ↑
         next

如果:

复制代码
next == nullptr

说明当前没有可以取出的节点:

复制代码
if (!next) return false;

如果存在节点:

复制代码
result = next->Data;

把数据返回给调用者。

例如:

复制代码
next->Data = 100

那么:

复制代码
result → 100

接着:

复制代码
_tail.store(next, std::memory_order_release);

_tail 向后移动。

原来:

复制代码
_tail
  ↓
Dummy → Node100 → Node200

现在:

复制代码
         _tail
           ↓
Dummy → Node100 → Node200

然后:

复制代码
delete tail;

删除原来的旧节点:

复制代码
删除 Dummy

最终:

复制代码
_tail
  ↓
Node100 → Node200

注意这里有一个比较容易第一次看懵的地方。

执行:

复制代码
result = next->Data;

取到的是:

复制代码
next节点的数据

但是:

复制代码
delete tail;

删除的却是:

复制代码
上一个节点

为什么不是直接:

复制代码
delete next;

这是因为当前的 next 还需要继续充当新的尾部节点。

例如:

复制代码
Dummy → Node100 → Node200

第一次出队:

复制代码
取出 Node100 的 Data
删除 Dummy
_tail = Node100

变成:

复制代码
Node100 → Node200
 ↑
tail

第二次出队:

复制代码
取出 Node200 的 Data
删除 Node100
_tail = Node200

所以每次都是:

复制代码
读取 next 的数据
      +
删除旧 tail
      +
让 next 成为新的 tail

这其实也是 Dummy Node 设计带来的好处。

整个出队流程可以总结成:

复制代码
读取 tail
   ↓
找到 tail->Next
   ↓
Next不存在?
   ↓ 是
队列暂时没有数据

   ↓ 否
获取 next->Data
   ↓
tail移动到next
   ↓
删除旧tail
   ↓
返回true

这里生产者发布节点时:

复制代码
prevHead->Next.store(node, std::memory_order_release);

消费者读取节点时:

复制代码
tail->Next.load(std::memory_order_acquire);

正好构成:

复制代码
Producer
release
   ↓
节点数据发布
   ↓
acquire
Consumer

这样消费者看到:

复制代码
Next != nullptr

以后,也能够正确看到这个节点之前已经准备好的:

复制代码
Data

这就是 acquire/release 在这个队列中的一个实际应用。

五、完整MPSC队列与多线程测试

把前面的结构组合起来,可以得到一个简化后的 MPSC 非侵入式队列:

复制代码
#ifndef MPSC_QUEUE_H
#define MPSC_QUEUE_H

#include <atomic>

template<typename T>
class MPSCQueue
{
private:
    struct Node
    {
        T* Data;
        std::atomic<Node*> Next;

        Node() : Data(nullptr), Next(nullptr) {}
        explicit Node(T* data) : Data(data), Next(nullptr) {}
    };

    std::atomic<Node*> _head;
    std::atomic<Node*> _tail;

public:
    // 创建Dummy节点,head和tail最开始都指向它
    MPSCQueue() : _head(new Node()), _tail(_head.load(std::memory_order_relaxed)) {}

    // 禁止拷贝
    MPSCQueue(const MPSCQueue&) = delete;
    MPSCQueue& operator=(const MPSCQueue&) = delete;

    ~MPSCQueue()
    {
        T* data;

        // 清理队列中还没有取出的数据
        while (Dequeue(data)) delete data;

        // 删除最后留下的Dummy节点
        Node* tail = _tail.load(std::memory_order_relaxed);
        delete tail;
    }

    // 多个生产者可以调用Enqueue
    void Enqueue(T* input)
    {
        Node* node = new Node(input);

        // 原子替换head,并获得之前的head
        Node* prevHead = _head.exchange(node, std::memory_order_acq_rel);

        // 将旧head连接到新节点
        prevHead->Next.store(node, std::memory_order_release);
    }

    // 只允许一个消费者调用Dequeue
    bool Dequeue(T*& result)
    {
        Node* tail = _tail.load(std::memory_order_relaxed);

        // 获取tail后面的节点
        Node* next = tail->Next.load(std::memory_order_acquire);

        // 没有数据
        if (!next) return false;

        // 获取数据
        result = next->Data;

        // tail向后移动
        _tail.store(next, std::memory_order_release);

        // 删除旧tail
        delete tail;

        return true;
    }
};

#endif

测试时先定义一个简单的数据类型:

复制代码
struct Count
{
    explicit Count(int value) : v(value) {}
    int v;
};

然后创建:

复制代码
MPSCQueue<Count> queue;

两个生产者不断向队列添加数据,一个消费者负责获取:

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

int main()
{
    MPSCQueue<Count> queue;
    std::atomic<int> producerFinished{0};

    // 生产者1
    std::thread producer1([&]()
    {
        queue.Enqueue(new Count(100));
        queue.Enqueue(new Count(200));
        queue.Enqueue(new Count(300));
        queue.Enqueue(new Count(400));

        ++producerFinished;
    });

    // 生产者2
    std::thread producer2([&]()
    {
        queue.Enqueue(new Count(500));
        queue.Enqueue(new Count(600));
        queue.Enqueue(new Count(700));
        queue.Enqueue(new Count(800));

        ++producerFinished;
    });

    // MPSC只有一个消费者
    std::thread consumer([&]()
    {
        Count* data = nullptr;

        while (true)
        {
            if (queue.Dequeue(data))
            {
                std::cout << "pop : " << data->v << std::endl;
                delete data;
                continue;
            }

            // 两个生产者都结束,并且当前已经取不到数据,则结束消费
            if (producerFinished.load() == 2) break;

            std::this_thread::yield();
        }
    });

    producer1.join();
    producer2.join();
    consumer.join();

    return 0;
}

这里线程关系非常清楚:

复制代码
producer1
   │
   ├──100
   ├──200
   ├──300
   └──400
       │
       ↓
   ┌─────────┐
   │         │
   │  MPSC   │ ─────→ consumer
   │  Queue  │
   │         │
   └─────────┘
       ↑
       │
   ┌──500
   ├──600
   ├──700
   └──800
producer2

和上一篇最大的区别是,上一篇使用:

复制代码
std::mutex
std::condition_variable

实现的是:

复制代码
加锁
等待
唤醒

这一篇则主要依靠:

复制代码
std::atomic<Node*>

以及:

复制代码
exchange()
load()
store()

维护链表。

其中最关键的入队操作只有:

复制代码
Node* prevHead = _head.exchange(node, std::memory_order_acq_rel);
prevHead->Next.store(node, std::memory_order_release);

而消费者最关键的逻辑是:

复制代码
Node* next = tail->Next.load(std::memory_order_acquire);

因此整个 MPSC 队列可以概括成:

复制代码
多个生产者
      ↓
atomic exchange
      ↓
不断向链表尾部连接新节点
      ↓
   MPSC Queue
      ↓
单个消费者
      ↓
沿着 Next 顺序取出数据

学习这一部分最需要掌握的并不是一开始就把整个无锁队列背下来,而是理解下面几个知识点之间的关系:

复制代码
std::atomic<Node*>        // 原子指针
exchange()                // 原子交换新旧head
load()                    // 原子读取
store()                   // 原子写入
memory_order_acquire      // 获取其他线程发布的数据
memory_order_release      // 发布当前线程已经完成的数据

另外还要特别注意:

MPSC 的前提是 Multiple Producer + Single Consumer。

多个线程可以同时执行:

复制代码
Enqueue();

但是这个版本不能随意让多个消费者同时执行:

复制代码
Dequeue();

否则原本"只有一个消费者操作 tail"的设计前提就被破坏了。

从前面三篇串起来看,线程安全队列的学习路线其实非常清楚:

复制代码
第一步
deque + mutex
↓
理解最基本的线程安全

第二步
queue + mutex + condition_variable
↓
理解生产者消费者、等待和唤醒

第三步
链表 + atomic + MPSC
↓
理解不依赖mutex的并发队列

其中:

复制代码
mutex

更容易编写和理解,适合绝大多数常规业务场景;

而:

复制代码
atomic + 无锁数据结构

实现难度更高,需要进一步理解 CAS、内存序以及线程之间的数据可见性,一般用于对并发性能要求较高的场景。

因此实际开发中并不是看到无锁队列就一定要替换 mutex,而是应该根据并发量、实现复杂度和实际性能需求进行选择。

0voice · GitHub

相关推荐
(Charon)2 小时前
【C++】:使用 mutex + deque 实现一个简单的 LockedQueue
开发语言·c++
星轨初途2 小时前
LeetCode 热题 100——day10 和为 K 的子数组
开发语言·c++·算法·leetcode
有点。2 小时前
C++广度优先搜索(二)-练习题
c++·算法·宽度优先
Q741_1473 小时前
TcpDump 使用笔记
网络·c++·笔记·测试工具·tcpdump
神奇小梵3 小时前
安全和开发的需要了解的知识————下载内容的安全,和下载内容如何运行
android·windows·安全·个人开发
程序猿编码3 小时前
不用PyTorch,不用CUDA,我用C++手写了一个能跑GPT和Whisper的推理库
c++·pytorch·gpt·大模型·whisper
MC皮蛋侠客3 小时前
TDengine C++ 系列(1):全景与最小闭环——从时序数据到第一个 C++ 读写
大数据·c++·tdengine
MindUp3 小时前
企业网盘选型的技术评估维度与主流产品架构简析
人工智能·安全·架构
生活皆是风景3 小时前
抢占AI采信,问答布局正当时
大数据·安全·搜索引擎