Rust in Everything
基于 loom 0.7.2

RMW 与释放序列

主线位置:主线 2 的一个分支。fetch_add、swap、compare_exchange 走的不是 load + store,而是 rt::Atomic::rmw:读的部分只看最新的写,写的部分继承读到的那次写的同步点。

所有 RMW 都是一个函数

对外的方法全部归到 sync/atomic/atomic.rs 的两个入口:

方法实现
fetch_add / fetch_sub / fetch_and / fetch_nand / fetch_or / fetch_xor / fetch_max / fetch_min、swaprmw(闭包, order),即 `try_rmw(order, order,
compare_exchange(cur, new, success, failure)try_rmw(success, failure, …),闭包里比较,相等返回 Ok(new),否则 Err(actual)
compare_exchange_weak直接调 compare_exchange(int.rs:94-102,bool.rs、ptr.rs 相同)
compare_and_swap(cur, new, order)按 order 推出失败序(Relaxed / Release → Relaxed,Acquire / AcqRel → Acquire,其余 → SeqCst),再调 compare_exchange(atomic.rs:65-79)
fetch_update(set, fetch, f)一次 load(fetch),然后 compare_exchange 循环(atomic.rs:98-116);所以它比 fetch_add 多一个 Load 分支、每次重试多一个调度点

AtomicPtr 没有 fetch_add 一类的方法,只有 swap、CAS 系列和 fetch_update。

rt::Atomic::rmw:读最新的那一个

rt/atomic.rs:297-336 的骨架和 load 一样:branch(Action::Rmw) → synchronize → 路径走到头时算候选、push_load → branch_load → State::rmw。区别在算候选的函数,match_rmw_to_stores:

        // Unlike `match_load_to_stores`, rmw operations only load "newest"
        // stores, in terms of modification order.
        'outer: for i in 0..self.stores.len() {
            // ...
            for j in 0..self.stores.len() {
                // ...
                if mo_i < mo_j {
                    // There is a newer store.
                    continue 'outer;
                }
            }

            // The load may return this store
            dst[n] = i as u8;
            n += 1;
        }

只要修改顺序上有任何更新的写,就不是候选,和当前线程看没看到无关、和序无关。修改顺序是偏序,所以「最新」可能不止一个:两次互不知情的写都没有比自己更新的写时,两者都是候选,RMW 读哪一个就把另一个排在前面(apply_load_coherence)。这就是 C11 的 RMW 原子性:RMW 读到的一定是它自己那次写在修改顺序上的直接前驱,不会有别的写插进中间。文件头的规则清单里「RMW Atomicity」一条是空的,实现在这里(写历史与 coherence)。

实验(exp-loom/memory-model/tests/rmw.rs 的 increment):两个线程各给计数器加一。

写法交错数最终值
fetch_add(1, Relaxed)4{2}
load(Relaxed) 再 store(v + 1, Relaxed)12{1, 2}

Relaxed 的 RMW 也不会丢更新;拆成 load + store 后,两个线程都读到 0 的交错被找了出来。

State::rmw:成功与失败

atomic.rs:501-545:

        self.loaded_locations.track(location, threads);

        // Track the load is happening in order to ensure correct
        // synchronization to the underlying cell.
        self.track_load(threads);

        // Apply coherence rules.
        self.apply_load_coherence(threads, index);

        self.stores[index].first_seen.touch(threads);

        let prev = self.stores[index].value;

        match f(prev) {
            Ok(next) => {
                self.stored_locations.track(location, threads);
                // Track a store operation happened
                self.track_store(threads);

                // Perform load synchronization using the `success` ordering.
                self.stores[index].sync.sync_load(threads, success);

                // Store the new value, initializing with the `sync` value from
                // the load. This is our (hacky) way to establish a release
                // sequence.
                let sync = self.stores[index].sync;
                self.store(threads, sync, next, success);

                Ok(prev)
            }
            Err(e) => {
                self.stores[index].sync.sync_load(threads, failure);
                Err(e)
            }
        }
  • 读的部分和 State::load 一样:竞争检测、coherence、first_seen。
  • 成功:先按成功序做一次 acquire(Release 和 Relaxed 在 sync_load 里什么也不做),再写入新值。新写的同步点不是从空白开始,而是复制被读那次写的 Synchronize,然后照常 sync_store(并入 released,Release 以上再并入当前时钟)。
  • 失败(只有 CAS 会失败):按失败序做一次 acquire,不写。读的仍是 match_rmw_to_stores 给的最新写。

释放序列

「复制被读那次写的同步点」就是注释里说的 hacky 的释放序列:线程 A Release 写了 1,线程 B 对它做一次 Relaxed 的 fetch_add,线程 C Acquire 读到 B 写的 2,C 也和 A 同步,因为 2 的同步点里带着 A 的时钟。普通写的同步点从 Synchronize::new() 开始(atomic.rs:272-295),所以普通写会切断这条链。这和 C++20 的定义一致:释放序列只由 release 写之后的 RMW 组成(C++11 还包括同一线程的后续普通写,C++20 去掉了)。

flowchart LR
  a["A:flag.store(1, Release),同步点 S1 = A 的时钟"] --> b["B:fetch_add(1, Relaxed) 读到 1,新写的同步点从 S1 复制"]
  b --> c["C:load(Acquire) 读到 2,并入 S1,与 A 同步"]
  a --> b2["B:store(2, Relaxed),同步点从空白开始"]
  b2 --> c2["C:load(Acquire) 读到 2,什么也没并入"]

实验(exp-loom/memory-model/tests/rmw.rs 的 release_sequence_*):线程 1 写 UnsafeCell 为 42,再 flag.store(1, Release);线程 2 把 flag 推到 2;主线程 flag.load(Acquire) == 2 时读单元格。

线程 2 的写法结果
flag.fetch_add(1, Relaxed)470 个交错全部通过
if flag.load(Relaxed) == 1 { flag.store(2, Relaxed) }第 78 个交错报「Causality violation: Concurrent read and write accesses.」

第二种写法里主线程读到的 2 来自一次普通的 Relaxed 写,同步点是空的,主线程和线程 1 之间没有 happens-before,单元格的读与线程 1 的写被判为并发。

失败的 CAS 也只读最新写

实验(exp-loom/memory-model/tests/rmw.rs 的 failed_cas_reads_newest):子线程 x.store(1, Relaxed) 后置 std 标志 STORED;主线程对 x 做一次读,读完看 STORED。读用两种写法:普通 load(Relaxed),或者一定失败的 compare_exchange(5, 6, Relaxed, Relaxed)(取 Ok / Err 里的值)。

主线程的读交错数(读时子线程已写, 读到的值)
load(Relaxed)6(否, 0)、(是, 0)、(是, 1)
失败的 compare_exchange4(否, 0)、(是, 1)

子线程的 1 已经写下之后,普通 load 仍可能读到 0,失败的 CAS 不会。这是代码事实:match_rmw_to_stores 不区分成功和失败。C++ 标准里「RMW 读修改顺序上最后一个值」是相对于这次 RMW 自己的写来规定的,失败的 CAS 没有写,标准对它的要求和普通 load 一样;loom 在这里给出的结果更少,方向是少探索。

compare_exchange_weak 不会伪失败

compare_exchange_weak 就是 compare_exchange。在 LL/SC 实现的平台上,weak 版本可以在值相等时也返回 Err(伪失败),所以 std 文档要求把它放在循环里。loom 里它从不伪失败。

实验 E16(exp-loom/memory-model/tests/rmw.rs 的 cas_weak_never_fails):单线程、不带循环的 compare_exchange_weak(0, 1, AcqRel, Acquire),1 个交错,结果 Ok(0)。「忘了写循环」的代码在 loom 里通过,这是少探索的方向,汇总在 07 能力边界。真实硬件上的伪失败本机也测不到:本机 aarch64-apple-darwin 上,compare_exchange_weak 和 compare_exchange 编译成同一条 casal 指令(rustc -O --emit asm 查看)。

与 tokio #1788 的关系

tokio 卷的工作窃取与空闲 讲过 #1788(2019-11-19,随 tokio 0.2.0):把 notify_should_wakeup 里的 SeqCst load 换成 fetch_add(0, SeqCst),PR 说明要写出能抓住这个 bug 的测试得先修 loom。这里只列「当时」和「现在」loom 各是什么,不推断因果。

loom 0.2.14(2019-11-19 发布,39691b2)loom 0.7.2
SeqCst 访问seq_cst() 把本线程时钟与全局 seq_cst_causality 双向 join(全局 happens-before)seq_cst() 是空函数(fd8680c #108 起),SeqCst 访问只比 AcqRel 多 load 匹配里的一条规则
RMW 读哪个写历史是一个按执行顺序追加的 Vec,RMW 读最后一个(stores.len() - 1)7 格环形数组,match_rmw_to_stores 读修改顺序上最新的写(88e4225 #111 起)

在 0.7.2 里,「SeqCst load」和「fetch_add(0, SeqCst)」在 SB(store buffering)形状上的差别可以直接测出来。

实验(exp-loom/memory-model/tests/rmw.rs 的 sb_fetch_add_zero):两个线程各写自己的变量、再读对方的变量,读分别用 load 和 fetch_add(0)。

写法交错数(线程 1 读到, 主线程读到)
SeqCst 写 + SeqCst load30{(0,0), (0,1), (1,0), (1,1)}
SeqCst 写 + fetch_add(0, SeqCst)10{(0,1), (1,0), (1,1)}
Relaxed 写 + fetch_add(0, Relaxed)10{(0,1), (1,0), (1,1)}
  • SeqCst load 版本出现 (0, 0):C++ 对全 SeqCst 的 SB 禁止这个结果,loom 给出它,是 SeqCst 访问被当作 AcqRel 的直接后果(假阳性方向),见 fence 与 SeqCst。
  • fetch_add(0) 版本没有 (0, 0):loom 按程序顺序逐个执行,两个读里后执行的那一个,对方的写已经存在且是最新的,RMW 只读最新写,只能读到 1。最后一行说明这和序无关,Relaxed 也一样。
  • 所以在 loom 0.7.2 里,fetch_add(0) 的含义就是「读最新值」。

小结

  • 所有 RMW 归到 rt::Atomic::rmw;compare_exchange_weak 就是 compare_exchange,fetch_update 是 load 加 CAS 循环。
  • RMW 只读修改顺序上没有更新写的那次写,实现了 RMW 原子性;Relaxed 的 fetch_add 也不丢更新(4 个交错,最终值恒为 2)。
  • 成功的 RMW 复制所读写的同步点,形成释放序列;普通写切断它(实验:470 个交错通过 / 第 78 个交错报竞争)。
  • 失败的 CAS 也只读最新写(实验:普通 load 在对方写后仍能读到 0,失败的 CAS 不能);weak CAS 不伪失败(E16)。
  • SB 形状里 SeqCst load 给出 (0, 0),fetch_add(0) 不给,与序无关。

💬 关联讨论

发起讨论
加载中...