无锁队列设计

小捏哩 2026-03-16 14:13:01

无锁队列设计

目录

  • 无锁队列设计
  • 1. 为什么需要无锁队列?
  • 2. 无锁编程基本概念
  • 2.1 阻塞(Blocking)、无锁(Lock-Free)与无等待(Wait-Free)
  • 2.2 无锁编程的挑战
  • 3. 无锁队列的分类
  • 4. SPSC环形缓冲区实现
  • 4.1 基本设计要点
  • 4.2 ringBuffer实现(带详细注释)
  • 4.3 关键点解析
  • 5. MPSC链表实现
  • 5.1 设计思路
  • 5.2 非侵入式实现
  • 5.3 侵入式实现
  • 6. 无锁队列设计中的考虑
  • 6.1 内存序的正确选择
  • 6.2 内存管理
  • 6.4 性能调优
  • 7. 性能对比与适用场景
  • 8. 总结

在多线程编程中,队列是一种常用的数据结构,用于在生产者和消费者之间传递数据。然而,传统的基于锁的队列在高并发场景下会引入性能瓶颈:线程阻塞、上下文切换、缓存行失效等。无锁队列(Lock-Free Queue)通过原子操作和精心设计的数据结构,避免了锁的开销,在某些场景下能大幅提升性能。本文将深入探讨无锁队列的设计思想、常见实现及注意事项。


1. 为什么需要无锁队列?

多线程环境下的锁竞争会带来以下问题:

  • 线程切换开销:当锁被持有时,其他线程必须等待,操作系统可能进行上下文切换,耗时可达微秒级。
  • 缓存损坏(Cache Pollution):锁的争用会导致内核调度器干预,使CPU缓存失效,后续重新加载数据需要时间。
  • 任务执行时间不确定:在硬实时系统或信号处理程序中,无法容忍阻塞等待。
  • 优先级反转:低优先级线程持有锁,高优先级线程等待,可能导致系统不可预测。

无锁队列允许多个线程并发访问而不使用互斥锁,通过原子操作(如CAS、Exchange)来保证数据一致性,从而避免上述问题。


2. 无锁编程基本概念

2.1 阻塞(Blocking)、无锁(Lock-Free)与无等待(Wait-Free)

  • 阻塞(Blocking):使用互斥锁、信号量等同步机制,线程在无法获取资源时会进入休眠,由操作系统调度唤醒。
  • 无锁(Lock-Free):整个系统的整体进度有保证,即任意线程被暂停,其他线程仍能继续完成操作。通常通过原子操作实现,但单个线程可能面临重试(如CAS失败)。
  • 无等待(Wait-Free):在无锁的基础上更进一步,保证每个线程的操作在有限步内完成,不会因为其他线程的干扰而无限重试。实现难度极高。

2.2 无锁编程的挑战

  • 内存管理:在多生产者多消费者场景下,如何安全释放节点内存?常用技术有Hazard PointerRCU(读-复制-更新)、引用计数等。
  • 内存顺序(Memory Ordering):原子操作需要配合恰当的内存顺序(如memory_order_acquire/release/relaxed)以保证跨线程的可见性和有序性。

3. 无锁队列的分类

根据生产者和消费者的数量,无锁队列可分为:

类型全称适用场景
SPSCSingle Producer Single Consumer单一生产者和单一消费者,实现最简单,性能最高
SPMCSingle Producer Multiple Consumers单一生产者,多个消费者(较少见)
MPSCMultiple Producers Single Consumer多个生产者,单一消费者,如日志收集、任务分发
MPMCMultiple Producers Multiple Consumers多个生产者,多个消费者,通用但实现复杂

本文将重点讲解 SPSC环形缓冲区MPSC链表队列 的实现。


4. SPSC环形缓冲区实现

环形缓冲区(Ring Buffer)基于固定大小的数组,通过头尾索引实现FIFO。SPSC场景下,生产者和消费者分别操作tail_head_索引,通过原子操作保证线程安全。

4.1 基本设计要点

  • 数组容量:设为2的幂次,以便将取模运算优化为位与运算:next = (curr + 1) & (cap - 1)
  • 存储任意类型:使用std::aligned_storage预留内存,通过placement new构造/析构对象,支持非POD类型。
  • 伪共享(False Sharing):将head_tail_和缓冲区数据放在不同的缓存行(通常64字节),避免频繁同步导致性能下降。
  • 内存序:利用memory_order_acquire/release保证写操作在消费者读之前可见,且读操作能看到最新的写入。

4.2 ringBuffer实现(带详细注释)

#pragma once

#include <atomic>
#include <utility>   // std::forward
#include <cstddef>   // std::size_t

// SPSC 环形缓冲区,容量必须为2的幂
template <typename T, std::size_t Capacity>
class ringBuffer {
public:
    static_assert(Capacity && ((Capacity & (Capacity - 1)) == 0),
                  "Capacity must be a power of 2");

    ringBuffer() : head_(0), tail_(0) {}

    ~ringBuffer() {
        // 析构时,若队列中还有未消费的元素,需要手动调用析构函数
        std::size_t head = head_.load(std::memory_order_relaxed);
        std::size_t tail = tail_.load(std::memory_order_relaxed);
        while (head != tail) {
            reinterpret_cast<T*>(&buffer_[head])->~T();
            head = (head + 1) & (Capacity - 1);
        }
    }

    // 万能引用,支持左值和右值
    template <typename U>
    bool Push(U&& value) {
        std::size_t tail = tail_.load(std::memory_order_relaxed);
        std::size_t next_tail = (tail + 1) & (Capacity - 1);

        // 检查队列是否满:下一个tail位置等于head,表示无空位
        if (next_tail == head_.load(std::memory_order_acquire)) {
            return false;   // 队列满
        }

        // 在缓冲区尾部构造对象(placement new)
        new (&buffer_[tail]) T(std::forward<U>(value));

        // 更新tail,使用release语义确保之前的构造对其他线程可见
        tail_.store(next_tail, std::memory_order_release);
        return true;
    }

    bool Pop(T& value) {
        std::size_t head = head_.load(std::memory_order_relaxed);
        if (head == tail_.load(std::memory_order_acquire)) {
            return false;   // 队列空
        }

        // 移动取出元素(使用move优化,避免拷贝)
        value = std::move(*reinterpret_cast<T*>(&buffer_[head]));

        // 显式析构对象
        reinterpret_cast<T*>(&buffer_[head])->~T();

        // 更新head,使用release语义保证之前的析构和移动操作对其他线程可见
        head_.store((head + 1) & (Capacity - 1), std::memory_order_release);
        return true;
    }

    // 返回当前队列中元素个数(注意:非原子快照,仅用于调试)
    std::size_t Size() const {
        const std::size_t head = head_.load(std::memory_order_relaxed);
        const std::size_t tail = tail_.load(std::memory_order_relaxed);
        return tail >= head ? tail - head : Capacity - (head - tail);
    }

private:
    // 将head_和tail_各自对齐到64字节,避免与buffer_共享缓存行
    alignas(64) std::atomic<std::size_t> head_;
    alignas(64) std::atomic<std::size_t> tail_;

    // 存储元素的原始内存,保证对齐
    alignas(64) std::aligned_storage_t<sizeof(T), alignof(T)> buffer_[Capacity];
};

4.3 关键点解析

  • 容量为2的幂(tail + 1) & (Capacity - 1) 等价于 (tail + 1) % Capacity,但位运算更快。
  • 内存对齐alignas(64)强制将变量放在64字节边界,避免与相邻变量共享缓存行,减少伪共享。
  • 内存序选择
    • tail_.load(std::memory_order_relaxed):生产者读自己的tail,不需要与其他线程同步。
    • head_.load(std::memory_order_acquire):需要看到消费者最新的head值,保证判满的准确性。
    • tail_.store(..., release):使之前对缓冲区的写入在消费者看到新tail时可见。
  • 对象生命周期:使用placement new构造,手动析构,确保非POD类型正确释放资源。
  • 右值支持Push接受万能引用,完美转发,避免不必要的拷贝。

5. MPSC链表实现

当有多个生产者但只有一个消费者时,可以采用基于链表的MPSC队列。链表结构可以动态增长,不受固定容量限制,适合任务数不确定的场景。

5.1 设计思路

  • head 指针始终指向最新插入的节点,多个生产者通过原子exchange竞争插入。
  • tail 指针指向最早插入的节点(即队列头),只有消费者会移动它。
  • 入队时,生产者创建一个新节点,将其next置为nullptr,然后将head原子交换为新节点,同时得到旧head,再将旧headnext指向新节点。这样就形成了一个从旧head指向新head的链表(实际上是逆序的,但消费者从tail正向遍历)。
  • 出队时,消费者检查tail->next,若不为空则移动tail,取出数据并删除原tail节点。

5.2 非侵入式实现

非侵入式队列中,节点包含数据指针和next指针,内存由队列管理。

#ifndef MPSC_QUEUE_NON_INTRUSIVE_H
#define MPSC_QUEUE_NON_INTRUSIVE_H

#include <atomic>
#include <utility>

template<typename T>
class MPSCQueueNonIntrusive {
public:
    MPSCQueueNonIntrusive() : _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);
    }

    ~MPSCQueueNonIntrusive() {
        T* output;
        while (Dequeue(output))
            delete output;       // 释放数据对象

        Node* front = _head.load(std::memory_order_relaxed);
        delete front;            // 释放最后一个节点(可能是dummy)
    }

    // 多生产者入队(wait-free)
    void Enqueue(T* input) {
        Node* node = new Node(input);
        Node* prevHead = _head.exchange(node, std::memory_order_acq_rel);
        prevHead->Next.store(node, std::memory_order_release);
    }

    // 单消费者出队
    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;            // 删除原tail节点
        return true;
    }

private:
    struct Node {
        Node() = default;
        explicit Node(T* data) : Data(data) {
            Next.store(nullptr, std::memory_order_relaxed);
        }

        T* Data;
        std::atomic<Node*> Next;
    };

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

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

#endif

说明

  • 构造函数中创建了一个dummy节点,使headtail初始指向它,避免空指针判断。
  • Enqueue_head.exchange(node, acq_rel)原子地将_head更新为新节点,并返回旧head。然后设置旧head的Next指向新节点。这一步保证了多生产者并发时的正确链接。
  • Dequeue:消费者从tail开始,如果tail->Next存在,则取出数据,更新tail,并删除原tail节点。
  • 内存序
    • exchange使用acq_rel:读取旧head需要acquire,写入新head需要release,同时保证对旧head的后续操作可见。
    • Next.store使用release:确保节点完全构造后再让消费者看到。
    • Next.load使用acquire:保证看到生产者设置的Next

5.3 侵入式实现

侵入式队列要求节点类型T内部包含一个std::atomic<T*>成员作为next指针,队列操作直接利用该成员,无需额外分配节点对象,节省内存,减少缓存缺失。

#ifndef MPSC_QUEUE_INTRUSIVE_H
#define MPSC_QUEUE_INTRUSIVE_H

#include <atomic>
#include <type_traits>
#include <new>

template<typename T, std::atomic<T*> T::* IntrusiveLink>
class MPSCQueueIntrusive {
public:
    MPSCQueueIntrusive()
        : _dummyPtr(reinterpret_cast<T*>(std::addressof(_dummy))),
          _head(_dummyPtr),
          _tail(_dummyPtr) {
        // 只初始化dummy节点的IntrusiveLink成员(因为T可能不可默认构造)
        std::atomic<T*>* dummyNext = new (&(_dummyPtr->*IntrusiveLink)) std::atomic<T*>();
        dummyNext->store(nullptr, std::memory_order_relaxed);
    }

    ~MPSCQueueIntrusive() {
        T* output;
        while (Dequeue(output)) {
            delete output;   // 注意:这里删除的是数据对象本身,其内存可能由外部管理,需谨慎
        }
    }

    // 入队
    void Enqueue(T* input) {
        (input->*IntrusiveLink).store(nullptr, std::memory_order_release);
        T* prevHead = _head.exchange(input, std::memory_order_acq_rel);
        (prevHead->*IntrusiveLink).store(input, std::memory_order_release);
    }

    // 出队
    bool Dequeue(T*& result) {
        T* tail = _tail.load(std::memory_order_relaxed);
        T* next = (tail->*IntrusiveLink).load(std::memory_order_acquire);

        // 如果tail是dummy节点,需要特殊处理:跳过dummy
        if (tail == _dummyPtr) {
            if (!next) return false;          // 队列空
            _tail.store(next, std::memory_order_release);
            tail = next;
            next = (next->*IntrusiveLink).load(std::memory_order_acquire);
        }

        if (next) {
            _tail.store(next, std::memory_order_release);
            result = tail;
            return true;
        }

        // 此时tail可能是最后一个节点,需要检查是否有新节点刚入队
        T* head = _head.load(std::memory_order_acquire);
        if (tail != head)          // 如果head已经更新,但tail->Next尚未设置(可能刚执行exchange但未设置Next)
            return false;          // 让消费者重试?此处简单返回false,实际可自旋或依赖外部重试

        // 队列为空,但需要将dummy重新入队以便下次使用(防止tail超过head)
        Enqueue(_dummyPtr);
        next = (tail->*IntrusiveLink).load(std::memory_order_acquire);
        if (next) {
            _tail.store(next, std::memory_order_release);
            result = tail;
            return true;
        }
        return false;
    }

private:
    std::aligned_storage_t<sizeof(T), alignof(T)> _dummy;
    T* _dummyPtr;
    std::atomic<T*> _head;
    std::atomic<T*> _tail;

    MPSCQueueIntrusive(const MPSCQueueIntrusive&) = delete;
    MPSCQueueIntrusive& operator=(const MPSCQueueIntrusive&) = delete;
};

#endif

要点

  • _dummy是一个aligned_storage,只用来占位,不构造T对象,仅初始化其IntrusiveLink成员。
  • 出队逻辑复杂:因为存在dummy节点,且可能出现刚入队但next尚未设置的情况,需要特殊处理。
  • 使用std::conditional_t可以定义一个统一的MPSCQueue别名,根据是否提供IntrusiveLink自动选择版本。

6. 无锁队列设计中的考虑

6.1 内存序的正确选择

C++11提供了六种内存顺序,理解它们对无锁编程至关重要:

  • memory_order_relaxed:仅保证原子性,无顺序约束。
  • memory_order_acquire:防止之后的内存读写被重排到该操作之前。
  • memory_order_release:防止之前的内存读写被重排到该操作之后。
  • memory_order_acq_rel:读-修改-写操作同时拥有acquire和release语义。
  • memory_order_seq_cst:顺序一致性,最严格但也最慢。

在SPSC中,我们用acquire读对方的索引,用release写自己的索引,保证了队列操作的顺序。在MPSC中,exchange使用acq_rel确保获取旧head的完整可见性。

6.2 内存管理

链式队列需要动态分配节点。频繁的new/delete可能成为性能瓶颈。优化手段包括:

  • 内存池:预先分配一大块内存,节点复用。
  • 侵入式设计:数据对象自身携带next指针,减少一次分配。
  • Hazard Pointer:安全回收内存,避免悬挂指针。

6.4 性能调优

  • 避免伪共享:将频繁修改的变量(如head、tail)分散到不同缓存行。
  • 批量操作:一次push/pop多个元素,分摊原子操作开销。
  • 选择合适的容量:环形缓冲区过大浪费内存,过小增加等待。

7. 性能对比与适用场景

类型优点缺点适用场景
SPSC环形缓冲区极高性能,无锁且无等待容量固定,不适用于动态增长音频处理、实时数据流、单生产者单消费者管道
MPSC链表支持多生产者,动态大小节点分配开销,出队可能稍慢日志聚合、任务队列、事件分发

实际选择时,需根据生产者/消费者数量、对延迟和吞吐的要求、内存限制等因素权衡。


8. 总结

无锁队列是高性能并发编程的重要组件。本文从基础概念出发,详细剖析了SPSC环形缓冲区和MPSC链表队列的实现,并讨论了内存序、内存管理等核心挑战。理解这些设计思想后,读者可以根据实际需求实现或选用合适的无锁队列。
https://blog.csdn.net/qq_57951250/article/details/159044528?spm=1011.2124.3001.6209

https://github.com/0voice

...全文
32 回复 打赏 收藏 转发到动态 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

545

社区成员

发帖
与我相关
我的任务
社区描述
零声学院,目前拥有上千名C/C++开发者,我们致力将我们的学员组织起来,打造一个开发者学习交流技术的社区圈子。
nginx中间件后端 企业社区
社区管理员
  • Linux技术狂
  • Yttsam
  • 零声教育-晚晚
加入社区
  • 近7日
  • 近30日
  • 至今
社区公告

请新加入的VIP学员,先将自己参加活动的【所有文章】,同步至社区:

【内容管理】-【同步至社区-【零声开发者社区】

试试用AI创作助手写篇文章吧