从零手写 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_------各碰各的变量,永远不会竞争。
索引只加不减: w 和 r 只递增,永不归零。取余映射到 ring buffer 位置------w % Capacity。Capacity 必须是 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-queue :
https://github.com/ch0sen1pm/lockfree-queue配套项目:my_muduo (Reactor 网络库)+ my_logger (日志库)+ memory-pool(内存池)