从零实现 SPSC 无锁队列

本文的目标:带你从"用锁的朴素队列"一步步演进到一个生产级的 SPSC 无锁队列


目录

  1. [SPSC 到底是什么,它有什么用](#SPSC 到底是什么,它有什么用)
  2. [基准版本:用 mutex 的朴素队列](#基准版本:用 mutex 的朴素队列)
  3. 理解环形缓冲区
  4. [第一版无锁 SPSC:朴素原子版](#第一版无锁 SPSC:朴素原子版)
  5. 内存序:最容易翻车的地方
  6. 伪共享:看不见的性能杀手
  7. 缓存索引:再榨一把性能
  8. 生产级实现
  9. 正确性测试与性能基准
  10. 常见陷阱与注意事项

1. SPSC 到底是什么,它有什么用

SPSC = Single Producer, Single Consumer,单生产者单消费者队列。它是所有无锁队列里最简单、性能也最高的一种。

为什么简单?因为只有一个线程写、一个线程读

  • 写入端不需要和其他写入端竞争
  • 读取端不需要和其他读取端竞争
  • 只需要协调"写入端"和"读取端"之间的可见性

这意味着很多实现甚至不需要 CAS(compare-and-swap),只需要普通的原子读写加上正确的内存屏障就够了。

典型用途:

  • 音频线程 ↔ UI 线程之间传递音频块
  • 网卡接收线程 ↔ 数据包处理线程
  • 日志线程的单个写入者 ↔ 刷盘线程
  • 游戏逻辑线程 ↔ 渲染线程的命令队列
  • FPGA/硬件 DMA 的数据搬运

只要你的场景能描述成"一个线程产生数据,另一个线程消费数据",SPSC 就是最合适的选择。永远不要用 MPMC 去做 SPSC 能搞定的事------那是白白浪费性能。


2. 基准版本:用 mutex 的朴素队列

先从最无脑的实现开始,建立一个性能参照;因为std::queue是线程不安全的,所以要加锁:

cpp 复制代码
#include <mutex>
#include <queue>

template <typename T>
class MutexQueue {
public:
    void push(T value) {
        std::lock_guard<std::mutex> lock(mu_);
        q_.push(std::move(value));
    }

    bool try_pop(T& out) {
        std::lock_guard<std::mutex> lock(mu_);
        if (q_.empty()) return false;
        out = std::move(q_.front());
        q_.pop();
        return true;
    }

private:
    std::mutex mu_;
    std::queue<T> q_;
};

这段代码当然是正确的,但有几个明显的问题:

  1. 每次操作都要加锁/解锁,涉及原子操作、可能的系统调用、上下文切换。
  2. 生产者和消费者会互相阻塞,明明它们操作的是队列的不同端,即锁的粒度不够细,明明操作不一定是同一个节点,但是每次操作都是把整个队列锁住。
  3. 堆分配std::queue 默认底层是 std::deque,入队出队会触发堆分配与释放,对缓存极不友好。
  4. mutex 本身会导致缓存行抖动:锁变量在两个核心之间来回弹。

在一个典型的桌面 CPU 上,这种实现每次 push/pop 大约需要 100--300ns 。我们的目标是做到 10ns 级别


3. 理解环形缓冲区

无锁队列几乎总是基于环形缓冲区(ring buffer) 或者链表。SPSC 我们选环形缓冲区,因为它:

  • 内存预分配,无动态分配
  • cache 友好,数据连续
  • 代码简单,只有两个索引需要维护

3.1 基本结构

用一个定长数组加上两个索引:write_idx(生产者的写入位置)和 read_idx(消费者的读取位置)。

3.2 空与满的判断

这里有个经典坑点:如果 write_idx == read_idx 同时代表"空"和"满",就没法区分了。

有两种常见解法:

方法 A:留一个空位(浪费一个槽)

  • 空:write_idx == read_idx
  • 满:(write_idx + 1) % capacity == read_idx

方法 B:使用单调递增的序号,用取模得到实际下标

  • 空:write_idx == read_idx
  • 满:write_idx - read_idx == capacity
  • 数组下标:idx & (capacity - 1)(容量必须是 2 的幂):
    假设容量是8,那么对应下标有0、1、2、3、4、5、6、7共八个;8的二进制表示为00001000,07的二进制表示为0000000000000111;也就是说(capacity - 1) = 00000111,无论idx具体是多少,只要idx & (capacity - 1)就一定会映射到00000000~00000111,但这必须是建立在容量是2的幂的情况下。
    同时这里使用**&操作相比求余操作**计算量更小(&操作单条 CPU 指令,1 个时钟周期完成;除法/取余是CPU最慢的整数运算之一,通常需要 20~90+个时钟周期)。

我们选方法 B,原因是:

  1. 不浪费槽位
  2. 空/满判断更直观
  3. 单调递增的序号对利用无符号整数溢出特性很友好(后面会用到)

示意图:


4. 第一版无锁 SPSC:朴素原子版

先写一版不考虑内存序、不考虑伪共享的"直觉版",看它哪里不对。

cpp 复制代码
// ❌ 有问题的版本,仅用于演示
template <typename T, size_t Capacity>
class SpscQueueBad
{
    // 00001000 & 00000111 = 00000000
    static_assert((Capacity & (Capacity - 1)) == 0, "Capacity must be a power of 2");

public:
    bool try_push(const T &value)
    {
        
        size_t w = write_idx_.load();
        size_t r = read_idx_.load();
        if (w - r >= Capacity)
            return false; // 满
        buffer_[w & (Capacity - 1)] = value;
        write_idx_.store(w + 1);
        return true;
    }

    bool try_pop(T &out)
    {
        size_t r = read_idx_.load();
        size_t w = write_idx_.load();
        if (r == w)
            return false; // 空
        out = buffer_[r & (Capacity - 1)];
        read_idx_.store(r + 1);
        return true;
    }

private:
    std::array<T, Capacity> buffer_;    // 定长数组
    std::atomic<size_t> write_idx_{0};  // 写下标--线性增长
    std::atomic<size_t> read_idx_{0};   // 读下标--线性增长
};

4.1 为什么 SPSC 不需要 CAS?

注意 try_push 只修改 write_idx_try_pop 只修改 read_idx_。因为:

  • 只有一个 生产者线程会碰 write_idx_
  • 只有一个 消费者线程会碰 read_idx_

没有写入端之间的竞争,也就不需要 CAS。普通的 load + store 就够了。

4.2 这版代码有什么问题?

看起来好像没啥问题对吧?但它有两个致命问题

  1. 内存序不正确 :默认的 load/storememory_order_seq_cst(顺序一致),虽然"能工作",但过于保守,在弱内存模型架构(如 ARM)上会插入不必要的屏障。更关键的是,即使用默认的 seq_cst 是对的,如果有人"优化"成 memory_order_relaxed,代码就彻底错了------编译器和 CPU 可能把数据写入重排到索引更新之后,消费者会读到未完成写入的垃圾数据。

  2. 伪共享write_idx_read_idx_ 很可能落在同一条 cache line 上,生产者和消费者会不断抢这条线,性能损失可能高达 10 倍。


5. 选择合适的内存序

5.1 问题的本质

编译器和 CPU 都可能重排指令以提升性能。在单线程内没有问题,但在多线程间,一个线程看到的另一个线程操作的顺序可能和源码不一致。上面分析过,使用memory_order_seq_cst是正确的,但是过于保守,会有不必要的屏障;但也不能无脑替换成memory_order_relaxed,这样会因为重排导致错误,例如下面使用memory_order_relaxed之后会产生的后果:

考虑这段生产者代码:

cpp 复制代码
buffer_[w & mask] = value;     // (1) 写数据
write_idx_.store(w + 1,std::memory_order_acquire);       // (2) 更新索引

执行(1)(2)之前,如果队列空且这两步被重排成 (2) 在 (1) 之前执行,消费者看到 write_idx_ 已更新就会去读 buffer_,读到的是旧值或未初始化数据

同样,消费者这段:

cpp 复制代码
out = buffer_[r & mask];       // (3) 读数据
read_idx_.store(r + 1,std::memory_order_acquire);        // (4) 更新索引

执行(3)(4)之前,如果队列为满且这两步被重排成 (4) 在 (3) 之前执行,生产者看到 read_idx_ 更新就可能覆写 buffer_,消费者读的是被覆盖后的数据

5.2 Release / Acquire 语义

C++ 用 memory_order 描述同步关系。我们只需要两个:

  • release :在当前操作之前的所有读写,不会被重排到它之后。用于"发布"。
  • acquire :在当前操作之后的所有读写,不会被重排到它之前。用于"获取"。

核心规则 :如果线程 A 的 release 写了某个变量,线程 B 以 acquire 读到了同一个变量 的那个值(或更新的值),那么 A 在 release 之前的所有写入对 B 在 acquire 之后都可见

这形成了 happens-before 关系:

5.3 修正后的代码

cpp 复制代码
bool try_push(const T& value) {
    const size_t w = write_idx_.load(std::memory_order_relaxed);  // relaxed
    const size_t r = read_idx_.load(std::memory_order_acquire);   // acquire ①
    if (w - r >= Capacity) return false;
    buffer_[w & (Capacity - 1)] = value;                          // ②
    write_idx_.store(w + 1, std::memory_order_release);           // release ③
    return true;
}

bool try_pop(T& out) {
    const size_t r = read_idx_.load(std::memory_order_relaxed);   // relaxed
    const size_t w = write_idx_.load(std::memory_order_acquire);  // acquire ④
    if (r == w) return false;
    out = buffer_[r & (Capacity - 1)];                            // ⑤
    read_idx_.store(r + 1, std::memory_order_release);            // release ⑥
    return true;
}

每一步的理由

  • 生产者加载自己write_idx_relaxed 就行(对于write_idx_的写操作始终只有生产者一个线程进行写入,所以不会存在多线程的问题)。
  • ① 生产者加载对方read_idx_acquire,是为了"观察"消费者的进度:消费者释放槽位(⑥ release)之后,生产者才能重用这些槽位(② 写入)。
  • ③ 生产者发布 write_idx_release,保证 ② 的数据写入对看到 write_idx_ 更新的消费者可见。
  • ④ 消费者加载 write_idx_acquire,与 ③ 配对,保证看到数据。
  • ⑤ 读数据。
  • ⑥ 消费者发布 read_idx_release,保证 ⑤ 的读取在释放槽位之前完成(否则生产者可能覆写数据,消费者读到新数据)。

5.4 一个直观的类比

把队列想象成一个信箱:

  • 生产者写信(② 写数据),然后挂上"有新信"的牌子(③ release store)。
  • 消费者先看牌子(④ acquire load)------看到了才去开信箱(⑤ 读数据)。
  • 消费者读完后再挂上"已取走"的牌子(⑥ release store)。
  • 生产者下次要投新信之前先看"已取走"的牌子(① acquire load)------确认旧信被取走了才复用槽位(② 写数据)。

release/acquire 保证了**"挂牌子"这个动作前后的操作不会跨越牌子**。


6. 伪共享:看不见的性能杀手

6.1 什么是伪共享(false sharing)

现代 CPU 的缓存以 cache line (典型 64 字节)为最小单位。当两个核心分别频繁修改同一条 cache line 上的不同变量时,会触发缓存一致性协议(MESI 等)不断在核心间传递这条 line 的所有权,即使它们操作的是不同的变量。这就是伪共享

对于上面的代码:

实测下来,伪共享能让性能下降 5--10 倍。这通常是无锁队列最大的性能瓶颈,比内存序还关键。

6.2 解决方案:cache line 对齐

C++17 以后有 std::hardware_destructive_interference_size,但许多编译器(比如 GCC 12 之前)支持不完整,实际项目里直接硬编码 64 字节最稳。

cpp 复制代码
static constexpr size_t kCacheLine = 64;

alignas(kCacheLine) std::atomic<size_t> write_idx_{0};
alignas(kCacheLine) std::atomic<size_t> read_idx_{0};

布局变成:

现在:

  • 生产者修改 write_idx_ 只影响 line 1
  • 消费者修改 read_idx_ 只影响 line 2
  • 两个核心不再互相抢缓存行

6.3 容易忽略的细节:buffer 本身的位置

除了索引,buffer_ 数组也要注意。如果 buffer 紧贴着其中一个索引,可能和索引共享一条 cache line。建议也显式对齐,或者在 buffer 两侧加 padding。

另外,整个 SpscQueue 对象后面如果紧跟着别的变量,也可能产生伪共享(外部对象干扰队列末尾的变量)。生产级实现会在最后加一段 padding 保护。


7. 缓存索引:再榨一把性能

7.1 观察:原子 load 也不是免费的

即使没有伪共享,每次 try_push 都要 load(acquire) 一次 read_idx_,这个原子操作:

  • 在 x86 上等价于普通 load(还好)
  • 在 ARM 上需要 ldar 指令,比普通 load 贵
  • 更关键的是:这个 load 会让 read_idx_ 所在的 cache line 进入生产者核心的共享状态,而消费者要写这条 line 时又得把它从共享变成独占,触发一致性流量

7.2 核心观察

绝大多数情况下,队列不会满也不会空。也就是说:

  • 生产者检查 read_idx_ 时,通常发现还有很多空位
  • 消费者检查 write_idx_ 时,通常发现还有很多元素

那我们何必每次都去读对方的原子变量呢?

7.3 缓存索引的思路 -- 减少load操作

给生产者一个本地变量 cached_read_idx_,保存"上次看到的 read_idx_"。

  • 检查队列是否满时,先用缓存判断:如果缓存显示还有空间,根本不用去碰原子变量
  • 只有缓存显示可能满了,才真的去 load 一次原子值(刷新缓存)

消费者同理,维护一个本地的 cached_write_idx_

7.4 生产者的逻辑

cpp 复制代码
bool try_push(const T& value) {
    const size_t w = write_idx_.load(std::memory_order_relaxed);

    // 先用缓存判断是否满
    if (w - cached_read_idx_ >= Capacity) {
        // 缓存显示满了,刷新一次
        cached_read_idx_ = read_idx_.load(std::memory_order_acquire);
        // 再判断一次
        if (w - cached_read_idx_ >= Capacity) {
            return false;   // 真的满了
        }
    }

    buffer_[w & (Capacity - 1)] = value;
    write_idx_.store(w + 1, std::memory_order_release);
    return true;
}

正确性论证cached_read_idx_ 可能比真实的 read_idx_ 小(落后),但不会更大(因为消费者只会让 read_idx_ 变大)。所以:

  • 如果 w - cached_read_idx_ < Capacity,那真实差值更小,肯定不满,安全写入。
  • 如果 w - cached_read_idx_ >= Capacity,可能是真满,也可能缓存过时,所以要刷新后再判断。

7.5 效果

在稳态下(队列不满不空),生产者完全不访问 read_idx_ 这条 cache line,反之亦然。这把两个核心间的缓存一致性流量降到了几乎为零,通常能再提升 2--3 倍吞吐。

这个技巧在 LMAX Disruptor、moodycamel、boost::lockfree 里都有使用。

7.6 优化后的代码

cpp 复制代码
#define START std::chrono::high_resolution_clock::now()
#define END std::chrono::high_resolution_clock::now()
#define DURATION(start, end) std::chrono::duration_cast<std::chrono::nanoseconds>(((end) - (start))).count()

#define QUERY 100000
#define CAPACITY 1024

static constexpr size_t kCacheLine = 64;

template <typename T, size_t Capacity>
class SpscQueue
{
    // 00001000 & 00000111 = 00000000
    static_assert((Capacity & (Capacity - 1)) == 0, "Capacity must be a power of 2");

public:
    bool try_push(const T &value)
    {
        size_t w = write_idx_.load(std::memory_order_relaxed);
        // size_t r = read_idx_.load(std::memory_order_acquire);  // 这里读取到的r主要用于判断队列是否已满
        // if (w - r >= Capacity)
        //     return false;
        // 使用缓存,减少read_idx_的读取频率,提升性能
        if(w - cached_read_idx_ >= Capacity)
        {
            cached_read_idx_ = read_idx_.load(std::memory_order_acquire);
            if(w - cached_read_idx_ >= Capacity)
            {
                return false; // 满
            }
        }
        buffer_[w & (Capacity - 1)] = value;
        write_idx_.store(w + 1, std::memory_order_release);
        return true;
    }

    bool try_pop(T &out)
    {
        size_t r = read_idx_.load(std::memory_order_relaxed);
        // size_t w = write_idx_.load(std::memory_order_acquire); // 这里读取到的w主要用于判断队列是否为空
        // if (r == w)
        //     return false; // 空
        if(r == cached_write_idx_)
        {
            cached_write_idx_ = write_idx_.load(std::memory_order_acquire);
            if(r == cached_write_idx_)
            {
                return false; // 空
            }
        }
        out = buffer_[r & (Capacity - 1)];
        read_idx_.store(r + 1, std::memory_order_release);
        return true;
    }

private:
    std::array<T, Capacity> buffer_;    // 定长数组
    
    alignas(kCacheLine) std::atomic<size_t> write_idx_{0};  // 写下标--线性增长
    size_t cached_read_idx_{0}; // 读下标缓存

    alignas(kCacheLine) std::atomic<size_t> read_idx_{0};   // 读下标--线性增长
    size_t cached_write_idx_{0}; // 写下标缓存
};

SpscQueue<int, CAPACITY> queue;

void producer()
{
    for(int i = 0; i < QUERY; ++i)
    {
        while(!queue.try_push(i))
        {
            std::this_thread::yield();
        }
    }
}

void consumer()
{
    std::vector<int> nums;
    int num = 0;
    for(int i = 0; i < QUERY; ++i)
    {
        while(!queue.try_pop(num))
        {
            std::this_thread::yield();
        }
        nums.emplace_back(num);
    }
    for(int i = 0; i < QUERY; ++i)
    {
        if(nums[i] != i)
        {
            std::cout << "false" << std::endl;
            return;
        }
    }
    std::cout << "true" << std::endl;
}

int main()
{
    auto start = START;

    std::thread t1(producer);
    std::thread t2(consumer);

    t1.join();
    t2.join();

    auto end = END;
    std::cout << "Duration: " << DURATION(start, end) << " ns" << std::endl;

    return 0;
}

实测优化效果是原来性能的3倍左右:


8. 生产级实现

把前面所有的优化整合起来。为了能支持不可默认构造的类型(如 std::unique_ptr<T>,我们用 placement new 手动管理对象生命周期。

cpp 复制代码
#include <atomic>
#include <cstddef>
#include <cstdint>
#include <new>          // placement new, std::launder
#include <type_traits>
#include <utility>

template <typename T, std::size_t Capacity>
class SpscQueue
{
    static_assert(Capacity >= 2, "Capacity must be at least 2");
    static_assert((Capacity & (Capacity - 1)) == 0, "Capacity must be a power of 2");

    static constexpr std::size_t kCacheLine = 64;
    static constexpr std::size_t kMask = Capacity - 1;

public:
    SpscQueue() = default;

    ~SpscQueue()
    {
        // 析构残留元素
        std::size_t r = read_idx_.load(std::memory_order_relaxed);
        const std::size_t w = write_idx_.load(std::memory_order_relaxed);
        while (r != w)
        {
            slot(r)->~T();
            ++r;
        }
    }

    SpscQueue(const SpscQueue &) = delete;
    SpscQueue &operator=(const SpscQueue &) = delete;
    SpscQueue(SpscQueue &&) = delete;
    SpscQueue &operator=(SpscQueue &&) = delete;

    // --- 生产者接口(只能被一个线程调用)---

    template <typename... Args>
    bool try_emplace(Args &&...args)
    {
        const std::size_t w = write_idx_.load(std::memory_order_relaxed);

        if (w - cached_read_idx_ >= Capacity)
        {
            cached_read_idx_ = read_idx_.load(std::memory_order_acquire);
            if (w - cached_read_idx_ >= Capacity)
            {
                return false;
            }
        }

        ::new (raw_slot(w)) T(std::forward<Args>(args)...);
        write_idx_.store(w + 1, std::memory_order_release);
        return true;
    }

    bool try_push(const T &value) 
    { 
        return try_emplace(value); 
    
    }
    bool try_push(T &&value) 
    { 
        return try_emplace(std::move(value)); 
    }

    // --- 消费者接口(只能被一个线程调用)---

    bool try_pop(T &out)
    {
        const std::size_t r = read_idx_.load(std::memory_order_relaxed);

        if (r == cached_write_idx_)
        {
            cached_write_idx_ = write_idx_.load(std::memory_order_acquire);
            if (r == cached_write_idx_)
            {
                return false;
            }
        }

        T *p = slot(r);
        out = std::move(*p);
        p->~T(); // 对象被析构了(~T()),但队列的内存没有被释放
        read_idx_.store(r + 1, std::memory_order_release);
        return true;
    }

    // --- 查询接口(近似值,仅供参考)---

    std::size_t size_approx() const noexcept
    {
        const std::size_t w = write_idx_.load(std::memory_order_acquire);
        const std::size_t r = read_idx_.load(std::memory_order_acquire);
        return w - r;
    }

    bool empty_approx() const noexcept 
    { 
        return size_approx() == 0; 
    }

    static constexpr std::size_t capacity() noexcept 
    { 
        return Capacity; 
    }

private:
    // 原始存储槽的地址
    void *raw_slot(std::size_t idx) noexcept
    {
        return &storage_[(idx & kMask) * sizeof(T)];
    }

    // 已构造的 T* 视角(用 launder 规避严格别名问题,不能通过一种类型的指针,去访问另一种类型的对象。)
    // 内存里存的实际是std::byte,但用T* 去访问,编译器认为这是未定义行为,所以需要 std::launder 来告诉编译器,这个指针是合法的,可以用来访问 T 对象。
    // std::launder(p):保证这块内存里真的有一个 T 对象,把这个指针变成合法可用的指针。
    T *slot(std::size_t idx) noexcept
    {
        // reinterpret_cast<T*>:只是强行改变指针类型,但不合法。
        // std::launder:让这个指针变得合法、安全、符合标准。
        return std::launder(reinterpret_cast<T *>(raw_slot(idx)));
    }

private:
    // 存储区:未初始化的字节数组,按 T 对齐
    // std::byte 是 C++17 专门用来表示「纯原始内存 / 字节」的类型,它不参与任何类型运算、不隐式转整数
    // std::byte 作用是提供未初始化的原始内存,申请一块足够大、对齐正确、但还没有构造任何 T 对象的内存区域。
    // std::byte 只代表1 字节原始内存,没有任何语义,不会自动调用任何构造 / 析构函数,不会被当成整数、字符使用,完全是"空白画布",方便后续用 placement new 在上面构造对象;这正是无锁队列需要的:预先分配内存,延迟构造对象。
    // 为什么不用T[]:
    //    1、会立刻构造 Capacity 个 T 对象(队列还没使用就构造了,浪费、错误、可能有副作用)
    //    2、无锁队列需要手动控制对象构造 / 析构
    //    3、会导致重复构造、重复析构,引发崩溃
    // 而 std::byte 完全没有这个问题,它只是字节,不是对象。
    // 为什么不用void*?因为void* 无法直接定义数组,无法计算偏移,无法分配栈 / 静态内存,不适合做固定大小的存储区。
    alignas(T) std::byte storage_[Capacity * sizeof(T)];

    // --- 生产者 cache line ---
    alignas(kCacheLine) std::atomic<std::size_t> write_idx_{0};
    std::size_t cached_read_idx_{0};
    char pad_producer_[kCacheLine - sizeof(std::atomic<std::size_t>) - sizeof(std::size_t)]{};

    // --- 消费者 cache line ---
    alignas(kCacheLine) std::atomic<std::size_t> read_idx_{0};
    std::size_t cached_write_idx_{0};
    char pad_consumer_[kCacheLine - sizeof(std::atomic<std::size_t>) - sizeof(std::size_t)]{};

    // 尾部 padding:防止外部对象污染消费者 cache line
    char pad_end_[kCacheLine]{};
};

8.1 设计要点回顾

  • 容量限制:2 的幂且至少为 2。
  • 对齐write_idx_read_idx_ 各占独立 cache line,cached_* 放在各自一侧,末尾加 padding。
  • 内存序:写端 release、读端 acquire;自己的索引用 relaxed load。
  • 缓存索引cached_read_idx_ 只被生产者读写,cached_write_idx_ 只被消费者读写,不是原子的。
  • 元素生命周期 :使用 alignas(T) 字节数组做存储,placement new 构造,手动析构。std::launder 是 C++17 引入的,为了在严格别名规则下合法访问 placement new 后的对象。
  • 禁用拷贝与移动:队列包含原子变量和内部指针相关状态,跨线程搬运无意义且容易出错。

8.2 关于 size_t 溢出

write_idx_read_idx_ 单调递增,最终会溢出 size_t。在 64 位系统上,size_t 是 2⁶⁴,即使每纳秒自增一次也要 500 多年才溢出,完全不用担心。

更妙的是,w - r 这类无符号减法在溢出发生时也能正确工作------因为无符号整数的减法是定义良好的模算术,只要真实的队列长度不超过 2⁶³ 就不会出错(而真实长度 ≤ Capacity,远小于这个数)。

在 32 位系统上,每秒千万级操作可能几分钟就溢出一次。虽然数学上仍然正确,但要做额外验证。大多数现代系统都是 64 位,可以安心用。


9. 正确性测试与性能基准

9.1 正确性测试:序列号验证

最可靠的测试是让生产者发送单调递增的序列号,消费者验证收到的序列号严格递增且无遗漏。

cpp 复制代码
// test_spsc.cpp
#include "spsc_queue.hpp"

#include <atomic>
#include <cassert>
#include <chrono>
#include <cstdint>
#include <iostream>
#include <thread>

int main() {
    constexpr std::size_t kCap   = 1024;
    constexpr std::uint64_t kN   = 10'000'000;

    SpscQueue<std::uint64_t, kCap> q;

    std::thread producer([&] {
        for (std::uint64_t i = 0; i < kN; ) {
            if (q.try_push(i)) {
                ++i;
            }
            // 满了就自旋;实际项目里可以挂起或让出 CPU
        }
    });

    std::thread consumer([&] {
        std::uint64_t expected = 0;
        std::uint64_t value;
        while (expected < kN) {
            if (q.try_pop(value)) {
                assert(value == expected && "序列号不连续,队列有 bug!");
                ++expected;
            }
        }
    });

    producer.join();
    consumer.join();
    std::cout << "Passed: " << kN << " items in order.\n";
    return 0;
}

9.2 吞吐量基准

cpp 复制代码
// bench_spsc.cpp
#include "spsc_queue.hpp"

#include <chrono>
#include <cstdint>
#include <iostream>
#include <thread>

int main() {
    constexpr std::size_t kCap     = 1024;
    constexpr std::uint64_t kCount = 100'000'000;

    SpscQueue<std::uint64_t, kCap> q;

    auto t0 = std::chrono::steady_clock::now();

    std::thread producer([&] {
        for (std::uint64_t i = 0; i < kCount; ) {
            if (q.try_push(i)) ++i;
        }
    });

    std::thread consumer([&] {
        std::uint64_t v;
        std::uint64_t n = 0;
        while (n < kCount) {
            if (q.try_pop(v)) ++n;
        }
    });

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

    auto t1 = std::chrono::steady_clock::now();
    auto ns = std::chrono::duration_cast<std::chrono::nanoseconds>(t1 - t0).count();

    std::cout << "Total:       " << kCount          << " items\n"
              << "Elapsed:     " << ns / 1'000'000  << " ms\n"
              << "Throughput:  " << (kCount * 1'000'000'000.0 / ns)
                                 << " ops/sec\n"
              << "Per op:      " << double(ns) / kCount << " ns\n";
    return 0;
}

编译命令:

bash 复制代码
g++ -O3 -std=c++17 -pthread bench_spsc.cpp -o bench_spsc

在一台典型的桌面 CPU(Intel i7 / AMD Ryzen)上,单条路径大约 5--15 ns/op 。对比 mutex 版本的 ~200 ns/op,提升是 一到两个数量级

9.3 推荐的其他测试

实战中还应该加上:

  • ASan / TSan :用 -fsanitize=thread 跑一遍,确认没有数据竞争。
  • 随机停顿 :在生产者和消费者的循环里随机 sleep,模拟不均衡速度,验证队列能正确处理满/空。
  • 不同类型测试std::stringstd::unique_ptr、自定义 move-only 类型。
  • 析构测试:在没清空的队列上析构,用带统计的类型(RAII 计数器)验证没有泄漏、没有重复析构。

10. 常见陷阱与注意事项

10.1 "单生产者"到底意味着什么

SPSC 的"单"指的是整个程序运行期间始终 只有一个固定的线程 调用 try_push。消费者同理。

不合法的用法:

  • 线程池里随便挑一个线程调用 try_push(即使保证同一时刻只有一个在调用)------因为换线程调用时,前一个线程的写入对新线程来说不一定可见,除非你自己加同步。
  • 主线程初始化阶段 push 了一些数据,然后把队列交给工作线程 push ------这种"切换生产者"的模式需要一次显式同步(比如 std::thread 构造本身提供的 happens-before 就够了)。

如果你需要多个线程轮流当生产者,请改用 MPSC 或 MPMC

10.2 不要在同一个线程里既 push 又 pop

不是绝对禁止,但很容易写出 bug。比如:

cpp 复制代码
// 危险:如果队列满了就死循环
while (!q.try_push(x)) {}    // 等不到有人 pop

如果当前线程同时负责消费,自旋等待 push 成功会导致死锁。正确做法:

  • 区分生产者线程和消费者线程
  • 如果真的单线程就不需要无锁队列

10.3 try_pop 的 out 参数要 movable 或 copyable

上面实现用 out = std::move(*p),要求 T 至少可以 move-assign。如果 T 只有 move 构造(常见于某些资源类),可以改成:

cpp 复制代码
bool try_pop(T& out) {
    // ...
    T* p = slot(r);
    out = std::move(*p);   // 若 T 无 move-assign 会编译失败
    p->~T();
    // ...
}

对于更通用的接口,可以提供 T consume() 或者传 lambda:

cpp 复制代码
template <typename F>
bool try_consume(F&& f);   // f 接受 T&&

10.4 队列满了怎么办?

SPSC 队列通常有界。满了的常见策略:

  • 丢弃:最新数据最重要,丢历史。适合行情、传感器采样。
  • 阻塞等待 :用 condition variable 或 std::this_thread::yield()。失去无锁的优势,但语义上是无损的。
  • 自旋 + 回退:先短自旋,再 yield,再 sleep。适合低延迟系统。
  • 反压:让生产者变慢。适合流处理。
  • 扩容:SPSC 下非常难做对,一般不做。需要扩容就改链表实现。

10.5 不要把队列作为临时对象跨线程传递

SpscQueue 被设计成长期存在的对象,通常是全局、成员或堆分配后共享指针持有。把它作为函数参数在栈上构造再传给另一个线程是自找麻烦:栈对象随函数返回销毁,另一边正在读写就是 UAF。

10.6 数据结构和控制结构不要混在一条 cache line

如果 T 很大(比如一个 1KB 的音频块),buffer 本身就占多条 cache line,不会和索引冲突;

如果 T 很小(比如 int),capacity 又小,buffer 可能几十字节,容易和相邻字段落在一起。用 alignas(kCacheLine) 给 buffer 前加对齐也是好习惯。

上面的生产级实现已经为索引做了对齐,而 storage_ 在索引之前,加上索引各自 64 字节对齐,实际布局是安全的------但如果你改动成员顺序,务必手动验证 sizeofoffsetof

10.7 relaxed 用在哪里,什么时候不能用

  • 自己的索引自己读 :用 relaxed,因为没有跨线程同步需求。
  • 对方的索引必须 acquire(读)/ release(写),用来同步数据本身。
  • 初始化阶段 :构造函数里把索引置 0,不需要原子操作(对象还没被其他线程看到)。但要保证构造完成之前 对象地址没被其他线程观察到,通常靠 std::thread 构造的 happens-before 自然满足。

10.8 要不要用 std::hardware_destructive_interference_size

C++17 提供了这个常量表示 cache line 大小,但实际坑:

  • GCC 12 之前默认不启用(链接错误)
  • 不同编译器报告的值不同(有的是 64,有的是 128)
  • 同一程序跨架构编译时可能不一致

推荐做法:直接硬编码 64,并在架构特殊时(如 Apple M1 的 128 字节)加宏处理。工业界更倾向于硬编码 + 注释。


11.总结

一个生产级 SPSC 队列,核心要点就四个:

  1. 环形缓冲区 + 2 的幂容量,用位与代替取模;
  2. 生产者和消费者的索引分别放在独立 cache line,消除伪共享;
  3. release / acquire 配对,保证数据可见性;
  4. 缓存对方索引,让稳态下的热路径不触碰对方的 cache line。

每一步都对应一个具体的硬件/语言行为:

  • 缓存行、缓存一致性协议 → 伪共享
  • CPU 重排、编译器重排 → 内存序
  • 核间缓存一致性流量 → 缓存索引
相关推荐
zore_c2 小时前
【C++】C++——类的默认成员函数(构造、析构、拷贝构造函数)
java·c语言·c++·笔记·算法·排序算法
m0_587098992 小时前
C++,cv::Mat数据类型、通道数等概念梳理
c++·opencv·计算机视觉
进击的荆棘2 小时前
C++起始之路——AVL树的实现
开发语言·数据结构·c++·stl·avl
Hical_W2 小时前
深入学习CPP26_静态反射
c++·学习
进击的荆棘2 小时前
C++起始之路——红黑树的实现
开发语言·数据结构·c++·stl·红黑树
t***54412 小时前
如何在现代C++中更有效地应用这些模式
java·开发语言·c++
itman30112 小时前
C语言、C++与C#深度研究:从底层到现代开发演进全解析
c语言·c++·c·内存管理·编译模型
Hical_W14 小时前
为 C++ Web 框架设计三层 PMR 内存池:从原理到实战
c++·github
BestOrNothing_201514 小时前
C++零基础到工程实战(3.6):逻辑实战示例—日志模块
c++·命令行参数·main函数·switch case·逻辑判断·if else·enum class