向量时钟与同步点
主线位置:主线 2 与主线 3 共用的底座。主线 2 里
Acquire读「把那次写的happens_before并进本线程时钟」,主线 3 里UnsafeCell用「上一次写的时钟是否领先」判竞争,说的都是这一页的两个类型:VersionVec和Synchronize。
VersionVec:每个线程一个计数
pub(crate) struct VersionVec {
versions: [u16; MAX_THREADS],
}
MAX_THREADS 是 5(rt/mod.rs:61-62),所以一个时钟就是 5 个 u16,按线程编号下标,Copy,10 字节。第 i 个分量的意思是「我已经知道 i 号线程做到了它的第几步」。三个操作:
| 操作 | 代码 | 含义 |
|---|---|---|
join(other) | 逐分量取最大(vv.rs:35-39) | 把对方知道的事并进来,happens-before 的传递就靠它 |
ahead(other) | 返回第一个 self[i] < other[i] 的 i(vv.rs:41-50) | 「other 记录的事里,有哪个线程的哪一步我还没看到」。检测器拿这个 i 当「冲突的那个线程」打印 |
partial_cmp | 逐分量比较,一个分量大、另一个分量小时返回 None(vv.rs:53-72) | 偏序:a <= b 就是「a 发生在 b 之前或同时」,不可比就是并发 |
后面几章用的都是这三个操作:修改顺序用 < 比较(写历史与 coherence),DPOR 用 <= 判断两次访问是否有先后(02 DPOR),检测器用 ahead 找出「没看到的那次访问」(04 检测器)。
分量是 u16,自增是普通的 += 1(vv.rs:31-33)。一个分量在一轮执行里加多少次,取决于这个线程做了多少次下面要讲的「同步操作」,每一轮开始时清零。
每个线程三套时钟
/// Tracks observed causality
pub causality: VersionVec,
/// Tracks the view of the lastest release fence
pub released: VersionVec,
/// Tracks DPOR relations
pub dpor_vv: VersionVec,
| 时钟 | 谁改它 | 谁读它 | 本卷哪里讲 |
|---|---|---|---|
causality | synchronize() 自增本线程分量;各种 acquire 把别人的时钟并进来 | 内存模型(哪些写已经「看到」)、所有竞争检测 | 本部分、04 检测器 |
released | fence(Release) / AcqRel / SeqCst 时整个复制 causality | 之后每一次写,sync_store 先把它并进写的同步点 | 下文;fence 与 SeqCst |
dpor_vv | Execution::schedule 选中线程时自增、并入对象上次相关访问的时钟 | 只有 DPOR 判断「两次访问有没有先后」 | 02 DPOR |
causality 和 dpor_vv 是两套独立的时钟:一次 Relaxed 读在 causality 上什么也不传递,但 DPOR 仍然把「读」和「之前的写」当作相关访问记进 dpor_vv。两者回答的问题不同:前者是「这个线程在内存模型意义上知道什么」,后者是「调换这两个操作的顺序会不会改变结果」。
另外还有一个全局时钟 Set::seq_cst_causality(thread.rs:54-56),只有 fence(SeqCst) 用它,见 fence 与 SeqCst。
synchronize():先给自己记一步
fn synchronize<F, R>(f: F) -> R
where
F: FnOnce(&mut Execution) -> R,
{
execution(|execution| {
execution.threads.active_causality_inc();
trace!("synchronize");
f(execution)
})
}
本线程自己的分量加一,然后执行操作本身。全仓库只有六处调用它:原子 load、store、rmw(atomic.rs:231、276、309),fence(atomic.rs:168),UnsafeCell 的 start_read / start_write(rt/cell.rs:55-84)。锁、Arc、Notify、channel 只做 sync_store / sync_load,不自增本线程的分量。
自增发生在操作之前,所以「本线程第 k 步」的编号就是做这一步时 causality[self] 的值。Store.first_seen 记的、UnsafeCell 的 write_access 记的,都是这个编号。
synchronize 和 branch 是两回事:branch 是调度点(02 DPOR),synchronize 只推进时钟。原子 load / store / rmw 两样都做;fence 和单元格访问只做 synchronize,不是调度点。
Synchronize:一个可以存时钟的地方
rt/synchronize.rs 全文 63 行,一个字段 happens_before: VersionVec:
pub fn sync_load(&mut self, threads: &mut thread::Set, order: Ordering) {
match order {
Relaxed | Release => {
// Nothing happens!
}
Acquire | AcqRel => {
self.sync_acq(threads);
}
SeqCst => {
self.sync_acq(threads);
threads.seq_cst();
}
order => unimplemented!("unimplemented ordering {:?}", order),
}
}
pub fn sync_store(&mut self, threads: &mut thread::Set, order: Ordering) {
self.happens_before.join(&threads.active().released);
match order {
Relaxed | Acquire => {
// Nothing happens!
}
Release | AcqRel => {
self.sync_rel(threads);
}
SeqCst => {
self.sync_rel(threads);
threads.seq_cst();
}
order => unimplemented!("unimplemented ordering {:?}", order),
}
}
sync_rel 是「把本线程 causality 并进同步点」,sync_acq 是「把同步点并进本线程 causality」。整个 Release / Acquire 语义就是这两句 join:
- 写:先无条件并入
released(最近一次 release fence 时的快照,没做过 fence 就是全零),这一句让「fence(Release)+Relaxed写」也能发布数据;序是Release/AcqRel/SeqCst时再并入当前causality。 - 读:序是
Acquire/AcqRel/SeqCst时,把读到的那次写的同步点并进来。 SeqCst多调的threads.seq_cst()是空函数,见 fence 与 SeqCst。
flowchart LR
subgraph w["写者线程"]
wc["causality"]
wr["released(最近一次 release fence 的快照)"]
end
s["这次写的 Synchronize"]
subgraph r["读者线程"]
rc["causality"]
end
wr -->|"任何序的写:join"| s
wc -->|"Release / AcqRel / SeqCst 写:join"| s
s -->|"Acquire / AcqRel / SeqCst 读:join"| rc
不合法的序不报错。 load(Release)、store(_, Acquire)、失败序为 Release 的 CAS,在 std 里运行时 panic(比如「there is no such thing as a release load」),loom 落到「Nothing happens!」分支照常执行。实测(exp-loom/memory-model/tests/bad_order.rs):三种写法在 loom 下各 1 个交错通过,同样的 load(Release) 用 std 原子直接 panic。字面量写错序时编译器的 invalid_atomic_ordering lint 只认 std 的原子类型,所以这类错误要靠非 loom 构建的普通测试发现。
每个原子写都带一个自己的 Synchronize(Store.sync),锁、Arc、Notify、lazy_static 各有一个,channel 每条消息一个。它们怎么用这两个函数,见 05 同步原语的模型。
Store 上的两个时钟不是一回事
原子写记录两个看起来相似的东西(atomic.rs:141-161):
Store.happens_before:写的那一刻写者的causality,无论什么序都记。只用于 coherence 规则(「这次写是否发生在本线程之前」),不传递给读者。Store.sync.happens_before:读者 acquire 时能拿到的时钟。Relaxed写且之前没有 release fence 时它是全零,读者什么也拿不到。
Relaxed 写也会让读者「不能再读更早的值」(coherence),但不会让读者「看到写者之前的普通内存写」(happens-before)。前者靠第一个时钟,后者靠第二个。
一次消息传递里的时钟
E1 的单元格版本(exp-loom/survey/tests/mp.rs):子线程 data.with_mut(写 42) 再 flag.store(true, Release);主线程 flag.load(Acquire) 读到 true 就 data.with(读)。下表是「子线程先跑完、主线程再读」那一轮,按代码逐步推算的 causality(只写前两个分量,[主线程, 子线程]):
| 步骤 | 主线程 | 子线程 | 记录下来的时钟 |
|---|---|---|---|
创建 UnsafeCell、AtomicBool | [0, 0] | — | 单元格 write_access = [0, 0];flag 初值的同步点 [0, 0] |
thread::spawn(new_thread) | [1, 0] | [0, 1] | 子线程继承父线程时钟,双方各自加一 |
子:with_mut 开始(synchronize) | [0, 2] | write_access 并入 → [0, 2] | |
子:store(true, Release) | [0, 3] | 这次写的 sync.happens_before = [0, 3] | |
主:load(Acquire) 读到 true | [2, 0] → [2, 3] | 自增后并入 [0, 3] | |
主:with 开始 | [3, 3] | 检查 ahead(write_access = [0, 2]):没有领先分量,通过 |
把 Acquire 换成 Relaxed,主线程读完是 [2, 0],with 时是 [3, 0],write_access 的第 1 个分量 2 领先,ahead 返回 1,报「Causality violation: Concurrent read and write accesses.」。E1 实测:Release / Acquire 6 个交错通过;Relaxed / Relaxed 和 Release / Relaxed 都在第 3 个交错报这句话;数据也换成原子、flag 用 Relaxed 时,第 3 个交错读到旧值 0(断言失败);SeqCst / SeqCst 6 个交错通过。
线程之间的另外两条边
spawn。 execution.rs:77-92:
pub(crate) fn new_thread(&mut self) -> thread::Id {
let thread_id = self.threads.new_thread();
let active_id = self.threads.active_id();
let (active, new) = self.threads.active2_mut(thread_id);
new.causality.join(&active.causality);
new.dpor_vv.join(&active.dpor_vv);
// Bump causality in order to ensure CausalCell accurately detects
// incorrect access when first action.
new.causality[thread_id] += 1;
active.causality[active_id] += 1;
thread_id
}
子线程知道父线程在 spawn 之前做过的一切;父线程 spawn 之后做的事,子线程不知道。实测(exp-loom/checkers/tests/cell.rs):父线程在 spawn 之前写单元格、子线程读,1 个交错通过;把父线程的写挪到 spawn 之后,第 1 个交错报「Causality violation: Concurrent read and write accesses.」,开 LOOM_LOCATION 时打印出读在 1 号线程、写在 0 号线程。注释里的 CausalCell 是 UnsafeCell 在 0.3.0 之前的名字。
反方向的边由 join 提供:JoinHandle::join 等的是一个 Notify,子线程结束时 notify 做 release、join 返回前做 acquire(05 线程、线程局部与 lazy_static)。
unpark。 thread.rs:153-156:被唤醒的线程直接并入唤醒者的整个 causality。线程唤醒自己时跳过 join(thread.rs:305-317)。Condvar、Notify 都建立在 park / unpark 之上,详见 05 Condvar、Notify 与 park。
小结
VersionVec是 5 个u16;join传递因果,ahead找出「没看到的那一步」,partial_cmp给出偏序。- 每线程三套时钟:
causality管内存模型与竞争检测,released存最近一次 release fence 的快照,dpor_vv只给 DPOR 用。 synchronize()只在原子 load / store / RMW、fence、单元格访问前自增本线程分量;fence 与单元格访问不是调度点。Synchronize用两句join实现 Release / Acquire;写总会带上released,所以 release fence 后的Relaxed写也能发布;不合法的序不报错。- 原子写的
happens_before只用于 coherence,sync才传给读者。 - spawn 时子线程继承父线程时钟、双方各加一;unpark 时被唤醒者并入唤醒者的时钟。