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、swap | rmw(闭包, 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:成功与失败
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_exchange | 4 | (否, 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 load | 30 | {(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)不给,与序无关。