无锁栈只有一个头指针要协调,无锁队列却有两个端点:头(出队)和尾(入队)。多生产者要抢尾巴,多消费者要抢头,中间还藏着一个新手最容易栽跟头的「slot 未就绪」问题。生产者用 fetch_add 抢到了槽位,但数据还没写完,消费者绝不能提前读。这篇用 Vyukov 有界环形队列(bounded MPMC queue)把这条线讲透,给出能真跑、TSan 干净的 MPSC 和 MPMC 两个版本,最后说清 MPSC 相对好写、MPMC 难在哪。
1. 引子:一个「读到了没写完的数据」的隐患
先看一个朴素的环形队列思路:用一个数组当缓冲区,生产者用 tail.fetch_add(1) 抢一个下标写数据,消费者用 head 读数据。这个思路错在哪里?
朴素环形队列的「slot 未就绪」竞态(时间从上往下)
生产者 P1 生产者 P2 消费者 C
────────── ────────── ──────────
[1] tail.fetch_add(1)=0 [2] tail.fetch_add(1)=1
抢到 slot 0 抢到 slot 1
[3] buffer[1]=42(写完)
[4] 读 head=0,
buffer[0] 还是旧值/垃圾!
[5] buffer[0]=7(P1 这时才写完)
结果:消费者在 P1 写完之前就读了 slot 0,读到的是脏数据。
根因:fetch_add 只「分配」了槽位,不保证「数据已经写完」。
tail.fetch_add(1) 是原子的,但它只能保证「每个生产者拿到不同的下标」,保证不了「拿到下标 = 数据已经可见」。两个生产者几乎同时抢槽,快的那个(P2)先写完,慢的那个(P1)还没写,消费者却可能已经读到了慢的槽位。这正是无锁队列区别于无锁栈的核心难点。
2. 解法:sequence 数组,给每个槽位一个「轮次号」
Vyukov 有界队列的解法很漂亮:给每个槽位配一个**sequence(轮次号)**原子量,用它来编码「这个槽位现在处于第几轮、是否可写/可读」。
Vyukov 有界环形队列(capacity = 4,mask = 3)
buffer: [ B0 ][ B1 ][ B2 ][ B3 ] ← 真正存数据的数组
seq: [ S0 ][ S1 ][ S2 ][ S3 ] ← 每个槽位的轮次号(原子)
初始化:seq[i] = i(第 i 个槽位第 0 轮可写)
入队(生产者):
pos = tail.fetch_add(1) ← 抢一个位置
自旋等待 seq[pos & mask] == pos ← 等到「这一轮轮到我了」
写 buffer[pos & mask] = value
seq[pos & mask] = pos + 1 ← 发布:下一轮(pos+1)可读
出队(消费者):
pos = head.fetch_add(1) ← 抢一个位置
自旋等待 seq[pos & mask] == pos + 1 ← 等到「数据已经写完」
读 value = buffer[pos & mask]
seq[pos & mask] = pos + capacity ← 归还槽位:下一轮(pos+cap)可写
「满」判定:入队时 seq < pos(槽位还被上一轮占着)
「空」判定:出队时 seq < pos + 1(还没轮到这一轮)
关键就在那个 seq:入队者等 seq == pos,出队者等 seq == pos + 1。一个槽位被写完,seq 从 pos 变成 pos+1,等于向消费者「发布」了数据;消费者读完,把 seq 改成 pos+capacity,等于「归还」槽位让下一轮生产者复用。生产者和消费者之间靠 seq 上的 acquire/release 完成同步,而不是靠 fetch_add 的先后顺序。
官方文档:std::memory_order(acquire/release 配对) · std::atomic::fetch_add
3. 完整实现:Vyukov 有界 MPMC 队列
下面是完整的实现,capacity 必须是 2 的幂(这样 & mask 就能取模)。buffer_ 存数据,seq_ 存轮次号,tail_/head_ 是两个单调递增的原子计数:
// 片段:Vyukov 有界 MPMC 队列(capacity 必须是 2 的幂)
#include <atomic>
#include <cstddef>
#include <vector>
template <typename T>
class BoundedMpmcQueue {
public:
explicit BoundedMpmcQueue(std::size_t cap)
: capacity_(cap), mask_(cap - 1), buffer_(cap), seq_(cap) {
for (std::size_t i = 0; i < cap; ++i)
seq_[i].store(i, std::memory_order_relaxed); // 第 0 轮可写
}
bool try_enqueue(const T& value) {
std::size_t pos = tail_.load(std::memory_order_relaxed);
for (;;) {
std::size_t seq = seq_[pos & mask_].load(std::memory_order_acquire);
std::ptrdiff_t diff = static_cast<std::ptrdiff_t>(seq)
- static_cast<std::ptrdiff_t>(pos);
if (diff == 0) { // 轮到这一轮写
if (tail_.compare_exchange_weak(pos, pos + 1,
std::memory_order_relaxed)) {
buffer_[pos & mask_] = value;
seq_[pos & mask_].store(pos + 1, std::memory_order_release);
return true;
}
} else if (diff < 0) {
return false; // 队列满
} else {
pos = tail_.load(std::memory_order_relaxed); // 别的生产者抢先,重读
}
}
}
bool try_dequeue(T& out) {
std::size_t pos = head_.load(std::memory_order_relaxed);
for (;;) {
std::size_t seq = seq_[pos & mask_].load(std::memory_order_acquire);
std::ptrdiff_t diff = static_cast<std::ptrdiff_t>(seq)
- static_cast<std::ptrdiff_t>(pos + 1);
if (diff == 0) { // 数据已经写完
if (head_.compare_exchange_weak(pos, pos + 1,
std::memory_order_relaxed)) {
out = buffer_[pos & mask_];
seq_[pos & mask_].store(pos + capacity_,
std::memory_order_release);
return true;
}
} else if (diff < 0) {
return false; // 队列空
} else {
pos = head_.load(std::memory_order_relaxed);
}
}
}
private:
std::size_t capacity_;
std::size_t mask_;
std::vector<T> buffer_;
std::vector<std::atomic<std::size_t>> seq_;
std::atomic<std::size_t> tail_{0};
std::atomic<std::size_t> head_{0};
};
官方文档:std::atomic(
std::vector<std::atomic<T>>的默认构造可用)
4. MPSC:多生产者单消费者
MPSC(multi-producer single-consumer)只有生产端有竞争,消费端是单线程独占 head。下面 4 个生产者各塞 4 个互不重复的值,1 个消费者全部取出,验证总和确定:
// mpsc.cpp — 编译: g++ -std=c++17 -Wall -O2 -pthread mpsc.cpp -o mpsc
#include <atomic>
#include <cstddef>
#include <cstdio>
#include <thread>
#include <vector>
template <typename T>
class BoundedMpmcQueue {
public:
explicit BoundedMpmcQueue(std::size_t cap)
: capacity_(cap), mask_(cap - 1), buffer_(cap), seq_(cap) {
for (std::size_t i = 0; i < cap; ++i) seq_[i].store(i, std::memory_order_relaxed);
}
bool try_enqueue(const T& value) {
std::size_t pos = tail_.load(std::memory_order_relaxed);
for (;;) {
std::size_t seq = seq_[pos & mask_].load(std::memory_order_acquire);
std::ptrdiff_t diff = static_cast<std::ptrdiff_t>(seq) - static_cast<std::ptrdiff_t>(pos);
if (diff == 0) {
if (tail_.compare_exchange_weak(pos, pos + 1, std::memory_order_relaxed)) {
buffer_[pos & mask_] = value;
seq_[pos & mask_].store(pos + 1, std::memory_order_release);
return true;
}
} else if (diff < 0) return false;
else pos = tail_.load(std::memory_order_relaxed);
}
}
bool try_dequeue(T& out) {
std::size_t pos = head_.load(std::memory_order_relaxed);
for (;;) {
std::size_t seq = seq_[pos & mask_].load(std::memory_order_acquire);
std::ptrdiff_t diff = static_cast<std::ptrdiff_t>(seq) - static_cast<std::ptrdiff_t>(pos + 1);
if (diff == 0) {
if (head_.compare_exchange_weak(pos, pos + 1, std::memory_order_relaxed)) {
out = buffer_[pos & mask_];
seq_[pos & mask_].store(pos + capacity_, std::memory_order_release);
return true;
}
} else if (diff < 0) return false;
else pos = head_.load(std::memory_order_relaxed);
}
}
private:
std::size_t capacity_, mask_;
std::vector<T> buffer_;
std::vector<std::atomic<std::size_t>> seq_;
std::atomic<std::size_t> tail_{0}, head_{0};
};
int main() {
constexpr int kProducers = 4;
constexpr int kPerProducer = 4;
constexpr int kTotal = kProducers * kPerProducer;
BoundedMpmcQueue<int> q(16); // 16 个槽位
std::vector<std::thread> producers;
for (int t = 0; t < kProducers; ++t) {
producers.emplace_back([&, t] {
for (int i = 0; i < kPerProducer; ++i) {
while (!q.try_enqueue(t * kPerProducer + i)) { }
}
});
}
for (auto& t : producers) t.join();
long long sum = 0; // 单个消费者,独占 head
for (int i = 0; i < kTotal; ++i) {
int v = 0;
while (!q.try_dequeue(v)) { }
sum += v;
}
std::printf("MPSC 出队 %d 个,值之和 = %lld(期望 %d)\n",
kTotal, sum, kTotal * (kTotal - 1) / 2);
}
MPSC 出队 16 个,值之和 = 120(期望 120)
120 = 0+1+…+15。注意消费者是单线程,try_dequeue 固定调用 16 次就能取完。这正是 MPSC 好写的地方:消费端没有竞争,head 只有一个人动。
5. MPMC:多生产者多消费者,难在哪
MPMC(multi-producer multi-consumer)两端都有竞争:生产者抢 tail_,消费者抢 head_。难点在于两端都要做完整的 sequence 协议,任何一个端点少一步都会出错。下面 4 生产者 + 4 消费者,用一个原子 remaining 计数保证恰好出队 16 次:
// mpmc.cpp — 编译: g++ -std=c++17 -Wall -O2 -pthread mpmc.cpp -o mpmc
#include <atomic>
#include <cstddef>
#include <cstdio>
#include <thread>
#include <vector>
template <typename T>
class BoundedMpmcQueue {
public:
explicit BoundedMpmcQueue(std::size_t cap)
: capacity_(cap), mask_(cap - 1), buffer_(cap), seq_(cap) {
for (std::size_t i = 0; i < cap; ++i) seq_[i].store(i, std::memory_order_relaxed);
}
bool try_enqueue(const T& value) {
std::size_t pos = tail_.load(std::memory_order_relaxed);
for (;;) {
std::size_t seq = seq_[pos & mask_].load(std::memory_order_acquire);
std::ptrdiff_t diff = static_cast<std::ptrdiff_t>(seq) - static_cast<std::ptrdiff_t>(pos);
if (diff == 0) {
if (tail_.compare_exchange_weak(pos, pos + 1, std::memory_order_relaxed)) {
buffer_[pos & mask_] = value;
seq_[pos & mask_].store(pos + 1, std::memory_order_release);
return true;
}
} else if (diff < 0) return false;
else pos = tail_.load(std::memory_order_relaxed);
}
}
bool try_dequeue(T& out) {
std::size_t pos = head_.load(std::memory_order_relaxed);
for (;;) {
std::size_t seq = seq_[pos & mask_].load(std::memory_order_acquire);
std::ptrdiff_t diff = static_cast<std::ptrdiff_t>(seq) - static_cast<std::ptrdiff_t>(pos + 1);
if (diff == 0) {
if (head_.compare_exchange_weak(pos, pos + 1, std::memory_order_relaxed)) {
out = buffer_[pos & mask_];
seq_[pos & mask_].store(pos + capacity_, std::memory_order_release);
return true;
}
} else if (diff < 0) return false;
else pos = head_.load(std::memory_order_relaxed);
}
}
private:
std::size_t capacity_, mask_;
std::vector<T> buffer_;
std::vector<std::atomic<std::size_t>> seq_;
std::atomic<std::size_t> tail_{0}, head_{0};
};
int main() {
constexpr int kProducers = 4;
constexpr int kPerProducer = 4;
constexpr int kConsumers = 4;
constexpr int kTotal = kProducers * kPerProducer;
BoundedMpmcQueue<int> q(16);
std::vector<std::thread> producers;
for (int t = 0; t < kProducers; ++t) {
producers.emplace_back([&, t] {
for (int i = 0; i < kPerProducer; ++i) {
while (!q.try_enqueue(t * kPerProducer + i)) { }
}
});
}
for (auto& t : producers) t.join();
std::atomic<int> remaining{kTotal}; // 恰好触发 kTotal 次出队
std::atomic<long long> sum{0};
std::atomic<int> count{0};
std::vector<std::thread> consumers;
for (int t = 0; t < kConsumers; ++t) {
consumers.emplace_back([&] {
for (;;) {
if (remaining.fetch_sub(1, std::memory_order_relaxed) <= 0) break;
int v = 0;
while (!q.try_dequeue(v)) { }
sum.fetch_add(v, std::memory_order_relaxed);
count.fetch_add(1, std::memory_order_relaxed);
}
});
}
for (auto& t : consumers) t.join();
std::printf("MPMC 出队总数 = %d(期望 %d)\n", count.load(), kTotal);
std::printf("值之和 = %lld(期望 %d)\n", sum.load(), kTotal * (kTotal - 1) / 2);
}
MPMC 出队总数 = 16(期望 16)
值之和 = 120(期望 120)
这段用 clang 19 配 -fsanitize=thread 真跑过,stderr 为空、没有 data race。同步全部落在 seq_ 的 acquire/release 上,没有任何对共享数据的裸读写竞争。
三种队列的难度对比:
| 类型 | 生产端 | 消费端 | 关键竞争 | 难度 |
|---|---|---|---|---|
| SPSC | 单生产者 | 单消费者 | 无(头尾各归一方) | 最低,一个原子都不用 |
| MPSC | 多生产者 | 单消费者 | 生产者抢 tail_ | 中,消费端可简化 |
| MPMC | 多生产者 | 多消费者 | 头尾都要抢 | 高,两端都要 sequence 协议 |
MPSC 相对好写的本质:消费端是单线程,head 可以用普通 size_t,只需要处理生产端的 slot 未就绪;而 MPMC 的 head 也必须原子化,消费者之间还要靠 sequence 协议互相让路,出错面翻倍。
6. 完整示例:满/空判定与环形复用
前面两个例子都是「正好装满、一次取完」,没展示有界队列的满/空边界。下面单线程把 capacity=4 的队列填满再取空,验证 try_enqueue/try_dequeue 的 bool 返回值正确标记满/空,并演示环形复用(取空后再入队仍能成功):
// ring.cpp — 编译: g++ -std=c++17 -Wall -O2 ring.cpp -o ring
#include <atomic>
#include <cstddef>
#include <cstdio>
#include <vector>
template <typename T>
class BoundedMpmcQueue {
public:
explicit BoundedMpmcQueue(std::size_t cap)
: capacity_(cap), mask_(cap - 1), buffer_(cap), seq_(cap) {
for (std::size_t i = 0; i < cap; ++i) seq_[i].store(i, std::memory_order_relaxed);
}
bool try_enqueue(const T& value) {
std::size_t pos = tail_.load(std::memory_order_relaxed);
for (;;) {
std::size_t seq = seq_[pos & mask_].load(std::memory_order_acquire);
std::ptrdiff_t diff = static_cast<std::ptrdiff_t>(seq) - static_cast<std::ptrdiff_t>(pos);
if (diff == 0) {
if (tail_.compare_exchange_weak(pos, pos + 1, std::memory_order_relaxed)) {
buffer_[pos & mask_] = value;
seq_[pos & mask_].store(pos + 1, std::memory_order_release);
return true;
}
} else if (diff < 0) return false;
else pos = tail_.load(std::memory_order_relaxed);
}
}
bool try_dequeue(T& out) {
std::size_t pos = head_.load(std::memory_order_relaxed);
for (;;) {
std::size_t seq = seq_[pos & mask_].load(std::memory_order_acquire);
std::ptrdiff_t diff = static_cast<std::ptrdiff_t>(seq) - static_cast<std::ptrdiff_t>(pos + 1);
if (diff == 0) {
if (head_.compare_exchange_weak(pos, pos + 1, std::memory_order_relaxed)) {
out = buffer_[pos & mask_];
seq_[pos & mask_].store(pos + capacity_, std::memory_order_release);
return true;
}
} else if (diff < 0) return false;
else pos = head_.load(std::memory_order_relaxed);
}
}
private:
std::size_t capacity_, mask_;
std::vector<T> buffer_;
std::vector<std::atomic<std::size_t>> seq_;
std::atomic<std::size_t> tail_{0}, head_{0};
};
int main() {
BoundedMpmcQueue<int> q(4); // 只有 4 个槽位
int ok = 0;
for (int i = 0; i < 5; ++i) // 尝试塞 5 个,第 5 个应失败(满)
if (q.try_enqueue(i * 10)) ++ok;
std::printf("入队成功 %d 次(期望 4,第 5 次因满失败)\n", ok);
int v = 0, cnt = 0;
long long sum = 0;
while (q.try_dequeue(v)) { // 取到空为止
sum += v;
++cnt;
}
std::printf("出队 %d 个,值之和 = %lld(期望 %d 个,和 60)\n",
cnt, sum, 4);
std::printf("再入队一个 %s\n", q.try_enqueue(99) ? "成功(环形复用)" : "失败");
}
入队成功 4 次(期望 4,第 5 次因满失败)
出队 4 个,值之和 = 60(期望 4 个,和 60)
再入队一个 成功(环形复用)
60 = 0+10+20+30,第 5 次入队被「满」判定拦下;取空后(try_dequeue 返回 false 时停止)再入队又成功了——槽位被 seq[pos + capacity] 归还,环形缓冲区真正转了起来。
最后照例提醒:这里只演示了固定容量、元素可平凡复制的场景。真实的无锁队列还要处理「动态扩容」「内存回收」「等待而非自旋」这些需求,而且正确性极难肉眼验证——生产级请直接用 moodycamel::ConcurrentQueue(concurrentqueue)、Boost.Lockfree 的 spsc_queue/queue、或 liblfds,它们经过海量测试和模型检查,自己手写只适合学习和理解原理。
7. 延伸阅读
- std::atomic::fetch_add — cppreference —— 分配槽位用的读-改-写原语
- std::memory_order — cppreference —— acquire/release 配对,sequence 协议的理论根基
- std::atomic::compare_exchange — cppreference —— 抢 tail/head 用的 CAS
- std::atomic — cppreference —— 原子类型总览与 lock-free 判定
- C++ Core Guidelines · 并发章节 CP.1 —— 无锁编程的取舍建议
本知识库内的相关篇目:
- 《无锁栈:Treiber 栈的 CAS、ABA 问题与 tagged pointer》 —— 无锁栈(Treiber stack)是理解无锁编程的入门课
- 《内存序实战:release/acquire 三种经典模式与 seq_cst 的代价》 —— 为什么原子操作默认用 seq_cst?因为它最不容易写错,但也是最贵的。
- 《无锁内存回收:hazard pointer 与 epoch-based reclamation 原理》 —— 无锁栈 pop 出来的节点为什么不能立刻 delete?因为另一个线程可能还攥着它的指针。
8. 一句话总结
无锁队列比栈难在有两个端点,且 fetch_add 只「分配槽位」不保证「数据已写完」。Vyukov 有界队列用 sequence 数组给每个槽位记轮次号,入队等 seq==pos、出队等 seq==pos+1,靠 acquire/release 完成生产消费同步;MPSC 消费端单线程、只有生产端抢 tail_ 所以相对好写,MPMC 头尾都要抢、两端都要完整 sequence 协议所以难;无论哪种,正确性都极难手写验证,生产级用 concurrentqueue / Boost.Lockfree 这类现成库。
2451



被折叠的 条评论
为什么被折叠?



