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

相关推荐
qeen878 小时前
【C++】vector的模拟实现详解(二)
c++·学习·算法·迭代器·stl
j7~9 小时前
【C++】C++ 继承全解析:从基本语法到菱形继承的底层原理
开发语言·c++·继承·组合·虚继承·#暑假·七月创作之星博客挑战赛
数行拙笔9 小时前
C++客户端---String类型
开发语言·c++·bootstrap
草莓熊Lotso9 小时前
【Linux网络】深入理解Linux IO多路复用:从本质到select服务器实战
linux·运维·服务器·c语言·网络·数据库·c++
selfsongs20 小时前
指针、内存管理、现代 C++ 特性
c++
吃着火锅x唱着歌1 天前
Effective C++ 学习笔记 条款37 绝不重新定义继承而来的缺省参数值
c++·笔记·学习
2401_841495641 天前
【数据结构】B+树
数据结构·数据库·c++·b+树·概念·结构·操作原理
小保CPP1 天前
OCR C++ Tesseract按行识别字符
c++·人工智能·ocr·模式识别·光学字符识别
阿米亚波1 天前
【C++】流式数据输入处理(不完全整理)
开发语言·c++·笔记