Rust in Everything
基于 loom 0.7.2

fence 与 SeqCst

主线位置:主线 2 与主线 4 的交点。fence 改变的是线程时钟(向量时钟与同步点),从而改变后续 load 的候选(load 的候选);SeqCst 是 loom 内存模型里取舍最大的一处,README 把它列为第一条「Unsupported features」。

四种 fence

loom::sync::atomic 只导出 fence,没有 compiler_fence(sync/atomic/mod.rs:25-28);crossbeam 在 loom 下用 fence 代替 compiler_fence,见 crossbeam 卷的怎么验证并发代码。运行时实现 rt/atomic.rs:166-209:

pub(crate) fn fence(ordering: Ordering) {
    rt::synchronize(|execution| match ordering {
        Ordering::Acquire => fence_acq(execution),
        Ordering::Release => fence_rel(execution),
        Ordering::AcqRel => fence_acqrel(execution),
        Ordering::SeqCst => fence_seqcst(execution),
        Ordering::Relaxed => panic!("there is no such thing as a relaxed fence"),
        order => unimplemented!("unimplemented ordering {:?}", order),
    });
}

fn fence_acq(execution: &mut Execution) {
    // Find all stores for all atomic objects and, if they have been read by
    // the current thread, establish an acquire synchronization.
    for state in execution.objects.iter_mut::<State>() {
        // Iterate all the stores
        for store in state.stores_mut() {
            if !store.first_seen.is_seen_by_current(&execution.threads) {
                continue;
            }

            store
                .sync
                .sync_load(&mut execution.threads, Ordering::Acquire);
        }
    }
}

fn fence_rel(execution: &mut Execution) {
    // take snapshot of cur view and record as rel view
    let active = execution.threads.active_mut();
    active.released = active.causality;
}
fence做什么
Acquire遍历所有原子对象窗口里的所有写,凡是「已被当前线程看到」(is_seen_by_current)的,都对它的同步点做一次 acquire。之前的 Relaxed 读本身不传递时钟,到 fence 这里一次补齐
Release把当前 causality 整个存进线程的 released。之后每一次写,sync_store 都先并入 released,所以 fence 之后的 Relaxed 写也带着 fence 之前的一切
AcqRel先 Acquire 再 Release
SeqCstAcqRel,再加 seq_cst_fence(下一节)
Relaxedpanic「there is no such thing as a relaxed fence」(实测原文)。std 写成字面量 fence(Relaxed) 时编译期就被 invalid_atomic_ordering 拒绝,运行时传入 Relaxed 也 panic 同一句话;这个 lint 只认 std 的函数,loom 的 fence(Relaxed) 能编译,到运行时才 panic

两个细节:

  • fence 不是调度点。 它只调 rt::synchronize(自增时钟),不调 branch。fence 前后能不能切到别的线程,取决于前后的原子操作。
  • fence_acq 的代价和模型里原子对象的个数成正比(每个对象最多 7 次写),fence(SeqCst) 也包含这一步。

实验(exp-loom/memory-model/tests/fences.rs 的 mp_fences):消息传递,数据是 UnsafeCell,flag 的读写都用 Relaxed,靠 fence 同步。

写法结果
写方 fence(Release),读方 fence(Acquire)6 个交错通过
只有读方 fence(Acquire)第 3 个交错「Causality violation: Concurrent read and write accesses.」
只有写方 fence(Release)第 3 个交错,同上

上游的三个 fence_sw_* 测试(tests/fence.rs:10-75)是同一组:fence 对 fence、release 写对 acquire fence、release fence 对 acquire 读,对应 CDSChecker 图 10 的三条 Fence Synchronization 规则。

fence(SeqCst):一个全局时钟

rt/thread.rs:343-349:

    pub(crate) fn seq_cst_fence(&mut self) {
        self.threads[self.active.unwrap()]
            .causality
            .join(&self.seq_cst_causality);
        self.seq_cst_causality
            .join(&self.threads[self.active.unwrap()].causality);
    }

seq_cst_causality 是 thread::Set 上的一个全局时钟,每轮清零。每执行一次 fence(SeqCst),当前线程先并入它、再把自己并回去。效果:loom 执行顺序里后执行 fence(SeqCst) 的线程,得到先执行的线程在它那道 fence 之前的全部因果历史。

以 SB(store buffering)为例:线程 A x = 1; fence(SeqCst); 读 y,线程 B y = 1; fence(SeqCst); 读 x。不论哪个先执行 fence,后执行的那一方都通过全局时钟知道了对方的写;对方的写已经「被看到」,按 load 的候选 的规则一,更老的初值 0 不再是候选,这一方只能读到 1。所以 (0, 0) 消失。

sequenceDiagram
  participant A as 线程 A
  participant G as seq_cst_causality
  participant B as 线程 B
  A->>A: x.store(1, Relaxed)
  A->>G: fence(SeqCst):A 并入 G,G 并入 A
  B->>B: y.store(1, Relaxed)
  B->>G: fence(SeqCst):B 并入 G(得到 A 的时钟)
  B->>B: x.load:x = 1 已被看到,初值 0 被排除
  A->>A: y.load:可能读到 0 或 1

SeqCst 访问:seq_cst() 是空函数

Synchronize 在 SeqCst 读写时额外调用 threads.seq_cst()(向量时钟与同步点),它的实现是 rt/thread.rs:319-341:

    /// Insert a point of sequential consistency
    /// TODO
    /// - Deprecate SeqCst accesses and allow SeqCst fences only. The semantics of SeqCst accesses
    ///   is complex and difficult to implement correctly. On the other hand, SeqCst fence has
    ///   well-understood and clear semantics in the absence of SeqCst accesses, and can be used
    ///   for enforcing the read-after-write (RAW) ordering which is probably what the user want to
    ///   achieve with SeqCst.
    /// ...
    pub(crate) fn seq_cst(&mut self) {
        // The previous implementation of sequential consistency was incorrect (though it's correct
        // for `fence(SeqCst)`-only scenario; use `seq_cst_fence` for `fence(SeqCst)`).
        // As a quick fix, just disable it. This may fail to model correct code,
        // but will not silently allow bugs.
    }

所以在 0.7.2 里,SeqCst 的 load / store / RMW 和 AcqRel 只差一处:SeqCst load 跳过被更新的 SeqCst 写压着的 SeqCst 写(atomic.rs:766-769,实验见 load 的候选 的规则三)。README 的说法与此一致(README.md:67-79):

SeqCst accesses (e.g. load, store, ..): They are are regarded as AcqRel. That is, they impose weaker synchronization, causing Loom to generate false alarms (not complete). … On the other hand, fence(SeqCst) is supported.

方向是假阳性:正确依赖 SeqCst 访问的代码可能被 loom 报错;不会因此放过错误的代码。

调用 seq_cst() 的地方因此都是空操作:Synchronize 的 SeqCst 分支、loom::sync::Mutex 构造时传 seq_cst = true(sync/mutex.rs:25,解锁处注释「Establish sequential consistency between the lock's operations」,rt/mutex.rs:70-73)、RwLock 的四处(rt/rwlock.rs 第 101、129、202、240 行)、JoinHandle 用的 Notify(seq_cst = true)。锁的同步只剩 acquire / release,见 05 Mutex 与 RwLock。

这条注释的来历

git log -S 查「As a quick fix, just disable it」,指向 fd8680c「Relax how SeqCst is modelled」(#108,2020-02-16,0.2.15)。那次提交把 seq_cst() 从「对全局时钟双向 join」改成空函数,提交说明写:原实现提供了全局 happens-before,太强;作为权宜之计先关掉,代价是不能验证正确依赖 SeqCst 的算法,但不会放过有错的代码。

注释里提到的 seq_cst_fence 是 19 个月之后才有的:ffb2672「Implement Release, AcqRel, and SeqCst fences」(#220,2021-09-22,0.5.2)新增了 seq_cst_fence,内容正是 #108 删掉的那段双向 join,只用在 fence(SeqCst) 上;同一个提交给注释补了括号里那句「though it's correct for fence(SeqCst)-only scenario」、加了上面的 TODO 与参考文献(SCFix 论文、P0668R5、Promising Semantics),并在 README 增加「Unsupported features」一节。在 #220 之前,loom 只支持 fence(Acquire),其他序 assert_eq! 失败(f8ef5e1 #57 引入)。

三个 litmus

全部只用两个或三个原子变量,结果用 std 的 Mutex<BTreeSet> 在模型外收集。不设抢占上限。

SB:store buffering(E5)

两个线程各写自己的变量、再读对方的变量。目录 exp-loom/survey/tests/litmus.rs 与 exp-loom/memory-model/tests/fences.rs 的 sb_one_sided_fence。

写法交错数(A 读到, B 读到)
Relaxed 写、Relaxed 读30{(0,0), (0,1), (1,0), (1,1)}
Release 写、Acquire 读30同上
SeqCst 写、SeqCst 读30同上
Relaxed 读写,两边都在写读之间 fence(SeqCst)15{(0,1), (1,0), (1,1)}
同上,只有一边有 fence30{(0,0), (0,1), (1,0), (1,1)}

全 SeqCst 访问的 SB 在 C++ 里不允许 (0, 0),loom 给出它,这就是 README 说的 false alarm。用 fence 改写后 (0, 0) 消失,交错数也少一半;只有一边加 fence 不够。上游 tests/fence.rs:77-101 的 sb_fences 是同一个模型。

crossbeam 卷 Chase-Lev 双端队列 的 E5 正是这个形状:LIFO pop 与 steal 各一道 fence(SeqCst),去掉任一道,第一个交错就把同一个任务取出两次。那个模型里 SeqCst 不只出现在 fence 上,pop 与 steal 对 front 的 compare_exchange 成功序也是 SeqCst;按本章,这两处在 loom 里等同 AcqRel 的 RMW(RMW 本来就只读最新写)。完整协议通过、去掉任一道 fence 就失败,起区分作用的是 fence,那一页「loom 0.7.2 确实模拟了 SeqCst fence 的语义」的结论成立。

RWC:read-to-write causality

三个线程:主线程写 x = 1;线程 1 读 x 得 a、读 y 得 b;线程 2 写 y = 1、读 x 得 c。(a, b, c) = (1, 0, 0) 在两处都加 fence(SeqCst) 时被 C++20 禁止。目录 exp-loom/memory-model/tests/fences.rs 的 rwc_fences,上游对应 tests/fence.rs:136-175 的 rwc_syncs(注释:这个结果 C/C++11 允许,后来被认为太弱,C/C++20 禁止)。

写法交错数结果
全 Relaxed,没有 fence3 7728 种组合全部出现
线程 1、线程 2 的两次访问之间各一道 fence(SeqCst)2 5827 种,只少 (1, 0, 0)

上游还有一个 W+RWC(tests/fence.rs:177-217):线程 1 不直接读 x,而是通过主线程对 z 的 Release 写与自己的 Acquire 读间接知道 x 已写,其余同 RWC,同样断言 (1, 0, 0) 不出现。三个上游用例(SB+fences、RWC+syncs、W+RWC)检验的都是 CDSChecker 图 9 那类「SC fence 限制读值」的效果,loom 用全局时钟实现,而不是逐条实现图 9 的规则。本卷没有单独重跑 W+RWC。

IRIW:independent reads of independent writes

四个线程:主线程写 x,线程 W 写 y;线程 R1 先读 x 再读 y,R2 先读 y 再读 x。非 SC 结果是「R1 看到 x 已写、y 未写,R2 看到 y 已写、x 未写」,即 (1, 0, 1, 0):两个读者对两次独立写的先后看法相反。全 SeqCst 访问时 C++ 禁止它。目录 exp-loom/memory-model/tests/fences.rs 的 iriw_*。

写法交错数出现 (1, 0, 1, 0)
两次写、四次读都是 SeqCst1 180 969是
全 Relaxed,两个读者的两次读之间各一道 fence(SeqCst)1 029 359否

和 SB 一样:SeqCst 访问挡不住,fence 挡得住。四个线程加主线程,已经到了 MAX_THREADS;不设抢占上限时交错数上百万,是本部分最大的模型。

怎么用这些结论

  • 用 SeqCst 访问写的、依赖全序的正确代码(SB、IRIW 形状),loom 可能报错。确认是这个原因后,两种处理:改写成「Relaxed / Acquire / Release 访问 + fence(SeqCst)」的形式(这往往也是 C++ 实现里实际起作用的东西),或者接受它是 loom 的限制、不为它写 loom 测试。
  • 依赖 fetch_add(0) 之类 RMW「读最新值」的写法,在 loom 里和 SeqCst 无关地成立(RMW 与释放序列)。
  • 这些条目连同 load buffering 汇总在 07 SeqCst 与 load buffering。

小结

  • fence(Acquire) 对所有已被看到的写补做 acquire;fence(Release) 把当前时钟存进 released,之后每次写都带上它;fence(Relaxed) panic;fence 不是调度点。
  • fence(SeqCst) = AcqRel + 与全局 seq_cst_causality 双向 join:后执行 SC fence 的线程得到先执行者的全部因果历史。
  • SeqCst 访问调用的 seq_cst() 是空函数,只剩「SeqCst load 跳过旧 SeqCst 写」一条规则;Mutex、RwLock、Notify 的 seq_cst 都因此不起作用。
  • 历史:#108(2020-02)把全局 join 从 SeqCst 访问上拿掉,#220(2021-09)把同一段代码只用于 fence(SeqCst)。
  • SB、RWC、IRIW 三个 litmus:SeqCst 访问挡不住非 SC 结果(假阳性方向),fence(SeqCst) 挡得住;只有一边加 fence 不够。

💬 关联讨论

发起讨论
加载中...