1.一个单生产者单消费者无锁队列实例
c
class ShmSpsc {
public:
static constexpr size_t kDefaultCapacity = 1u << 18; // 262144 x 64B = 16MB
~ShmSpsc() {
if (base_) munmap(base_, mapSize_);
if (owner_) shm_unlink(name_.c_str());
}
// 生产
bool tryPush(const TopOfBookEvent& ev) {
const uint64_t wr = wr_->load(std::memory_order_relaxed);
const uint64_t rd = rd_->load(std::memory_order_acquire);
if (wr - rd >= capacity_) return false;
data_[wr & mask_] = ev;
wr_->store(wr + 1, std::memory_order_release);
return true;
}
// 消费
bool tryPop(TopOfBookEvent& ev) {
const uint64_t rd = rd_->load(std::memory_order_relaxed);
const uint64_t wr = wr_->load(std::memory_order_acquire);
if (rd == wr) return false;
ev = data_[rd & mask_];
rd_->store(rd + 1, std::memory_order_release);
return true;
}
// 容量
uint64_t capacity() const { return capacity_; }
uint64_t sizeApprox() const {
return wr_->load(std::memory_order_relaxed) - rd_->load(std::memory_order_relaxed);
}
static constexpr size_t kDataOffset = 192; // header 64 + wr 64 + rd 64
ShmSpsc(void* base, size_t capacity)
: base_(base), capacity_(capacity), mask_(capacity - 1),
wr_(reinterpret_cast<std::atomic<uint64_t>*>(static_cast<char*>(base) + 64)),
rd_(reinterpret_cast<std::atomic<uint64_t>*>(static_cast<char*>(base) + 128)),
data_(reinterpret_cast<TopOfBookEvent*>(static_cast<char*>(base) + kDataOffset)) {}
void* base_ = nullptr;
size_t mapSize_ = 0;
size_t capacity_;
size_t mask_;
std::atomic<uint64_t>* wr_;
std::atomic<uint64_t>* rd_;
TopOfBookEvent* data_;
bool owner_ = false;
std::string name_;
};
2.从编译器、CPU 流水线、缓存一致性三个层面拆解 acquire / release
2.1.零、先建立一个心智模型:三个各自为政的层面
理解内存序,关键是意识到你的程序同时运行在三个"互不信任"的层面上:
- 编译器:它看不到其他核,只按单线程语义做优化。它认为"只要本线程的最终结果不变,访存指令随便重排"。
- 本核 CPU 流水线:乱序执行、store buffer、invalidate queue,让每个核"发出的顺序"和"被其他核观察到的顺序"可以不同。
- 核间缓存一致性协议(MESI 及其变体):只承诺一件事------所有核对同一个缓存行的写入,最终会按某个全局一致的顺序收敛。它对"收敛得多快"完全不作保证。
内存序屏障(memory_order)的本质,就是在这三个层面上各打一道补丁,把"没有保证的地方"补上。
贯穿全文的具体实例------最经典的"消息传递"场景:
c
// 初始:A = 0, F = 0
// 生产者核 P: // 消费者核 C:
A = 42; while (F == 0) { /* 等待 */ }
F = 1; print(A); // 期望打印 42
假设 A 在缓存行 X 上,F 在缓存行 Y 上。两行是独立的,MESI 对它们的同步是分别进行的,协议本身不保证任何先后关系。 这句话是整个问题的核心,后面所有故障都源于它。
2.2.不加屏障时,这个实例怎么坏掉
坏法一:P 核"写出去"的顺序倒了(本核的 store buffer)
P 核执行 A = 42; F = 1;,但硬件上:
- 写 A 时,行 X 需要从共享状态升级为独占(MESI: S→M),这个升级请求要在核间走一圈。为了不等它,CPU 把这条写先丢进 store buffer------一个"待寄出的包裹架",然后立刻执行下一条指令。
- 写 F 时,行 Y 恰好在 P 核上是独占的(E),写直接落进 L1,立刻对全局可见。
于是时序变成:C 核先看到 F = 1,而 A = 42 还躺在 P 核的包裹架上没寄出。C 打印出 0。
注意:P 核自己的程序顺序完全没错,错在两个缓存行的"快递"在核间是各走各的,协议不管谁先送到。
坏法二:C 核"读进来"的是旧货(本核的 invalidate queue)
反过来,即使 P 核的写早就传播出来了,C 核也可能读不到:
- P 写 A = 42 时,按 MESI 协议,C 核 L1 里行 X 的副本必须作废(S→I),作废消息发到 C 核。
- C 核收到作废消息,但它当下很忙,就把消息塞进 invalidate queue(待处理信箱),不立即作废缓存行。
- 之后 C 核读 A,这条 load 命中自己 L1 里还没作废的旧行,直接返回 0------尽管"全局真相"早已是 42,尽管作废消息已经在 C 核的信箱里躺着。
MESI 保证的是"作废消息最终会被处理、各核最终一致",对"load 发生的那一瞬间,信箱清没清"零承诺。
坏法三:编译器重排
在前两种都还没发生的机器上,编译器照样能搞砸:它看到 A = 42 和 F = 1 是两个无关地址的写,单线程语义下互换位置结果不变,于是完全有权把 F = 1 排到 A = 42 前面发射。
3.release:一堵"后墙",管住本核的过去
memory_order_release 用在写上,语义是:本核(编译器 + 硬件)在该 store 之前的所有访存,不许排到它之后。
落到三个层面:
- 编译器:release 是编译器屏障,它前面的 st/ld 不允许被下沉到这条 store 之后。
- 本核硬件:执行 release store 时,先清空 store buffer------所有排在它前面的写,必须先推进到"对核间可见"的程度,才轮到这条 store 出去。
回到实例:P 核 A = 42; F = 1(release) 的含义变成------包裹架上的 A = 42 必须先寄出并签收,F = 1 才允许露头。坏法一被堵死。
但注意两点:
- 它是单向墙:release 不阻止它后面的操作提前。F = 1 之后的写仍可能被排到它前面(这正是它和全屏障/seq_cst 的区别)。
- 单方面的 release 没有跨核意义。墙只约束了 P 核自己;如果 C 核不配对使用 acquire,C 那边读进来的乱序(坏法二)依然无人约束。
4.acquire:一堵"前墙",管住本核的未来
memory_order_acquire 用在读上,语义是:本核(编译器 + 硬件)在该 load 之后的所有访存,不许排到它之前。
落到三个层面:
- 编译器:acquire 之后的 st/ld 不允许被提升到这条 load 之前。
- 本核硬件:执行 acquire load 时,先处理完积压的 invalidate queue,并保证后续的 load 不会被乱序发射到它前面。
回到实例:C 核 while ((F = ld_acquire) == 0); print(A) 的含义变成------一旦读到 F = 1,信箱里积压的作废消息(包括行 X 的)必须先清掉,之后读 A 时,如果行 X 已被作废,load 会 miss,于是按 MESI 向 P 核要最新的行。坏法二被堵死。
同样注意:它也是单向墙,不约束它之前的操作。
5.配对生效:release 与 acquire 如何在核间建立因果
单看两边,各自只是一堵单向墙,貌似各管各的。跨核的魔法来自配对:
c
P 核: C 核:
st A = 42
st.release F = 1 ──→ ld.acquire F == 1 (读到 → 建立 synchronizes-with)
ld A (保证读到 42)
时序上发生的事:
- P 的 release 保证了:A 的写在任何观察者看来,都先于 F = 1。
- C 的 acquire 保证了:C 读到 F = 1 这一刻,全局所有"排在 F = 1 之前的写"对 C 已可见,且 C 之后的读被强制排在读 F 之后。
- 两条墙在核间接力:P 侧的"先 A 后 F" + C 侧的"先 F 后读 A" ⇒ 读 A 必然看到 42。
用快递比喻总结:release 让 P 核"先打包的包裹先发车",acquire 让 C 核"看到发车通知后,先把积压邮件清完再拆包裹"。 中间核间那段路,由 MESI 最终一致性兜底------只要两边都把自己的墙立好,"包裹必达"就够了,路上多慢无所谓。
6.两个平台的真实实现:为什么 x86 上"免费",ARM 上"收费"
x86 / AMD64(TSO 内存模型):硬件本身就保证了 store 与 store 按序变可见(store buffer 是 FIFO 的、按序排空)、load 与 load 不重排。也就是说,上面坏法一在硬件层面基本被架构本身禁止。因此:
- st.release 编译成一条普通 mov------零成本;
- ld.acquire 编译成一条普通 mov------零成本。
x86 真正缺的是 store→load 顺序(store buffer 造成的经典 SB 模式失效),那是 seq_cst 的事,需要 lock xchg / mfence,与 acquire/release 无关。这就是"原队列在 x86 上跑 relaxed 也大概率正确"的原因------不是 relaxed 对,是 x86 替你付了钱。
ARM / POWER(弱内存模型):不同地址的 st/st、ld/ld、st/ld 都可能乱序,store buffer 和 invalidate queue 的效果全部暴露。因此:
- release store 编译为 stlr(或 dmb + st),它会挡住 store buffer 里排在前面的写------有真实成本(几十周期量级);
- acquire load 编译为 ldar(或 ld + dmb),强制清 invalidate queue 并挡住后续乱序------同样有成本。
7.回到你的 SPSC 队列:用缓存行语言复盘
队列里有两条独立的缓存行:data... 占的行,和 wr_ 占的行(rd_ 同理,且三者应分开放以避免 false sharing)。MESI 对它们的同步各自独立------wr_ 行的新值传播到 C 核,与 data 行的新值传播到 C 核,是两场互不知情的快递。
- P:data_k = ev; 然后 wr_->store(wr+1, release)。release 保证 data 行的写先于 wr 行的写对全局可见。
- C:wr_->load(acquire) 读到新值后,invalidate queue 已清、后续读被按住,再读 data_k------此时要么行已失效(miss,按 MESI 向 P 取新行),要么 P 的写早已传播,总之拿到的一定是新事件。
若load改成 relaxed 就是拆掉了这两堵墙:C 核可能在 data 行的新货还在路上时就凭 wr 的新值去读旧缓存行------实例一里那个"返回 true 但行情是旧的"的脏数据,缓存层面就是这么发生的。
8.一页总结
| 本地核效果(编译器+硬件) | 跨核效果(需配对) | x86 实现 | ARM 实现 | |
|---|---|---|---|---|
| release(写) | 之前的访存不许排到它之后;store buffer 先排空 | 我的"先行写"对所有 acquire 读者保证先可见 | 普通 mov(免费) |
stlr / dmb+st(有成本) |
| acquire(读) | 之后的访存不许排到它之前;先清 invalidate queue | 读到对方 release 的新值 ⇒ 看到它之前的全部写 | 普通 mov(免费) |
ldar / ld+dmb(有成本) |
| 单向墙? | 都只挡一个方向(区别于全屏障/seq_cst) | 必须 release 写、acquire 读配对才成立 |
一句话收束:MESI 只保证"最终一致",acquire/release 在"最终"之前人为修出一条因果链;本核的两堵单向墙(排空 store buffer、清空 invalidate queue)保证了这条链在你的核上不被乱序和旧缓存行切断。