大量 GitHub 上的无锁队列实现依赖 x86-TSO 的强内存模型“碰巧正确”,迁移到 ARM(包括 Apple Silicon、AWS Graviton)后立刻暴露缺失的 acquire/release 屏障,表现为间歇性读到脏数据。本文从一段 GCC 13 在 x86-64 与 AArch64 上的对照反汇编出发,拆清 memory_order_relaxed/acquire/release/seq_cst 在收发线程模型中的正确映射,走读 Folly 和 rigtorp 的真实代码看 cache line padding 如何消灭 false sharing,剖析网络 buffer 复用场景下 ABA 与 epoch-based reclamation 的生命周期陷阱,论证为什么“一个 IO 线程喂多个 worker”应该用 MPSC 而非 MPMC,并给出一个可移植 SPSC ring 的完整实现。
一段反汇编揭开的真相
看两段一模一样的 C++ 代码,GCC 13 -O2 分别为 x86-64 和 AArch64 生成了什么(可在 Compiler Explorer 复验):
C++
// C++20, GCC 13, -O2
#include <atomic>
std::atomic<int> flag{0};
int payload = 0;
// 生产者
void producer() {
payload = 42;
flag.store(1, std::memory_order_release);
}
// 消费者
int consumer() {
while (flag.load(std::memory_order_acquire) == 0) {}
return payload;
}
x86-64 的输出:
text
producer():
mov DWORD PTR payload[rip], 42
mov DWORD PTR flag[rip], 1 ; 就是一条普通 MOV
ret
consumer():
.L2:
mov eax, DWORD PTR flag[rip] ; 也是一条普通 MOV
test eax, eax
je .L2
mov eax, DWORD PTR payload[rip]
ret
AArch64 的输出:
text
producer():
adrp x8, payload
mov w9, #42
str w9, [x8, :lo12:payload]
adrp x8, flag
stlr w9, [x8, :lo12:flag] ; STLR = Store-Release
ret
consumer():
.L2:
adrp x8, flag
ldar w0, [x8, :lo12:flag] ; LDAR = Load-Acquire
cbz w0, .L2
adrp x8, payload
ldr w0, [x8, :lo12:payload]
ret
差异一目了然。x86-64 上 memory_order_acquire 和 memory_order_release 编译出来跟 memory_order_relaxed 一模一样——都是普通 MOV。ARM 上则必须用 LDAR(Load-Acquire)和 STLR(Store-Release)这两条专门的单向屏障指令。
这就是问题的根源:如果你把 release 写成了 relaxed,x86 上编译出来的机器码完全相同,跑一千万次也测不出 bug。但 ARM 上 STR(普通写)和 STLR(Store-Release)是两条截然不同的指令,前者不阻止后续读写被 CPU 重排到它前面,消费者线程看到 flag == 1 时 payload 可能还是垃圾值。
这不是理论问题。我经手过一个消息中间件的 ARM 移植,原来在 x86 服务器上稳定运行两年的无锁队列,上 Graviton3 后大约每百万次入队出一次数据错乱——错乱的不是队列结构本身,是消费者读到了生产者还没写完的 payload。排查了三天才定位到一个 memory_order_relaxed 应该是 memory_order_release 的 store。在 x86 上,这两者生成的指令**完全相同**。
这篇文章要回答一个判断:大多数“无锁队列”实现的正确性是 x86-TSO 这个硬件特性的副产品,不是设计者有意为之;而且多数网络场景根本不需要 MPMC——用 SPSC/MPSC 更快、更容易证明正确。
x86-TSO 到底保证了什么,又没保证什么
要理解为什么 x86 上测不出 bug,得先精确理解 x86 的 Total Store Order(TSO)模型到底承诺了什么。不是“强一致”——这个词太笼统,容易产生虚假安全感。
Intel 的 Software Developer Manual Volume 3A §9.2 给出的 TSO 保证可以精简为四条规则:
Store 不与 Store 重排。如果一个核先写了 A 再写了 B,其他核看到 B 的新值时一定也能看到 A 的新值。
Load 不与 Load 重排。如果一个核先读了 X 再读了 Y,它读到的 X 值不会比读到的 Y 值“更新”。
Load 不与先前的 Store 重排——但有一个关键例外:Store 可以被后续的 Load “越过”。也就是说,一个核写了 A 之后读 B,可能在写 A 还没对外可见时就先看到了 B 的值。
所有核观察到的 Store 顺序一致。不存在核 0 看到“先写 A 再写 B”而核 1 看到“先写 B 再写 A”的情况。
这四条规则的工程含义是:**在 x86 上,每一次普通的 MOV store 自带 release 语义,每一次普通的 MOV load 自带 acquire 语义。** 编译器为 memory_order_acquire 和 memory_order_release 只需要插一个编译器屏障(asm volatile("" ::: "memory“)),告诉优化器别把前后的内存访问跨过这个点重排,CPU 硬件已经保证了剩下的事。
唯一需要真正硬件屏障的是 memory_order_seq_cst 的 store——它需要一条 MFENCE(或者用 LOCK XCHG)来阻止规则 3 中那个”Store 被后续 Load 越过"的重排。这是 TSO 唯一允许的重排方向。
画成一张表:
text
x86-64 生成的指令 ARM AArch64 生成的指令
─────────────────────────────────────────────────────────────────
relaxed MOV LDR / STR
acquire MOV (+ 编译器屏障) LDAR
release MOV (+ 编译器屏障) STLR
acq_rel MOV (+ 编译器屏障) LDAXR/STLXR(用于 RMW)
seq_cst MOV + MFENCE 或 LOCK XCHG LDAR + STLR + DMB(取决于上下文)
这张表解释了一切:在 x86 上,relaxed 和 acquire/release 生成的机器码完全相同。 你写错了 memory order,编译器不报错,x86 硬件也不报错——它会用 TSO 的强保证替你兜底。但这个兜底不存在于 ARM 上。
那个唯一的例外:StoreLoad 重排
TSO 允许的唯一重排方向是 StoreLoad:核心 0 先执行 store A,再执行 load B,但 load B 可能在 store A 对外可见之前就执行了。这会导致经典的 Dekker/Peterson 算法在 x86 上也失败——是的,x86 也不是“绝对强一致”。
但 StoreLoad 重排对生产者-消费者模式的无锁队列几乎没有影响。生产者先写 payload,再写 tail 指针;消费者先读 tail 指针,再读 payload。这里关键的依赖链是 StoreStore(生产者端)和 LoadLoad(消费者端),而 TSO 对这两个方向都保证了顺序。所以无锁队列恰好踩在了 TSO 的“免费保证”区间内。
这就是为什么你在 x86 上跑无锁队列的压力测试,哪怕 memory order 全写成 relaxed,也大概率跑不出问题——硬件在替你做你忘记做的事。
memory_order 五件套在收发线程模型中的正确映射
把五个 memory order 在无锁队列的生产者-消费者模型中精确映射一遍。我见过太多人把它们当“性能旋钮”——“seq_cst 最慢但最安全,relaxed 最快但最危险,用 release 折中一下”——这是错误的心理模型。memory order 不是性能等级,是同步语义的精确规约。
relaxed:不建立任何跨线程可见性关系
memory_order_relaxed 只保证原子性(不会撕裂),不保证任何排序。它唯一合法的使用场景是:这个原子变量的值**只对写它和读它的线程自己有意义**,不用它来“传递”其他非原子数据的可见性。
在无锁队列中,典型的合法 relaxed 用法:
C++
// C++20, GCC 13
// 生产者在 CAS 循环中"预读"当前 tail,
// 这次读不需要看到消费者的数据——真正的同步点在后面的 CAS
size_t pos = tail_.load(std::memory_order_relaxed);
这里 tail_ 的 relaxed load 是安全的,因为这次读不建立 happens-before——后面那次 CAS(带 release)才是真正的同步点。如果这里写成 seq_cst,x86 上没区别,ARM 上多一条 LDAR,白白浪费。
但如果你把发布 tail 的那次 store 也写成 relaxed——在 x86 上完全没问题,ARM 上消费者就可能看到新 tail 但看不到新 payload。
acquire + release:配对建立 happens-before
这是无锁队列最核心的同步语义。acquire 和 release 必须成对出现,一个在写端,一个在读端,中间通过同一个原子变量传递可见性。
text
生产者线程 消费者线程
───────────── ─────────────
写 payload = 42
│
├─ tail.store(new_val, release) ──▶ tail.load(acquire) ──┤
│ happens-before 建立 │ │
│ ├─ 读 payload
│ │ 保证看到 42
release 的语义是:**在这条 store 之前的所有内存写入,对任何后续通过 acquire 读到这个值的线程都可见。** 这是一个单向屏障——它阻止 release store 之前的写操作被重排到它之后,但不阻止它之后的操作被提前。
acquire 的语义是对称的:**在这条 load 之后的所有内存读取,都能看到对应 release 之前写入的值。** 它阻止 acquire load 之后的读操作被重排到它之前。
在 ARM 上,这两个语义分别对应 STLR 和 LDAR 指令。STLR 是一个“放行栏杆”——它之前的所有写入必须先完成,然后这条 store 本身完成,之后的操作才能执行。LDAR 是一个“入口栏杆”——这条 load 完成之后,后续的读写才能执行。
seq_cst:全序一致性,代价最高
memory_order_seq_cst 在 acquire/release 的基础上增加了一个全局全序:所有线程观察到的 seq_cst 操作的顺序是一致的。它的典型应用场景是需要多个原子变量之间维持一致的全局观察顺序——比如 Dekker 互斥算法。
在无锁队列中,几乎不需要 seq_cst。生产者和消费者之间只需要通过一个原子变量(tail 或 head)传递可见性,acquire/release 完全够用。我看过不少代码“为了安全”把所有原子操作都用默认的 seq_cst——在 x86 上这只是让 store 多了一条 MFENCE(或者编译器用 LOCK MOV 代替),在 ARM 上则是每个 store 都变成 STLR + 额外的 DMB ISH(Data Memory Barrier, Inner Shareable),代价显著。
我在那个消息中间件的移植项目中做过对比测试:一个 SPSC ring buffer,把所有 seq_cst 换成正确的 acquire/release,在 Graviton3 上吞吐提升了约 18%。x86 上差异不到 2%,因为 x86 的 MFENCE 只在 seq_cst store 时才出现,而 acquire/release 本就是免费的。
acq_rel:用于 Read-Modify-Write 操作
memory_order_acq_rel 只用在 compare_exchange、fetch_add 这类 RMW(Read-Modify-Write)操作上。它同时具备 acquire 和 release 的语义——这次原子操作既“拿到”了其他线程通过 release 发布的数据,也“发布”了自己之前写入的数据。
在 MPMC 队列中,生产者和消费者都可能竞争同一个 tail/head 指针,CAS 的成功路径通常需要 acq_rel:成功的那个线程既要看到前一个成功者写入的数据,也要把自己写入的数据对后续线程可见。
ARM 的 LDAR/STLR 与 x86 的对照反汇编:编译器到底插了什么
上一节从语义角度讲了 memory order 的映射,这一节从机器码层面彻底看清楚。
x86-64:TSO 是“免费的午餐”
在 x86-64 上,GCC 13 -O2 为不同 memory order 生成的代码如下(以一个 std::atomic<int> 的 store 为例,可在 Compiler Explorer 复验):
text
; memory_order_relaxed store
mov DWORD PTR [rdi], esi ; 普通 MOV,无屏障
; memory_order_release store
mov DWORD PTR [rdi], esi ; 还是普通 MOV!完全一样
; memory_order_seq_cst store
xchg DWORD PTR [rdi], esi ; XCHG 自带 LOCK 前缀(隐含全屏障)
; 或者:
; mov DWORD PTR [rdi], esi
; mfence
对于 load:
text
; memory_order_relaxed load
mov eax, DWORD PTR [rdi] ; 普通 MOV
; memory_order_acquire load
mov eax, DWORD PTR [rdi] ; 还是普通 MOV
; memory_order_seq_cst load
mov eax, DWORD PTR [rdi] ; 还是普通 MOV(TSO 保证了 load 顺序)
你看到了吗?在 x86 上,relaxed、acquire、release 三个级别的 load/store 生成的机器码完全一样。 这意味着你在 x86 上完全无法通过运行时行为来区分它们——无论你写错了哪个,跑出来的行为都是“正确”的,因为硬件在兜底。
AArch64:每个级别都对应不同的指令
同样的操作在 AArch64 上:
text
; memory_order_relaxed store
str w1, [x0] ; 普通 STR
; memory_order_release store
stlr w1, [x0] ; STLR = Store-Release
; 保证此前的所有内存写入对外可见后才执行此 store
; memory_order_seq_cst store
stlr w1, [x0] ; 也是 STLR(但配合 load 端的 LDAR 建立全序)
text
; memory_order_relaxed load
ldr w0, [x0] ; 普通 LDR
; memory_order_acquire load
ldar w0, [x0] ; LDAR = Load-Acquire
; 保证此 load 完成后才执行后续的内存访问
; memory_order_seq_cst load
ldar w0, [x0] ; 也是 LDAR
STR 和 STLR 是两条**完全不同的指令**,CPU 流水线对它们的处理方式不同。STR 是一条普通的写入,CPU 可以自由地把它重排到后续的读写之后(只要对单线程的可见性没有影响)。STLR 则带有单向屏障语义——CPU 必须保证在执行 STLR 之前,所有先前的内存操作都已经完成。
LDAR 同理:普通的 LDR 可以被 CPU 重排到前面的写操作之前(ARM 允许 LoadLoad 和 LoadStore 重排),而 LDAR 建立了一个“不可穿越”的边界。
ARMv8.1 的进化:LDAPR(Acquire 的弱化版)
ARMv8.1 引入了 LDAPR(Load-AcquirePc),比 LDAR 弱:它只阻止 LoadLoad 和 LoadStore 重排,不阻止 StoreStore 重排。GCC 在某些场景下会把 memory_order_acquire 的 load 编译成 LDAPR 而不是 LDAR,获得更好的性能。但 LDAR 仍然是最安全的选择。
实际影响:一次 CAS 在两个架构上的差异
看一个 compare_exchange_weak 在两个架构上的编译结果:
C++
// C++20, GCC 13, -O2
bool try_enqueue(std::atomic<size_t>& tail, size_t expected, size_t desired) {
return tail.compare_exchange_weak(
expected, desired,
std::memory_order_release, // 成功
std::memory_order_relaxed // 失败
);
}
x86-64:
text
try_enqueue:
mov rax, rsi ; expected → rax
lock cmpxchg [rdi], rdx ; LOCK CMPXCHG(自带全屏障,比 release 更强)
sete al
ret
x86 的 LOCK CMPXCHG 本身就是一个全屏障操作——它既是 acquire 也是 release,甚至比你请求的 release 更强。**在 x86 上,CAS 的 memory order 参数几乎不影响生成的代码。**
AArch64:
text
try_enqueue:
.L1:
ldxr x3, [x0] ; LL: Load-Exclusive(relaxed)
cmp x3, x1
b.ne .L2
stlxr w4, x2, [x0] ; SC: Store-Exclusive-Release
cbnz w4, .L1 ; SC 失败重试(伪失败)
mov w0, #1
ret
.L2:
clrex ; 清除 exclusive monitor
mov w0, #0
ret
ARM 用 LL/SC(Load-Linked/Store-Conditional)来实现 CAS。注意 LDXR 是不带 acquire 的(因为失败路径是 relaxed),而 STLXR 带了 release 后缀。如果你把成功路径也写成 relaxed,这里就会变成普通的 STXR——少了 release 语义,消费者就可能看到过期的 payload。
一个“正确的”无锁队列为什么在 ARM 上炸:具体的失效路径
讲完了原理,下面具体推演一个有 bug 的无锁队列在 ARM 上的失效路径。不是假设,是真实会发生的时序。
假设一个 SPSC 环形队列,开发者用了 relaxed store 来发布 tail:
C++
// BUG: release 被误写为 relaxed
// C++20, GCC 13
template<typename T, size_t N>
class BrokenSPSC {
alignas(64) std::atomic<size_t> head_{0};
alignas(64) std::atomic<size_t> tail_{0};
T buffer_[N];
public:
bool push(const T& val) {
size_t t = tail_.load(std::memory_order_relaxed);
if (t – head_.load(std::memory_order_acquire) >= N)
return false; // 满
buffer_[t % N] = val;
// BUG: 这里应该是 release,不是 relaxed
tail_.store(t + 1, std::memory_order_relaxed);
return true;
}
bool pop(T& val) {
size_t h = head_.load(std::memory_order_relaxed);
if (h == tail_.load(std::memory_order_acquire))
return false; // 空
val = buffer_[h % N];
head_.store(h + 1, std::memory_order_release);
return true;
}
};
在 x86 上,tail_.store(t + 1, relaxed) 编译成 MOV。由于 TSO 保证 StoreStore 不重排,buffer_[t % N] = val 的写入一定在 tail_.store 之前对外可见。消费者通过 tail_.load(acquire) 读到新 tail 时,payload 已经在那里了。**测不出 bug。**
在 ARM 上,tail_.store(t + 1, relaxed) 编译成 STR。ARM 允许 StoreStore 重排——CPU 可以在 buffer_[t % N] = val 的写入到达缓存之前,就先把 tail_ 的新值写出去。消费者在另一个核上通过 LDAR 读到了新 tail,立刻去读 buffer_[h % N],读到的是**上一轮的旧值**或者未初始化的垃圾。
text
时序(ARM 上可能发生):
生产者核 (Core 0) 消费者核 (Core 1)
──────────────── ────────────────
buffer_[0] = val ──┐
│ CPU 重排!
tail_.store(1, STR) ──┘──▶ 先到达 tail_.load(LDAR) → 1
内存/缓存 val = buffer_[0] → ?? ← 垃圾!
(payload 的写入还没传播到 Core 1)
这个 bug 有几个特征使它极难排查:
间歇性:只有在 CPU 恰好选择重排 StoreStore 时才会触发,概率跟负载、核心数、缓存压力有关。我观察到的频率大约是每百万次操作出一次。
加 printf 就消失:printf 涉及 I/O 系统调用,内核会执行内存屏障,把你漏掉的同步“补上”了。
降优化级别就消失:-O0 时编译器不做指令重排,加上频繁的栈溢出,各种副作用会“碰巧”保证顺序。
TSan 可能抓不到:ThreadSanitizer 能检测 data race,但如果你用了 atomic(哪怕是 relaxed),TSan 认为你“有意识地选择了 relaxed 语义”,不一定报错。
修复只需要一个词:把 tail_.store(t + 1, std::memory_order_relaxed) 改成 tail_.store(t + 1, std::memory_order_release)。ARM 上的 STR 变成 STLR,StoreStore 重排被阻止。
MPMC 的真实代价:每多一个竞争者,CAS 重试翻一倍
在讨论“该用 SPSC 还是 MPMC”之前,先搞清楚 MPMC 队列到底在为什么付出代价。
一个典型的 bounded MPMC 队列(Vyukov 风格,简化自 Dmitry Vyukov 在 1024cores.net 上的经典设计)的入队操作:
C++
// C++20, GCC 13。简化自 Vyukov bounded MPMC queue
// 仅展示核心入队逻辑,省略类定义和出队
struct Cell {
std::atomic<size_t> sequence;
T data;
};
bool enqueue(const T& val) {
Cell* cells = cells_;
size_t mask = mask_;
size_t pos = enqueue_pos_.load(std::memory_order_relaxed);
for (;;) {
Cell& cell = cells[pos & mask];
size_t seq = cell.sequence.load(std::memory_order_acquire);
intptr_t diff = static_cast<intptr_t>(seq) – static_cast<intptr_t>(pos);
if (diff == 0) {
// 这个 slot 可用,尝试 CAS 占位
if (enqueue_pos_.compare_exchange_weak(
pos, pos + 1, std::memory_order_relaxed)) {
// 占位成功,写入数据
cell.data = val;
// 发布:让消费者看到
cell.sequence.store(pos + 1, std::memory_order_release);
return true;
}
// CAS 失败:别的生产者抢先了,pos 已被刷新,重试
} else if (diff < 0) {
// 队列满
return false;
} else {
// diff > 0:另一个生产者刚在写、还没发布,重读 pos
pos = enqueue_pos_.load(std::memory_order_relaxed);
}
}
}
这段代码展示了 MPMC 的核心开销:**每个生产者都要通过 CAS 竞争 enqueue_pos_**。当有 N 个生产者同时入队时:
CAS 竞争是串行化点。同一时刻只有一个生产者能 CAS 成功,其余 N-1 个都失败重试。高竞争下平均重试次数正比于 N。
**enqueue_pos_ 所在的 cache line 在核间弹跳**。每次 CAS(无论成功失败)都会把这条 cache line 从一个核的 L1 拉到另一个核的 L1,触发 cache coherence 协议(MESI 的 Invalidate/Share 往返)。在典型的 Intel Xeon 上一次跨 NUMA 的 cache line 传输约 100-200 个周期,这直接叠加到延迟上。
**每个 Cell 的 sequence 也是竞争点**。消费端同样有类似的 CAS 竞争 dequeue_pos_。
我做过一个简单的 benchmark(GCC 13, -O2, Intel Xeon Gold 6348, 2 NUMA nodes, 56 cores):固定队列容量 65536,每个线程入队 10M 条消息,测总吞吐。
text
线程数 (生产者) MPMC ops/sec SPSC ops/sec (对照)
───────────── ───────────── ─────────────────
1 ~180M ~320M
2 ~120M N/A (SPSC 单生产者)
4 ~65M N/A
8 ~28M N/A
16 ~11M N/A
MPMC 在 8 个生产者时吞吐已经不到单线程的 1/6。CAS 重试和 cache line bouncing 是平方级的代价,不是线性的。
而 SPSC 的单线程吞吐接近 320M ops/sec——因为它根本没有 CAS。生产者无竞争地写 tail,消费者无竞争地写 head,两个指针在不同的 cache line 上,几乎没有跨核通信。
为什么“一个 IO 线程喂多个 worker”应该用 MPSC 而非 MPMC
网络编程中最常见的线程模型是:一个(或少量)IO 线程负责收包/解包,然后把消息分发给 N 个 worker 线程处理。很多人第一反应是用一个 MPMC 队列:IO 线程往里扔,N 个 worker 竞争着取。
但这个模型的性能瓶颈在消费端的 CAS 竞争——N 个 worker 都在抢 dequeue_pos_。这跟上一节分析的生产端 CAS 竞争完全对称,开销随 N 增长。
更好的架构是:每个 worker 一个 MPSC 队列。IO 线程根据连接亲和性把消息推到对应 worker 的队列里。
text
方案 A:一个 MPMC 队列(竞争严重)
┌───────────┐
IO Thread ──▶ │ MPMC Queue│ ──▶ Worker 0
│ │ ──▶ Worker 1
│ │ ──▶ Worker 2
└───────────┘ … N workers 竞争 dequeue
方案 B:N 个 MPSC 队列(无消费端竞争)
IO Thread ──▶ MPSC Queue 0 ──▶ Worker 0 (独占消费)
──▶ MPSC Queue 1 ──▶ Worker 1 (独占消费)
──▶ MPSC Queue 2 ──▶ Worker 2 (独占消费)
方案 B 的优势:
消费端零竞争。每个 worker 独占消费自己的队列,head_ 永远只有一个线程写,不需要 CAS——直接 head_.store(h + 1, release) 即可。这使得消费端的开销降到跟 SPSC 一样。
生产端竞争降低。如果有多个 IO 线程(比如 SO_REUSEPORT 模式),每个 IO 线程推到不同 worker 队列的概率分散了竞争。即使所有 IO 线程都可能推同一个 worker,MPSC 的生产端 CAS 竞争也只在 IO 线程之间,不像 MPMC 的消费端那样牵涉所有 worker。
连接亲和性。同一个 TCP 连接的所有消息可以路由到同一个 worker,保证有序处理,不需要额外的序列号排序。这在协议解析、会话状态管理场景下极其重要——MPMC 模式下同一个连接的消息可能被不同 worker 抢到,导致乱序。
cache 局部性。每个 worker 只读自己的队列,head 指针和队列 buffer 长期驻留在自己核心的 L1/L2 cache 中,几乎不触发跨核传输。
MPSC 队列的 consumer 端有多简单
MPSC 的消费端退化成了 SPSC 的消费端——因为只有一个消费者。这意味着消费端的代码可以极度简化:
C++
// C++20, GCC 13
// MPSC 队列的消费端——只有一个消费者,不需要 CAS
bool dequeue(T& val) {
size_t h = head_.load(std::memory_order_relaxed); // 只有自己读写 head_
size_t t = tail_.load(std::memory_order_acquire); // 看生产者发布的 tail
if (h == t) return false; // 空
val = buffer_[h & mask_];
head_.store(h + 1, std::memory_order_release); // 发布给生产者(让它知道这个 slot 回收了)
return true;
}
消费端一个 CAS 都没有。所有的竞争压力都在生产端的 tail_ CAS 上,而生产端只有 IO 线程在竞争——数量通常很少(1-4 个)。
什么时候确实需要 MPMC
MPMC 不是没有用武之地。如果你的场景是工作窃取(work-stealing)——每个 worker 有自己的任务队列,但空闲时可以从其他 worker 的队列里偷任务——那 MPMC 的灵活性是必要的。Go runtime 的 goroutine 调度器和 Tokio 的 work-stealing scheduler 都用了这种模式。
但要注意:真正高性能的 work-stealing 实现(比如 Chase-Lev deque)通常不是一个通用 MPMC 队列,而是一个 SPSC + 原子窃取端的混合结构,把常见路径(自己的 push/pop)做成无竞争的,只有窃取路径才有 CAS 竞争。这是一个比通用 MPMC 更精细的设计。
ABA 与生命周期:网络 buffer 复用场景下最阴险的陷阱
无锁队列在网络服务中有一个特殊的生命周期陷阱:buffer 复用。网络框架通常预分配一个 buffer pool,收包时从池中取一个 buffer,处理完后归还。如果用无锁栈或无锁链表管理这个 pool,ABA 问题就会严重威胁正确性。
ABA 问题的精确定义
ABA 是 CAS 操作的固有陷阱:
text
时序:
线程 A 线程 B
──────── ────────
读取 head → 指针 P(指向 node X)
pop(X) → X 被回收
(buffer 归还到 pool)
push(X) → X 重新入队
(buffer 被新请求复用)
CAS(head, P, P->next)
→ CAS 成功!因为 head 还是 P
→ 但 P->next 可能已经变了(X 被复用后 next 指向了别的 node)
→ 队列结构被破坏
CAS 只比较值(指针地址),不比较身份(这个 node 是不是我当初看到的那个)。当 node X 被回收又重新分配时,它的地址没变,但内容(包括 next 指针)已经被覆盖了。CAS 看到地址没变就认为“没人动过”,这是致命的误判。
在网络 buffer 复用场景下,ABA 尤其危险:
Buffer 地址复用是常态。预分配的 buffer pool 本就是固定地址集合,pop 出去用完又 push 回来,地址必然重复。
时间窗口很大。一个 buffer 可能在网络 IO、协议解析、业务逻辑、序列化整个链路上流转,从 pop 到 push 回来可能跨越数毫秒——足够其他线程在队列上做多次操作。
后果不是 crash,是数据错乱。队列结构被悄悄破坏,后续的 pop 可能返回错误的 buffer、跳过 buffer、甚至无限循环。
解决方案一:Tagged Pointer(版本号标签)
最经典的 ABA 解决方案是给指针附加一个单调递增的版本号:
C++
// C++20, GCC 13。Tagged pointer 解决 ABA
// 利用 x86-64 地址空间只用低 48 位,高 16 位可以放版本号
// 注意:这个技巧在指针压缩或 5-level paging 的内核上需要调整
struct TaggedPtr {
uintptr_t ptr : 48;
uintptr_t tag : 16; // 版本号,每次 CAS 成功后 +1
};
static_assert(sizeof(TaggedPtr) == 8);
// CAS 时比较整个 64 位(包含版本号),
// 即使地址相同、版本号不同也会 CAS 失败
std::atomic<TaggedPtr> head_;
bool pop(Node*& result) {
TaggedPtr old = head_.load(std::memory_order_acquire);
for (;;) {
if (old.ptr == 0) return false;
Node* node = reinterpret_cast<Node*>(old.ptr);
TaggedPtr next{reinterpret_cast<uintptr_t>(node->next), old.tag + 1};
if (head_.compare_exchange_weak(old, next,
std::memory_order_acq_rel, std::memory_order_acquire)) {
result = node;
return true;
}
// old 已被刷新,重试
}
}
Tagged pointer 在实践中足够好——16 位版本号意味着要 65536 次 ABA 巧合才会误判。但它有两个限制:一是依赖平台特定的指针布局(x86-64 的 48 位地址空间),可移植性差;二是在 AArch64 上也可以利用高位,但 Top Byte Ignore(TBI)和 Memory Tagging Extension(MTE)可能占用这些位。
解决方案二:Hazard Pointer
Hazard pointer 是 Maged M. Michael 在 2004 年提出的安全回收方案,已被接纳为 C++26 标准(<hazard_pointer>, P2530)。核心思想:**线程在访问一个共享 node 之前,先把这个 node 的地址“公告”在自己的 hazard pointer slot 里;其他线程在回收 node 前,必须扫描所有线程的 hazard pointer,只有没人保护的 node 才能真正释放。**
C++
// C++26 hazard_pointer 概念示意(API 以 P2530R4 为准)
// 简化展示核心流程
void consumer_thread(LockFreeStack& stack) {
auto hp = std::make_hazard_pointer(); // 获取一个 hazard pointer slot
for (;;) {
Node* ptr;
do {
ptr = stack.head_.load(std::memory_order_acquire);
if (!ptr) break;
hp.protect(ptr); // 公告:我在用这个 node
// 重读 head,确认没有在 protect 之前就被别人改了
} while (ptr != stack.head_.load(std::memory_order_acquire));
if (!ptr) continue;
// 现在 ptr 受保护,即使别的线程 pop 了它也不会释放
// … 使用 ptr …
hp.reset_protection(); // 用完了,取消保护
}
}
// 回收端:把 retired 的 node 放进待回收列表
// 达到阈值后扫描所有 hazard pointer,只释放没人保护的
void retire_node(Node* node) {
node->retire(); // C++26: 加入线程本地的 retired list
// 当 retired list 足够长时自动触发 scan + reclaim
}
Hazard pointer 的代价是每次访问都需要一个 atomic store(protect)和一个 atomic load(验证),加上回收时的全局扫描。在读密集场景下这个开销不算小——每次 protect 至少一次 release store + 一次 acquire load。
解决方案三:Epoch-Based Reclamation(EBR)
EBR 是一种更粗粒度的方案:维护一个全局 epoch 计数器(通常是 0、1、2 三个值循环),每个线程进入“临界区”时记录当前 epoch,退出临界区时清除。回收逻辑只在确认所有线程都已经离开旧 epoch 时才释放那个 epoch 的 retired node。
C++
// Epoch-based reclamation 概念示意
// C++20, GCC 13
class EpochGuard {
thread_local static size_t local_epoch_;
static std::atomic<size_t> global_epoch_;
static thread_local std::vector<Node*> retired_[3]; // 三个 epoch 的待回收列表
public:
// 进入临界区
void enter() {
local_epoch_ = global_epoch_.load(std::memory_order_acquire);
// 注意:这里需要一个 fence 保证 local_epoch_ 的写
// 对 epoch advancement 逻辑可见
}
// 离开临界区
void leave() {
// 标记本线程已经不在临界区
// (实现上通常是把 local_epoch_ 设为特殊值 INACTIVE)
}
// 推进 epoch:当所有线程都已离开 old_epoch 时才能推进
static void try_advance() {
size_t e = global_epoch_.load(std::memory_order_relaxed);
// 扫描所有线程的 local_epoch_
// 如果所有线程要么在当前 epoch,要么 INACTIVE
// → 推进 epoch,回收 (e – 2) 的 retired list
}
void retire(Node* node) {
retired_[local_epoch_ % 3].push_back(node);
if (retired_[local_epoch_ % 3].size() > threshold) {
try_advance();
}
}
};
EBR 的读端开销极低——进入临界区只是一次 atomic load + 一次线程局部写,没有 per-object 的 atomic store。但它有一个致命弱点:如果任何一个线程在临界区内被阻塞(比如陷入 page fault、被调度器抢占、或者开发者不小心在临界区内做了阻塞 IO),全局 epoch 就推不动,所有 retired node 都无法回收,内存无限增长。
网络 buffer 场景该选哪个
对于预分配 buffer pool + 高频复用的网络场景,我倾向于:
如果用 SPSC ring buffer 做队列(后文会实现一个):SPSC 天然没有 ABA 问题,因为 buffer 在 ring 中的位置是固定的,不涉及指针链表。这是最干净的方案。
如果必须用链表式无锁栈管理 buffer pool:用 tagged pointer。网络 buffer 的地址空间通常是预分配的固定大小,16 位版本号足够。
如果 buffer 的生命周期跨越多个阶段(收包 → 解析 → 处理 → 归还):用 hazard pointer。EBR 在网络场景下风险大——worker 线程可能在处理某个请求时做了一次 DNS 查询或远程调用,阻塞了几百毫秒,整个 epoch 推不动。
但先别急着选方案。真正的工程建议是:先看你到底需不需要链表。如果队列是 bounded 的(网络场景几乎都是),用 ring buffer 就完全避开了 ABA 和内存回收的全部复杂度。
一个可移植 SPSC Ring 的完整实现
到这里该给完整的代码了。这是一个可移植的 SPSC ring buffer,关键设计点:正确的 acquire/release 映射、cache line padding 避免 false sharing、power-of-two 容量用位运算代替取模、本地缓存对端指针减少跨核 cache line 读取。
C++
// spsc_ring.hpp
// C++20, GCC 13 / Clang 17, 可移植到 x86-64 和 AArch64
// 参考 rigtorp/SPSCQueue 的核心思路,做了简化和注释
// License: MIT (示例代码)
#pragma once
#include <atomic>
#include <cassert>
#include <cstddef>
#include <new> // std::hardware_destructive_interference_size
#include <type_traits>
// cache line 大小:C++17 引入了 hardware_destructive_interference_size,
// 但截至 GCC 13 和 Clang 17 很多平台上它还是 constexpr 未定义或需要
// -Wno-interference-size。这里用 64 作为保守值(覆盖 x86 和 ARM 主流 CPU)
#ifndef CACHE_LINE_SIZE
#define CACHE_LINE_SIZE 64
#endif
template<typename T>
class SPSCRing {
static_assert(std::is_nothrow_move_constructible_v<T> ||
std::is_nothrow_copy_constructible_v<T>,
"T must be nothrow move or copy constructible");
public:
// capacity 必须是 2 的幂,方便用 & (capacity_ – 1) 代替 % capacity_
explicit SPSCRing(size_t capacity)
: capacity_(capacity)
, mask_(capacity – 1)
, buffer_(static_cast<T*>(
::operator new(sizeof(T) * capacity, std::align_val_t{CACHE_LINE_SIZE})))
{
assert(capacity >= 2 && (capacity & (capacity – 1)) == 0
&& "capacity must be a power of two");
}
~SPSCRing() {
// 析构未消费的元素
size_t h = head_.load(std::memory_order_relaxed);
size_t t = tail_.load(std::memory_order_relaxed);
while (h != t) {
buffer_[h & mask_].~T();
++h;
}
::operator delete(buffer_, std::align_val_t{CACHE_LINE_SIZE});
}
// 禁止拷贝和移动(指针和原子状态不可安全转移)
SPSCRing(const SPSCRing&) = delete;
SPSCRing& operator=(const SPSCRing&) = delete;
// === 生产者端 ===
// 调用方保证:只有一个线程调用 push
template<typename U>
bool push(U&& val) {
size_t t = tail_.load(std::memory_order_relaxed);
// 先查本地缓存的 head,避免不必要的跨核 cache line 读取
if (t – cached_head_ >= capacity_) {
// 本地缓存说"满了",但可能消费者已经推进了 head
cached_head_ = head_.load(std::memory_order_acquire);
if (t – cached_head_ >= capacity_) {
return false; // 真的满了
}
}
// 构造元素到 buffer 中
new (&buffer_[t & mask_]) T(std::forward<U>(val));
// 关键:release store 发布 tail
// 保证上面的构造操作在此 store 之前对消费者可见
tail_.store(t + 1, std::memory_order_release);
return true;
}
// === 消费者端 ===
// 调用方保证:只有一个线程调用 pop
bool pop(T& val) {
size_t h = head_.load(std::memory_order_relaxed);
// 先查本地缓存的 tail
if (h == cached_tail_) {
cached_tail_ = tail_.load(std::memory_order_acquire);
if (h == cached_tail_) {
return false; // 真的空
}
}
// 读取并移动元素
val = std::move(buffer_[h & mask_]);
buffer_[h & mask_].~T();
// 关键:release store 发布 head
// 保证上面的析构操作在此 store 之前完成
// (否则生产者可能在消费者还没读完时就覆写这个 slot)
head_.store(h + 1, std::memory_order_release);
return true;
}
size_t size_approx() const {
// 近似大小,不保证精确(head 和 tail 的读取不是原子的一对)
return tail_.load(std::memory_order_relaxed)
– head_.load(std::memory_order_relaxed);
}
private:
const size_t capacity_;
const size_t mask_;
T* const buffer_;
// ─── Cache Line 隔离 ───
// head_ 只被消费者写、被生产者读(通过 cached_head_ 减少频率)
// tail_ 只被生产者写、被消费者读(通过 cached_tail_ 减少频率)
// 把它们放在不同的 cache line 上,避免 false sharing
// 生产者独占的 cache line
alignas(CACHE_LINE_SIZE) std::atomic<size_t> tail_{0};
size_t cached_head_{0}; // 生产者本地缓存的 head 值
// 消费者独占的 cache line
alignas(CACHE_LINE_SIZE) std::atomic<size_t> head_{0};
size_t cached_tail_{0}; // 消费者本地缓存的 tail 值
// padding 到下一个 cache line 边界,防止 buffer 后面的其他对象误入
char pad_[CACHE_LINE_SIZE – sizeof(std::atomic<size_t>) – sizeof(size_t)];
};
这段代码有几个关键设计决策值得逐一拆解。
决策 1:为什么 capacity 必须是 2 的幂
buffer_[t % capacity] 的取模运算在 x86 上编译成 div 或 idiv,延迟约 30-40 个周期。buffer_[t & mask_] 的位与运算编译成 and,延迟 1 个周期。在每秒几亿次的入队出队操作中,这个差异是显著的。rigtorp/SPSCQueue 和 Folly 的 ProducerConsumerQueue 都强制 power-of-two 容量。
决策 2:cache line padding 的精确布局
false sharing 是 SPSC ring 最大的性能杀手。如果 head_ 和 tail_ 在同一条 cache line 上,消费者每次更新 head_ 都会让生产者核心的 tail_ 所在 cache line 失效,反之亦然。两个核心交替地把同一条 cache line 拉来拉去——这就是 false sharing,也叫"cache line ping-pong"。
解决方案是用 alignas(CACHE_LINE_SIZE) 把 head_ 和 tail_ 放在不同的 cache line 上。但光隔离这两个原子变量还不够——cached_head_ 和 cached_tail_ 也要跟对应的原子变量放在同一条 cache line 上:
text
Cache Line 0: [tail_ | cached_head_ | padding…] ← 生产者独占
Cache Line 1: [head_ | cached_tail_ | padding…] ← 消费者独占
cached_head_ 只被生产者读写,所以它跟 tail_(也只被生产者写)放同一条 cache line 没问题——都是同一个核心在操作,不会 ping-pong。cached_tail_ 同理。
决策 3:本地缓存对端指针(最关键的优化)
这是 rigtorp/SPSCQueue 相对于朴素实现性能提升最大的一个技巧。
朴素实现中,生产者每次 push 都要做 head_.load(acquire) 来检查队列是否满:这意味着**每次 push 都要跨核读取消费者的 head_,触发一次 cache line 传输**。如果生产者和消费者在不同的核心上(通常如此),这一次跨核读取的延迟是 40-100 个周期。
本地缓存的做法是:生产者把上次读到的 head_ 值存在自己的 cached_head_ 里(不是 atomic,不需要跨核可见)。push 时先用 cached_head_ 检查——如果按缓存的值队列还没满,直接跳过跨核读取。只有当缓存值显示“满了”时,才真正去读一次 head_.load(acquire),更新缓存。
这意味着:**如果队列不常满(网络场景中通常如此),生产者可能连续 push 几千次都不需要读一次 head_。** 跨核 cache line 传输从“每次 push 一次”降到了“队列快满时才一次”。
消费者端对 tail_ 做同样的缓存。
我用 perf stat 对比过有无本地缓存的版本(GCC 13, -O2, Intel Xeon Gold 6348,两个线程分别绑定到不同核心,队列容量 65536,连续 push/pop 1 亿条 64 字节消息):
text
无缓存版 有缓存版
─────────────────────────────────────────────
L1-dcache-load-misses ~45M ~2.1M
LLC-load-misses ~12M ~0.4M
ops/sec ~180M ~320M
cache miss 数量降了一个数量级以上,吞吐几乎翻倍。
决策 4:memory order 的精确选择
回顾一下每个原子操作的 memory order:
|
tail_.load() 生产者自己读 tail |
relaxed |
只有自己写 tail,读自己的值不需要同步 |
|
head_.load() 生产者读消费者的 head |
acquire |
需要看到消费者在 head_.store(release) 之前做的析构 |
|
tail_.store() 生产者发布 tail |
release |
保证 buffer 的构造对消费者可见 |
|
head_.load() 消费者自己读 head |
relaxed |
只有自己写 head |
|
tail_.load() 消费者读生产者的 tail |
acquire |
需要看到生产者在 tail_.store(release) 之前做的构造 |
|
head_.store() 消费者发布 head |
release |
保证 buffer 的析构完成后再让生产者看到空出的 slot |
没有任何地方需要 seq_cst。每一个 memory order 都有精确的理由——不是“为了安全”而升级,也不是“为了性能”而降级。
这个实现的局限性(反证自己的方案)
这个 SPSC ring 不是万能的:
固定容量。如果消费者处理速度跟不上生产者,push 返回 false,生产者必须自己处理背压(丢弃、阻塞等待、或者回退到更大的队列)。动态扩容的无锁队列存在但复杂得多,且会失去 cache line padding 的精确控制。
容量必须是 2 的幂。如果你的最佳容量恰好是 1000,你得向上取到 1024,浪费 2.4% 的空间。对于大型 buffer(比如每个 slot 是 4KB 的网络 buffer),浪费量不可忽略。
不支持批量操作。高吞吐场景下,一次 push/pop 一个元素的接口会因为 function call overhead 和 tail_/head_ 的频繁原子操作而成为瓶颈。更好的接口是 push_batch(T*, size_t) / pop_batch(T*, size_t),一次更新指针、摊薄原子操作开销。
析构的位置。当前实现在 pop 内部做 buffer_[h & mask_].~T(),如果 T 的析构函数抛异常(虽然不应该),head_ 已经更新了但元素没被正确析构。noexcept 的 static_assert 只检查了构造,析构也应该检查。
编译器与硬件的合谋:为什么 volatile 救不了你
写到这里,必须正面回应一个持续了二十年的错误做法:**用 volatile 做线程同步**。
volatile 的语义是“每次读写都真正去内存做,不要缓存在寄存器里”。它对付的是**编译器优化**——阻止编译器把多次读合并成一次、把没被后续代码引用的写删除。但它完全不处理两个问题:
CPU 指令重排。volatile 不生成任何硬件屏障指令。ARM CPU 可以把 volatile 写重排到后续的 volatile 读之前。
store buffer 可见性。CPU 的 store buffer 可能延迟 volatile 写对其他核心的可见性。
C++
// 错误示范:用 volatile 做 SPSC 同步
volatile int flag = 0;
volatile int payload = 0;
// 生产者
payload = 42;
flag = 1; // volatile store,编译器不会重排这两行
// 但 CPU 可以!ARM 上 flag = 1 可能先于 payload = 42 到达缓存
// 消费者
while (flag == 0) {}
int x = payload; // 可能读到 0,不是 42
在 x86 上这段代码“碰巧正确”——TSO 保证了 StoreStore 顺序。在 ARM 上,volatile 写编译成普通 STR,CPU 有权重排。
更糟糕的是,volatile 和 std::atomic 混用会导致编译器无法推理内存模型,产生微妙的 bug。C++ 标准明确指出([intro.races] §6.9.2.1):volatile 不构成线程间的 happens-before 关系。
**结论:在 C++11 之后,volatile 在多线程同步场景中没有任何合法用途。** 它唯一的正当用途是内存映射 I/O(MMIO),对付的是硬件寄存器,不是线程。
seq_cst 的真实代价:x86 与 ARM 的非对称影响
前面说了“大多数无锁队列不需要 seq_cst”,但到底贵多少?
在 x86 上,seq_cst store 需要一条 MFENCE 或者用 LOCK XCHG 代替普通 MOV。MFENCE 在 Skylake 架构上的延迟约 33 个周期(Intel Optimization Manual),而且它会 flush store buffer、stall 流水线。但 seq_cst load 在 x86 上是免费的——跟 acquire load 生成完全相同的 MOV。
在 ARM 上,seq_cst 的代价更不对称。seq_cst store 编译成 STLR(跟 release 一样),但 seq_cst load 可能需要在 LDAR 之后额外插入一条 DMB ISH(Data Memory Barrier, Inner Shareable),以保证全序。具体取决于编译器和上下文——GCC 13 在某些情况下会优化掉这个 DMB,但不保证。
一次 DMB ISH 在 Cortex-A78(Graviton3 使用的核心)上大约 30-50 个周期,而且它会 stall 整个流水线直到之前的所有内存操作都完成。在高频循环中(比如无锁队列的 CAS 自旋),这个代价会叠加。
回到那个 benchmark:SPSC ring buffer,容量 65536,GCC 13 -O2,1 亿次操作。
text
x86-64 (Xeon Gold 6348) AArch64 (Graviton3)
───────────────── ────────────────────── ─────────────────────
全部 seq_cst ~300M ops/sec ~185M ops/sec
正确 acq/rel ~320M ops/sec ~225M ops/sec
全部 relaxed(BUG) ~325M ops/sec ~260M ops/sec(但数据错)
x86 上从 seq_cst 换到 acquire/release 只快了约 6%(因为主要差异只在 store 端的 MFENCE)。ARM 上快了约 22%——因为 ARM 的每一级 memory order 都对应不同的指令,acquire/release 相对 seq_cst 少了额外的 barrier。
而从 acquire/release 再降到 relaxed,x86 几乎无变化(本来就是同一条 MOV),ARM 上又快了 16%——但数据是错的。**在 ARM 上,relaxed 的“性能提升”是用正确性换来的。**
这组数字说明了一个重要结论:memory order 的选择对性能的影响是平台非对称的。x86 上 acquire/release 几乎免费,没有理由用 relaxed;ARM 上 acquire/release 有约 15-20% 的开销但这是正确性的代价,不能省。
实测验证:用 ThreadSanitizer 和 LKMM Litmus 测试你的实现
讲了这么多正确性和性能的取舍,还剩一个实际问题要回答:怎么验证你的无锁队列实现是正确的?“在 x86 上跑通测试”显然不够。
ThreadSanitizer(TSan)
GCC 和 Clang 都支持 -fsanitize=thread。TSan 在运行时拦截所有内存访问和同步操作,检测 data race。它能检测到大部分“完全漏掉同步”的情况:
Bash
# GCC 13, -O1 -g -fsanitize=thread
g++ -O1 -g -fsanitize=thread -std=c++20 spsc_test.cpp -o spsc_test
./spsc_test
但 TSan 有一个重要的局限:它不检测 memory order 选择错误。 如果你用了 std::atomic 并且选择了 relaxed,TSan 会认为你是有意为之——你“选择了弱同步语义”。TSan 不会报告“你这里应该用 release 而不是 relaxed”。
所以 TSan 能抓到“忘了用 atomic”的 bug,抓不到“用了 atomic 但 memory order 选错”的 bug。后者正是 x86 到 ARM 移植时最常遇到的问题。
C/C++ Memory Model Litmus Tests
更精确的工具是 herd7(来自 INRIA 的 diy7 工具套件),它可以在抽象的 C11 内存模型上穷举所有合法的执行序列,判断一段代码是否存在 data race 或违反约束的执行路径。
写一个 litmus test 来验证我们的 SPSC ring buffer 的关键不变式:
text
C spsc-release-acquire
{
[x] = 0; (* payload *)
[flag] = 0; (* tail *)
}
P0 (int *x, atomic_int *flag) {
*x = 42;
atomic_store_explicit(flag, 1, memory_order_release);
}
P1 (int *x, atomic_int *flag) {
int r1 = atomic_load_explicit(flag, memory_order_acquire);
if (r1 == 1) {
int r2 = *x;
}
}
exists (1:r1=1 /\\ 1:r2=0)
(* 问:消费者是否可能看到 flag=1 但 payload 还是 0? *)
(* 答:No。acquire/release 配对保证了 happens-before。 *)
用 herd7 运行:
Bash
herd7 spsc-release-acquire.litmus
如果把 memory_order_release 改成 memory_order_relaxed,herd7 会报告 exists 条件可满足——即消费者可能看到 flag=1 但 payload=0。这直接证明了 relaxed 在弱内存模型上的不安全性。
在 ARM 硬件上实际触发
最直接的方法:在真正的 ARM 机器上跑压力测试。AWS Graviton(基于 ARM Neoverse)实例是最方便的选择:
Bash
# 在 Graviton3 实例上编译运行
aarch64-linux-gnu-g++ -O2 -std=c++20 -march=armv8.2-a spsc_test.cpp -o spsc_test
# 绑定两个核心,生产者和消费者分别在不同核心
taskset -c 0 ./spsc_producer &
taskset -c 1 ./spsc_consumer
如果你的实现有 memory order 错误,在 Graviton 上跑 10 亿次操作通常能在几分钟内触发。我的经验是:错误的 relaxed store 在 Graviton3 上大约每百万次操作出一次可观测的错误——频率不算高但绝不是理论上的。
而在 x86 上跑同样的测试——即使你把所有 memory order 都改成 relaxed——一千亿次也未必出一次。因为 TSO 在替你兜底。
C++20/23/26 的演进:标准在追赶工程实践
再看一下标准演进。C++11 引入 <atomic> 至今已经十多年,后续标准一直在补齐并发工具链:
C++20:
-
std::atomic<T>::wait() / notify_one() / notify_all():原子变量直接支持等待/通知,不再需要额外的条件变量。对 SPSC ring 的背压策略有意义——满时可以 tail_.wait(old_tail) 而不是忙等。
-
std::atomic_ref<T>:对非原子对象做原子操作。对 buffer pool 中“有时需要原子访问、有时不需要”的场景有用。
C++23:
-
std::atomic<T>::operator= 的 value 返回(P0558):消除了一个长期的 API 不一致。
-
更好的 constexpr 支持:原子操作可以在编译期求值(有限场景)。
C++26(已进入 Working Draft):
-
<hazard_pointer>(P2530):标准化 hazard pointer,不再需要自己实现或依赖第三方库。
-
<rcu>(P2545):Read-Copy-Update,另一种安全回收机制。
-
这两个标准化的意义不只是“有了官方实现”——更重要的是编译器可以针对标准 API 做优化,比如把 hazard pointer 的 protect 操作跟后续的 load 合并、消除不必要的 fence。
对于今天写无锁队列的工程师来说,C26 的 <hazard_pointer> 是值得关注的——它意味着你不再需要在 tagged pointer 和自己手搓的 EBR 之间做选择,标准库会给你一个经过充分验证、编译器友好的实现。但在 C26 普及之前(可能要到 2028 年左右编译器才会完整支持),Folly 的 folly::hazard_pointer 是最成熟的生产级选择。
关于跨架构移植的方法论:不要在你手头的机器上跑过就算数
总结这篇文章的核心判断:
大量无锁队列实现的“正确性”是 x86-TSO 硬件模型的赠品,不是开发者的设计。 迁移到 ARM 是一次强制性的正确性审计——你以为自己在用 acquire/release 做同步,实际上只是在用“恰好不重排的 CPU 上的恰好不重排的指令”。
正确的工作方式是按最弱的目标架构设计同步逻辑,然后让编译器在强架构上自动优化掉不必要的屏障。这恰好是 C++ memory model 的设计意图——你声明语义(acquire/release),编译器根据目标架构生成最高效的指令。在 x86 上它免费给你一个 MOV,在 ARM 上它给你一条 LDAR/STLR。两者的正确性都有保证,但你必须在源码层面说清楚你要什么语义。
同样的方法论适用于队列模型的选择。MPMC 是一个“万能”接口——谁都能 push,谁都能 pop——但这个万能性的代价是所有参与者都在竞争。如果你的架构能分解成 SPSC 或 MPSC 的组合,你应该这么做。 “一个 MPMC 队列连接所有线程”是最偷懒的设计,也是最慢的——因为它让 N 个线程竞争同一条 cache line 上的同一个 CAS。
我倾向于认为,一个系统中无锁队列的种类和数量应该被视为架构决策,而不是性能优化。选 SPSC/MPSC/MPMC 不是在调参数,是在定义线程间的通信拓扑。想清楚谁跟谁说话、单向还是双向、有没有背压——队列的类型就自然出来了。
这个问题在大模型推理引擎中同样存在。vLLM 的 scheduler 线程把请求分发给 worker,worker 处理完把结果返回——这是一个典型的“一对多分发 + 多对一收集”的拓扑。用一个 MPMC 队列做分发就是在强迫 N 个 worker 竞争出队;用 N 个 SPSC 队列,scheduler 直接往对应 worker 的队列里 push,每个 worker 独占消费自己的队列——CAS 竞争降到零,cache line bouncing 降到最低。收集端反过来:N 个 worker push 到一个 MPSC 队列,scheduler 独占消费。
这不是什么高深的架构模式。但我见过太多系统——包括一些知名的开源项目——用一个 MPMC 队列解决一切,然后花几个月在 CAS 重试的 backoff 策略上调参。退一步选对模型,比在错误的模型上做微优化有效得多。
网硕互联帮助中心







评论前必须登录!
注册