Rust in Everything
基于 crossbeam 0.8.5

本库里的内存序

主线位置:第四条线「内存序横线」的汇总页。三条主线的每一章遇到 Ordering 都回答同一个问题:这一对 Release / Acquire(或这个 SeqCst fence)在和谁配对,防的是哪种重排。答案都归到本页的五类里。

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(...) 单独再数一遍):

crateRelaxedAcquireReleaseAcqRelSeqCst其中 fence
crossbeam-utils6139159Acquire 2、Release 2
crossbeam-epoch27161319Acquire 1、SeqCst 3
crossbeam-deque363921431Release 5、SeqCst 11
crossbeam-channel193016936SeqCst 3
crossbeam-queue142111221SeqCst 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 的 backpush:写任务,fence(Release) 再 back.store(Relaxed)steal:back.load(Acquire)任务
epoch Atomic<T>store / swap / compare_exchange 由调用方传 Release / AcqRelload(Acquire) 或 load_consume节点
AtomicCell原子路径的 store 固定 Releaseload 固定 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 popback.store(b - 1),fence(SeqCst),再读 frontsteal:读 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 访问的语义不同,一次 SeqCst CAS 不等于一个 SeqCst fence。这里依赖的是 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 分支:Relaxed store 加 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。

动脑筋

❗IMPORTANT
动脑筋:把 array channel 的 Release 换成 Relaxed 会怎样?

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:Relaxed store 允许和前面对 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 的 LIFO pop / 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。

💬 关联讨论

发起讨论
加载中...