本库里的内存序
主线位置:第四条线「内存序横线」的汇总页。三条主线的每一章遇到
Ordering都回答同一个问题:这一对Release/Acquire(或这个SeqCstfence)在和谁配对,防的是哪种重排。答案都归到本页的五类里。
flowchart TB
q{"这次原子操作在防什么"} -->|"让对方看到我写的数据"| c1["① Release 写 / Acquire 读"]
q -->|"我写完再读,对方也写完再读,至少一方要看到另一方"| c2["② SeqCst fence 或 SeqCst 访问"]
c2 --> c3["③ x86:CAS(SeqCst) 代替 fence(SeqCst)"]
q -->|"读指针再解引用,只需要数据依赖"| c4["④ load_consume"]
q -->|"只要数对,不和别的内存配对"| c5["⑤ Relaxed"]
这一页不讲 happens-before、修改顺序这些定义。它们在 tokio 卷里已经用具体代码讲过多次(下面列了对照),这里只做一件事:把 crossbeam 源码里出现的 Ordering 按「用来干什么」分类,后面每章引用时说「这是第 ① 类」就够了。
先数一数
在 2920c984 上统计五个发布 crate 的 src/,去掉注释、文档示例、use 语句和 #[cfg(test)] 之后的部分,数每种 Ordering 名字出现的次数(fence(...) 单独再数一遍):
| crate | Relaxed | Acquire | Release | AcqRel | SeqCst | 其中 fence |
|---|---|---|---|---|---|---|
| crossbeam-utils | 6 | 13 | 9 | 15 | 9 | Acquire 2、Release 2 |
| crossbeam-epoch | 27 | 16 | 13 | 1 | 9 | Acquire 1、SeqCst 3 |
| crossbeam-deque | 36 | 39 | 21 | 4 | 31 | Release 5、SeqCst 11 |
| crossbeam-channel | 19 | 30 | 16 | 9 | 36 | SeqCst 3 |
| crossbeam-queue | 14 | 21 | 11 | 2 | 21 | SeqCst 3 |
(epoch 的 Atomic<T> 把 Ordering 作为参数交给调用方,所以它自己的数字不大;deque 和 channel 是 SeqCst 用得最多的两个。规划阶段含注释的粗 grep 给过 epoch「SeqCst 106」,那是把文档示例里的 SeqCst 也算进去了。)
① Release 写,Acquire 读:发布一块数据
最常见的一类。写方先写普通内存(消息、任务、节点),再用 Release 写一个「标志」;读方用 Acquire 读到这个标志的新值,之后读普通内存一定能看到写方写的内容。
| 位置 | 写方(Release) | 读方(Acquire) | 发布的是什么 |
|---|---|---|---|
| array channel 的槽 | write:写 msg,再 stamp.store(tail + 1, Release) | start_recv:stamp.load(Acquire),等于 head + 1 才读 | 消息本身;反方向 read 后 stamp 的 Release 写把「槽空了」发布给发送方 |
| list channel 的槽 | slot.state.fetch_or(WRITE, Release) | wait_write 里 state.load(Acquire) & WRITE | 消息 |
deque 的 back | push:写任务,fence(Release) 再 back.store(Relaxed) | steal:back.load(Acquire) | 任务 |
epoch Atomic<T> | store / swap / compare_exchange 由调用方传 Release / AcqRel | load(Acquire) 或 load_consume | 节点 |
AtomicCell | 原子路径的 store 固定 Release | load 固定 Acquire | 值本身 |
deque 那一行用的是「Release fence + Relaxed store」,效果等同于一次 Release store;cfg(crossbeam_sanitize_thread) 下改写成真正的 Release store,因为 TSan 不认 fence(怎么验证并发代码)。
对照:tokio 本地队列的 tail.store(tail + 1, Release) 和窃取方的 tail.load(Acquire) 是同一类(tokio 本地队列);bytes 第一次 clone 时 CAS 换进 Shared 用 AcqRel,读侧 Acquire,也是这一类(bytes 的 clone 与提升)。
② SeqCst:store 之后的 load 不能提前
第 ① 类管不了这种情况:线程 A 先写 x 再读 y,线程 B 先写 y 再读 x,要求「至少一方读到对方的写」。Release 写后面的 Acquire 读是可以被重排到写前面的(store-load 重排,x86 上也会发生),两边可能都读到旧值。要禁止它,需要 SeqCst fence,或者两边的写和读都是 SeqCst 访问。crossbeam 里这一类出现在几个关键位置:
| 位置 | 一方:写,然后 | 另一方:写,然后 | 防的是什么 |
|---|---|---|---|
epoch pin | 写本地纪元(pinned),fence(SeqCst),之后才读共享指针 | try_advance:读全局纪元,fence(SeqCst),再逐个读各参与者的本地纪元 | 推进纪元的线程没看到某个参与者 pin 了,而这个参与者却读到了即将被回收的旧指针 |
epoch push_bag | 摘下的对象进袋子,fence(SeqCst),再读全局纪元给袋子封印 | 同上 | 读到的全局纪元比摘下对象时的真实纪元还旧,袋子封上旧纪元,就会提前「过期」、被提前回收 |
deque LIFO pop | back.store(b - 1),fence(SeqCst),再读 front | steal:读 front,fence(或 pin 里的 fence),再读 back,最后 CAS front | 主人和窃取者都以为自己拿到了同一个(最后一个)任务 |
| array channel 判满 / 判空 | 读到槽的 stamp 显示「满」,fence(SeqCst),再读 head 确认 | 接收方 compare_exchange_weak(head, …, SeqCst) | 用一个过时的 head 误判为满 |
SyncWaker 的握手 | 阻塞方:注册 → is_empty.store(false, SeqCst) → 再查一次 is_full()(head / tail 的 SeqCst 读) | 唤醒方:head 的 SeqCst CAS → … → is_empty.load(SeqCst) | 丢唤醒:阻塞方没看到通道变得可用,唤醒方也没看到有人在等 |
最后一行是经典的 Dekker 式握手,用 SeqCst 访问而不是 fence。tokio 的 worker 睡前「最后再查一遍所有队列」是同一个形状(任务窃取与空闲)。
epoch 和 deque 的 fence 在 一块内存的回收 和 一个任务 里有时序图;完整论证在 pin 与 Guard 和 Chase-Lev 双端队列。
③ x86 上,用一次 CAS 代替 SeqCst fence
Local::pin 是 epoch 的热路径,每次 epoch::pin() 都要走一遍。internal.rs:412-444:
if cfg!(all(
any(target_arch = "x86", target_arch = "x86_64"),
not(miri)
)) {
// HACK: On x86 architectures there are two different ways of executing
// a `SeqCst` fence.
//
// 1. `atomic::fence(SeqCst)`, which compiles into a `mfence` instruction.
// 2. `_.compare_exchange(_, _, SeqCst, SeqCst)`, which compiles into a `lock cmpxchg`
// instruction.
//
// Both instructions have the effect of a full barrier, but benchmarks have shown
// that the second one makes pinning faster in this particular case. It is not
// clear that this is permitted by the C++ memory model (SC fences work very
// differently from SC accesses), but experimental evidence suggests that this
// works fine. Using inline assembly would be a viable (and correct) alternative,
// but alas, that is not possible on stable Rust.
let current = Epoch::starting();
let res = self.epoch.compare_exchange(
current,
new_epoch,
Ordering::SeqCst,
Ordering::SeqCst,
);
debug_assert!(res.is_ok(), "participant was expected to be unpinned");
// We add a compiler fence to make it less likely for LLVM to do something wrong
// here. Formally, this is not enough to get rid of data races; practically,
// it should go a long way.
atomic::compiler_fence(Ordering::SeqCst);
} else {
self.epoch.store(new_epoch, Ordering::Relaxed);
atomic::fence(Ordering::SeqCst);
}
(原注释 HACK 后面的括号里是一个人名,引用时去掉了。)三件事值得记下:
- 源码自己承认语言层面不保证。 C++ / Rust 内存模型里,SC fence 和 SC 访问的语义不同,一次
SeqCstCAS 不等于一个SeqCstfence。这里依赖的是 x86 的硬件事实:lock cmpxchg本身是全屏障。注释里「用内联汇编才是正确的替代」那句写于asm!稳定之前(Rust 1.59),代码一直没改。 - Miri 下关掉。 2022-07 的
ce2b5c35(「Do not use x86 specific fence on Miri」)加上not(miri),Miri 按语言模型检查,不认这个硬件事实。 - 只在 x86 上。 本机 aarch64 走
else分支:Relaxedstore 加fence(SeqCst)(编译成dmb ish)。
④ load_consume:只要数据依赖
consume.rs 给原生原子类型加了一个 load_consume:
#[cfg(all(
any(target_arch = "arm", target_arch = "aarch64"),
not(any(miri, crossbeam_loom, crossbeam_sanitize_thread)),
))]
macro_rules! impl_consume {
() => {
#[inline]
fn load_consume(&self) -> Self::Val {
use crate::primitive::sync::atomic::compiler_fence;
let result = self.load(Ordering::Relaxed);
compiler_fence(Ordering::Acquire);
result
}
};
}
// 其他平台、Miri、loom、TSan:self.load(Ordering::Acquire)
想法来自 C++ 的 memory_order_consume:读到一个指针,之后只通过这个指针去读,CPU 的数据依赖本身就保证顺序,不需要 Acquire 的屏障。ARM / AArch64 上 Acquire 读要用 ldar(或 ldapr),Relaxed 读是普通 ldr,省掉的就是这点差别;compiler_fence 只拦编译器,不生成指令。注释说明了为什么只开在 ARM 上:PowerPC、MIPS 上 LLVM 会把 compiler_fence(Acquire) 编译成和真 fence 等价的指令,省不下什么。Miri 和 loom 不支持 consume,TSan 不把「Relaxed + compiler fence」当成 consume,这几种情况一律退回 Acquire。
这是形式上不受语言模型保证的写法:Rust 没有 consume 序,编译器在理论上可以把数据依赖优化掉。实际用户只有两个:epoch 的 Atomic::load_consume,和 skiplist 查找时沿塔往下走的 6 处(并发跳表)。channel、deque、queue 都不用它。
⑤ Relaxed:只要数对
不和任何其他内存配对的计数和提示,用 Relaxed:
- deque
pop/push里主人读自己的back(只有主人写back);Worker::len读back。 - epoch
Local::pin先Relaxed读全局纪元,正确性由后面的 fence 保证;pin_count甚至不是原子变量,只是Cell。 - skiplist 的长度计数
len是Relaxed的加减,len()读出来的值可能「下溢」成一个巨大的数,于是大于isize::MAX的一律当 0:
pub fn len(&self) -> usize {
let len = self.hot_data.len.load(Ordering::Relaxed);
// Due to the relaxed memory ordering, the length counter may sometimes
// underflow and produce a very large value. We treat such values as 0.
if len > isize::max_value() as usize {
0
} else {
len
}
}
WaitGroup::clone的fetch_add(1, Relaxed):和Arc::clone同理,能克隆说明计数至少是 1。
动脑筋
array channel 的 write 是这样的:
slot.msg.get().write(MaybeUninit::new(msg));
slot.stamp.store(token.array.stamp, Ordering::Release);
如果把这里的 Release 改成 Relaxed,start_recv 里的 stamp.load(Acquire) 保持不变,在 x86_64 和 aarch64 上分别可能出什么问题?x86_64 上能用测试复现吗?答案在本页末尾。
和 tokio 卷对照
| 本页的类 | tokio 卷里的例子 |
|---|---|
| ① Release / Acquire 发布 | 本地队列的 tail;任务状态机里 AcqRel 的状态转换 |
| ② SeqCst 握手 | 任务窃取与空闲的「睡前最后一次检查」 |
| 和 park 配合的 SeqCst 三态 | driver 与 park(crossbeam 的 Parker 是同一段算法) |
tokio 里没有 ③ 和 ④:它不在热路径上用 fence 代替品,也没有 consume 读。
动脑筋答案
- aarch64:
Relaxedstore 允许和前面对msg的普通写重排。接收方可能先看到新的stamp(等于head + 1),CAS 抢到head,然后读槽:读到的是还没写完、甚至从未初始化过的MaybeUninit<T>,assume_init()就是未定义行为,T是String之类时会拿着垃圾指针去释放。 - x86_64:硬件不会把两次写互相重排(TSO),普通的
mov写本身就有 Release 语义,所以生成的机器码大概率和原来一样,测试几乎不可能复现。但这仍然是语言层面的数据竞争,编译器有权把msg的写挪到stamp的写后面。这类错误要靠 loom、Miri 或 TSan 去抓,见 怎么验证并发代码。
小结
- ① Release 写 + Acquire 读发布数据:array 的
stamp、list 的WRITE位、deque 的back、AtomicCell的固定序。 - ② store-load 需要
SeqCst:epoch 的pin/try_advance/push_bag,deque 的 LIFOpop/steal,array 的判满判空,SyncWaker的 Dekker 式握手。 - ③ x86 上
pin用compare_exchange(SeqCst)代替fence(SeqCst),源码自认不确定语言模型是否允许;Miri 下关闭。 - ④
load_consume只在 ARM / AArch64 上是Relaxed+compiler_fence(Acquire),用户只有 epoch 和 skiplist。 - ⑤
Relaxed计数:skiplist 的len把可能下溢出的巨大值当 0。