在上一篇文章中,我们成功手写了一个基于链表的无界无锁队列 (UnboundedQueue),并巧妙利用“引用计数”彻底消灭了链表并发释放带来的 Use-After-Free 痛点。

然而,我们在文末埋下了一个伏笔:无界队列的最大痛点,在于它无法摆脱动态内存分配。 每次入队 new Node(),每次出队 delete Node()。在普通业务中这完全可行,但在极速高频并发场景(如自动驾驶 CyberRT、金融高频交易 Disruptor)中,频繁的堆内存分配不仅会造成严重的内存碎片,还会无可避免地触发系统调用底层的全局锁(即使是 tcmalloc 等也有不小开销)。

为了将性能压榨到极致,彻底斩断 new/delete 的枷锁,我们需要抛弃链表,直面无锁编程皇冠上的明珠——基于连续内存的有界无锁队列(Bounded Lock-Free Queue,即 RingBuffer 环形数组)

今天,我们将硬核拆解这个极速 RingBuffer 的落地细节,看看为了这几纳秒的性能,我们需要跨越多少常人难以窥见的深坑。


一、 核心设计:为什么选择预分配环形数组?

相比于链表的“用时分配”,有界队列在初始化时就会一次性分配好全量数组,并在整个生命周期内实现绝对的 “零内存分配”。在使用 std::calloc 申请内存后,我们会使用 Placement New 在预分配的内存上立刻显式初始化对象:

pool_size_ = size + 2; // 预留余量,方便区分空与满
pool_ = reinterpret_cast<T*>(std::calloc(pool_size_, sizeof(T)));
for (uint64_t i = 0; i < pool_size_; ++i) {
    new (&(pool_[i])) T(); // Placement New 预热对象
}

在连续内存的基础上,通过游标递增取模来模拟环。但由于是多生产者-多消费者 (MPMC) 的并发场景,原先单线程中只需 head 和 ail 两个游标的方案会瞬间崩溃。我们引入了极其关键的三游标设计

  • head_ (消费游标):消费者应该从哪里读。
  • ail_ (占位游标):最新的生产者刚刚“抢”到了哪个位置的写入权。
  • commit_ (提交游标):所有生产者中,数据真正完全写进内存的位置。

为了保证多线程环境下的内存可见性,这三个游标全部使用 std::atomic<uint64_t> 加持。


二、 破局点 1:MPMC 并发下的“脏读”漏洞与状态管理

在多线程下,为什么一定要把生产者的操作拆分为“占位( ail_)”和“提交(commit_)”?
试想:如果生产者 A 和 B 同时 CAS 抢到了槽位 1 和 2。B 跑得快,瞬间把数据写进了槽位 2。而 A 遇到了 OS 线程调度卡住了,还没来得及写数据。
此时,如果仅仅通过 ail_ 来判断,消费者去读槽位 1,它读到的将是未经初始化的脏数据或上一个轮回的旧数据

1. 入队 (Enqueue) - 占位与强序提交

我们必须让跑得快的 B 等一等 A,保证 commit_ 游标必须严格单调递增。我们来看看极光般严谨的代码:

template <typename T>
bool BoundedQueue<T>::Enqueue(const T& element) {
    uint64_t old_tail = tail_.load(std::memory_order_acquire);
    
    // 1. CAS 争抢队列占位 (只占位,不涉及具体数据写入)
    while (true) {
        uint64_t new_tail = old_tail + 1;
        // 如果尾追上了头,说明满载
        if (GetIndex(new_tail) == GetIndex(head_.load(std::memory_order_acquire))) return false; 
        if (tail_.compare_exchange_weak(old_tail, new_tail, std::memory_order_acq_rel, std::memory_order_relaxed)) {
            break; // 成功抢占槽位
        }
        cpu_relax(); // 减轻总线冲突
    }
    
    // 2. 独占这块小内存,安全将数据写入对应的数组槽
    pool_[GetIndex(old_tail)] = element;
    
    // 3. 推进 commit_ 游标:最核心的精髓!严格单调更新!
    uint64_t expected_commit = old_tail;
    while (cyber_unlikely(!commit_.compare_exchange_weak(
        expected_commit, new_tail, std::memory_order_acq_rel, std::memory_order_relaxed))) {
        expected_commit = old_tail; // CAS失败说明前面的老哥还在磨叽,重置预期值继续等他
        cpu_relax(); 
    }
    
    // 唤醒消费者...
    return true; // (配套 WaitStrategy 唤醒代码略)
}

2. 出队 (Dequeue) - 严防跳跃漏洞

在消费端,我们必须以此 commit_ 为准,而不是 ail_。这也是一个极度容易踩坑的地方,如果在多线程中判断有没有新数据,绝对不能用 ==,必须用 >=!

template <typename T>
bool BoundedQueue<T>::Dequeue(T* element) {
    uint64_t old_head = head_.load(std::memory_order_acquire);
    
    while (true) {
        uint64_t new_head = old_head + 1;
        // 【核心防御】:严防消费者因挂起唤醒而越过生产者 commit_!
        // 如果用 ==,一旦 commit_ 跑出去多圈,消费者会错误以为有数据而越界
        if (new_head >= commit_.load(std::memory_order_acquire)) {
            return false; // 数据没 commit 完,绝不提前读取
        }
        
        // 核心心法:必须【先】读取出数据(哪怕后来证明这是一次失败的读),再去 CAS 推头指针
        *element = pool_[GetIndex(new_head)]; 
        
        // 最后 CAS 动头指针
        if (head_.compare_exchange_weak(old_head, new_head, std::memory_order_acq_rel, std::memory_order_relaxed)) {
            break;
        }
        cpu_relax();
    }
    return true;
}

三、 破局点 2:隐藏的性能杀手 —— 伪共享 (False Sharing)

有了 MPMC 算法就万事大吉了吗?许多人写出了逻辑严密的无锁队列,一压测发现性能竟然不如加锁的 std::mutex 队列,甚至 CPU 占用满负荷。
这就引出了硬件底层的梦魇——伪共享计算(False Sharing)

在多核 CPU 中,数据的加载是以 Cache Line(通常 64 字节) 为单位拉取到 L1/L2 缓存的。如果你的 head_、 ail_ 和 commit_ 紧挨着声明,它们会被塞进同一个 Cache Line:

  • 核心 A(生产者)频繁修改 ail_ 和 commit_。
  • 核心 B(消费者)频繁修改 head_。
    根据底层的 MESI 缓存一致性协议,由于它们在同一个缓存行,核心 A 一更新,就会宣告核心 B 的高速缓存失效(Invalidate)。两个核心如同打乒乓球一般互相令对方缓存失效,强迫跨总线读写主存,性能一落千丈。

优化方案:空间换时间,硬格栅强行隔开!

// 消除伪共享的终极武器:内存对齐
alignas(CACHELINE_SIZE) std::atomic<uint64_t> head_ = {0};
alignas(CACHELINE_SIZE) std::atomic<uint64_t> tail_ = {1};
alignas(CACHELINE_SIZE) std::atomic<uint64_t> commit_ = {1};

简简单单的三个 alignas 修饰符,强制将三大高频游标打散到不同的 Cache Line,往往能让吞吐量瞬时暴涨数倍!


四、 极限反杀:CPU 退让与除法抛弃

在极高并发的 while(true) CAS 自旋锁中,如果你面对 100 个线程抢一个槽位,1 个成功,剩下 99 个线程单纯空转而不退让,会引发灾难性的总线风暴。密集的 CAS 探测信号会将内存总线塞满,使得真正本该写数据的那个线程由于带宽被抢反而变慢。

这就是我们在每次 CAS 失败的代码后面,都要加上 cpu_relax() 的原因:

while (!atomic.compare_exchange_weak(...)) {
    cpu_relax(); // 减轻总线冲突
}

cpu_relax() 在 x86 架构下通常会被宏定义为汇编:asm volatile("pause":::"memory")。它明确告诉 CPU 这是一个自旋锁,让指令流水线适度“歇息”,极大缓解对总线的恶意掠夺,并防止分支预测失败的严重惩罚。

另一方面,环形数组由于需要边界取模(%),而底层除法指令(div)的时钟周期开销是加减法的数倍。为了追求极致那几纳秒的性能,主流做到是大小为 2 的幂并使用位运算 &。
或者,像我们的系统和 CyberRT 底层常做的那样:

inline uint64_t GetIndex(uint64_t num) {
    // 依靠编译器优化与减法结合来绕过纯除法限制
    return num - (num / pool_size_) * pool_size_;  
}

总结

从单线程的粗糙链表,到引入多态的 WaitStrategy 控制阻塞节奏,再到解决动态分配痛点的无界并发模型,最后走到今天实现内存“零分配”、伪共享“免疫”的 极速 RingBuffer (BoundedQueue)

虽然我们牺牲了队列规模无限扩张的灵活性,但只要我们在代码里用 commit_ 守住数据底线、用 alignas 打破硬件缓存瓶颈、善用底层汇编退让,就能得到一个无懈可击的高性能核心组件。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐