刚学完 CAS 原子操作,我花了一晚上写了三个无锁队列

从零手写 C++ 无锁并发队列 ------ 从 ring buffer 到 CAS 原子操作

一个晚上,三种无锁队列,从 SPSC 到 MPMC。这篇文章记录每一层的设计思路和核心代码。适合想理解 std::atomic 和 CAS 怎么用的 C++ 初学者。


为什么要无锁

多线程下共享队列最常见的写法是 std::queue + std::mutex

cpp 复制代码
std::queue<int> q;
std::mutex mtx;

// 线程 A(生产者)
{ std::lock_guard lk(mtx); q.push(42); }

// 线程 B(消费者)
{ std::lock_guard lk(mtx); q.pop(val); }

每次 push/pop 都要加锁------A 和 B 同时操作时,一个必须等另一个。在量化交易(每秒百万次 push/pop)、游戏引擎(每帧几千次消息)、实时音视频这类场景下,锁竞争带来的上下文切换开销不可接受。

无锁队列用 std::atomic 的 CAS(Compare And Swap)原子指令替代 std::mutex。多个线程可以同时操作队列而不会互相阻塞------CAS 失败时原地重试,而不是被系统挂起。


第 1 层:SPSC --- 单生产者单消费者

只有一个写入线程和一个读取线程。天然无锁 ------写线程只碰 writeIndex_,读线程只碰 readIndex_,两个线程从不竞争同一个变量。

cpp 复制代码
template <typename T, size_t Capacity>
class SPSCQueue {
    std::vector<T> buffer_;             // ring buffer
    std::atomic<size_t> writeIndex_{0}; // 写线程写入下一个位置
    std::atomic<size_t> readIndex_{0};  // 读线程读取下一个位置

    bool push(const T& val) {
        size_t w = writeIndex_.load(acquire);
        size_t r = readIndex_.load(acquire);
        if (w - r >= Capacity) return false;   // 满了
        buffer_[w % Capacity] = val;
        writeIndex_.store(w + 1, release);     // 告诉消费者:数据准备好了
        return true;
    }

    bool pop(T& val) {
        size_t r = readIndex_.load(acquire);
        size_t w = writeIndex_.load(acquire);
        if (r >= w) return false;              // 空了
        val = buffer_[r % Capacity];
        readIndex_.store(r + 1, release);      // 告诉生产者:可以覆盖了
        return true;
    }
};

为什么无锁: 写线程只改 writeIndex_,读线程只改 readIndex_------各碰各的变量,永远不会竞争。

索引只加不减: wr 只递增,永不归零。取余映射到 ring buffer 位置------w % CapacityCapacity 必须是 2 的幂次------static_assert((Capacity & (Capacity - 1)) == 0),编译器把 % 优化为 & 位运算。

acquire/release 配对: push 用 acquire 读写索引------保证看到消费者刚腾出的空间。release 更新写索引------告诉消费者"数据准备好了"。pop 镜像------acquire 看生产者最新数据,release 更新读索引。


第 2 层:MPSC --- 多生产者单消费者

多个写入线程 + 一个读取线程。写入线程之间需要协调------用 CAS 原子地抢占 writeIndex_

cpp 复制代码
bool push(const T& val) {
    size_t w = writeIndex_.load(relaxed);      // ① 随便看一眼
    do {
        size_t r = readIndex_.load(acquire);    // ② 真的有空位吗
        if (w - r >= Capacity) return false;
    } while (!writeIndex_.compare_exchange_weak(
                 w, w + 1, release, relaxed));  // ③ CAS 抢位置!
    buffer_[w % Capacity] = val;                // ④ 抢到了,写数据
    return true;
}

CAS 做了什么: "如果 writeIndex_ 还是 w,就改成 w+1,返回成功。如果被别的线程抢先改了,w 自动更新为新值,返回失败,重试。"

多线程同时 push------CAS 保证每个线程抢到不同的位置,不会两个线程写到同一个 buffer 槽位。

pop 端只有一个消费线程------跟 SPSC 一样直接 readIndex_++,不需要 CAS。


第 3 层:MPMC --- 多生产者多消费者

写入和读取都有多个线程。两端的索引都用 CAS 抢占:

cpp 复制代码
bool pop(T& val) {
    size_t r = readIndex_.load(relaxed);       // 随便看一眼
    do {
        size_t w = writeIndex_.load(acquire);   // 真的有空吗
        if (r >= w) return false;
    } while (!readIndex_.compare_exchange_weak(
                 r, r + 1, release, relaxed));  // CAS 抢读位!
    val = buffer_[r % Capacity];
    return true;
}

push 和 pop 完全镜像------两个原子索引,各用各的 CAS。四个线程同时 push/pop 不会互相阻塞。


为什么第一步用 relaxed

CAS 循环的第一步是初读------"随便看一眼"。如果读到的值是旧的(其他线程抢先了索引),CAS 会自动纠正------失败了 r 被更新为最新值,重试。relaxed 不消耗内存同步开销,反正 CAS 会兜底。

成功/失败的内存序

CAS 有两个内存序参数------第一个是成功时的,第二个是失败时的。成功用 release------保证写的数据对后续 acquire 可见。失败用 relaxed------反正要重试,白干了无所谓。


Benchmark

4 线程,每线程 100K 次 push/pop:

模式 lockfree std::mutex moodycamel
SPSC 100M ops/s 12.5M 25M
MPSC 12.9M 25M ---
MPMC 8.1M 9.5M 10.8M

SPSC 碾压------ring buffer 简单直接,8x faster than mutex,4x vs moodycamel。MPMC 与工业级 concurrentqueue 有差距------moodycamel 用了缓存行填充、批量出队等十年优化的技巧。一个晚上写到这个程度,满意了。


项目结构

arduino 复制代码
lockfree-queue/
├── spsc_queue.h    --- ring buffer + atomic 索引,天然无锁
├── mpsc_queue.h    --- CAS 抢占 writeIndex
├── mpmc_queue.h    --- 两端 CAS
├── bench.cpp       --- vs std::mutex
├── bench_moody.cpp --- vs moodycamel ConcurrentQueue
└── main.cpp        --- 综合测试

3 个头文件,300 行代码,零外部依赖。


更新记录

  • 7/20 SPSC Queue --- ring buffer + atomic 索引
  • 7/20 MPSC Queue --- CAS 抢占写位
  • 7/20 MPMC Queue --- 两端 CAS
  • 7/20 Benchmark vs mutex + vs moodycamel

项目已完结。一个晚上从 ring buffer 到 CAS 原子操作。代码在 GitHub 上,MIT 协议,欢迎 star ⭐

lockfree-queuehttps://github.com/ch0sen1pm/lockfree-queue 配套项目:my_muduo (Reactor 网络库)+ my_logger (日志库)+ memory-pool(内存池)

相关推荐
Chester_19992 小时前
CSP202203C.计算资源调度器
开发语言·数据结构·c++·蓝桥杯
会周易的程序员4 小时前
给 PLC 写一个字节码虚拟机:STVM 虚拟机架构设计
开发语言·c++·虚拟机·软plc·iec61131·stvm
小灰灰搞电子4 小时前
完全驾驭 Qt 与数据库:C++ ORM 框架 QxOrm 原理与实践指南
数据库·c++·qt
QT界面美化性能优化4 小时前
QT+AI:使用AI技术为QT应用程序赋能
c++·人工智能·qt·opencv·qt教程·qt6.3
学习星球6 小时前
单调栈——从“找下一个更大的“到柱状图中的最大矩形
数据库·c++·算法·leetcode·xcode
小灰灰搞电子7 小时前
C++ 引用折叠详解
c++
布莱克6057 小时前
数组详解:定义、作用、应用场景及与链表的区别(C/C++ 代码讲解)
c语言·开发语言·c++·数组
牛油果子哥q8 小时前
C++内存池与对象池精讲:内存碎片、自定义分配器、对象池实现、STL allocator原理、工程落地与性能对比
java·开发语言·c++
万法若空9 小时前
C/C++ 类型别名定义方式对比
java·c语言·c++
小小晓.10 小时前
C++ 值类别与完美转发深度剖析:从左值右值到 std::forward 源码解密
开发语言·c++·算法