刚学完 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(内存池)

相关推荐
·薯条大王29 分钟前
经济实惠玩云服务器|一台云服务器多人共用,子账号配置教程
java·linux·运维·服务器·汇编·c++·python
hansang_IR5 小时前
【题解】LC:倍增 / 区间并查集(Range Parallel Unionfind)
c++·算法·并查集
东东最爱敲键盘5 小时前
day7.c++继承
c++
沫璃染墨6 小时前
《从零入门Linux系统篇(十五):系统工具篇·六——GDB调试器详解:从程序执行控制到高级调试技巧》
linux·运维·服务器·c语言·c++
吃着火锅x唱着歌6 小时前
Effective C++ 学习笔记 条款40 明智而审慎地使用多重继承
c++·笔记·学习
醉城夜风~7 小时前
(超详细)C++ STL list容器详解:从使用方法到双向链表底层原理全面解析
c++·windows
烟花落o7 小时前
C++ 从 C 到进阶 ②:引用、inline 、nullptr
c++·引用·inline·nullptr·c++入门
liulilittle7 小时前
MOE路由:路由(logits: top-k/8)
c++·人工智能·算法·机器学习·llm
旖旎夜光7 小时前
LeetCode 11:盛最多水的容器(双指针问题) —— 题解
数据结构·c++·算法·leetcode·双指针
我不会插花弄玉10 小时前
15.map,set下:AVL树,红黑树和map,set的封装【由浅入深-C++】
c++·stl