UnsafeCell 与原子的非同步访问
主线位置:主线 3 全部。
UnsafeCell::with_mut→rt::Cell::start_write:synchronize自增时钟 →track_write:上一次写 / 读的时钟有某个分量领先于当前线程,就是并发访问 →location::panic(...).fire()。守卫 drop 时再查一次。
loom 的 UnsafeCell 长什么样
pub struct UnsafeCell<T: ?Sized> {
/// Causality associated with the cell
state: rt::Cell,
data: std::cell::UnsafeCell<T>,
}
数据就放在 std 的 UnsafeCell 里,rt::Cell 只是对象仓库里的一个下标,指向访问记录。和 std 的差别在 API:没有 get() -> *mut T,取而代之的是四个方法(unsafe_cell.rs:122-203):
| 方法 | 访问区间 |
|---|---|
| `with( | *const T |
| `with_mut( | *mut T |
get() -> ConstPtr<T> | 守卫活着的期间算一次读 |
get_mut() -> MutPtr<T> | 守卫活着的期间算一次写 |
new 要调 rt::Cell::new(location!()),两者都要进执行状态,所以 loom 的 UnsafeCell 只能在模型里构造,不能 const(06 cfg(loom) 切换层)。into_inner 不做检查,直接拆出数据。get / get_mut 返回守卫是 0.5.4(#219)加的。tokio 的切换层给 std 版的 UnsafeCell 补了同名的 with / with_mut(src/loom/std/unsafe_cell.rs),好让同一份代码两边都能编译,见 06 cfg(loom) 切换层。
读写两个时钟
pub(super) struct State {
/// Where the cell was created
created_location: Location,
/// Number of threads currently reading the cell
is_reading: usize,
/// `true` if in a `with_mut` closure.
is_writing: bool,
/// The transitive closure of all immutable accesses of `data`.
read_access: VersionVec,
/// Location for the *last* time a thread read from the cell.
read_locations: LocationSet,
/// The last mutable access of `data`.
write_access: VersionVec,
/// Location for the *last* time a thread wrote to the cell
write_locations: LocationSet,
}
read_access 是所有读的时钟之并,write_access 是所有写的时钟之并;创建时两者都初始化为创建者当时的 causality。检查规则(rt/cell.rs:102-149):
fn track_read(&mut self, threads: &thread::Set) {
let current = &threads.active().causality;
// Check that there is no concurrent mutable access, i.e., the last
// mutable access must happen-before this immutable access.
if let Some(writer) = current.ahead(&self.write_access) {
location::panic("Causality violation: Concurrent read and write accesses.")
// ...
.fire();
}
self.read_access.join(current);
}
fn track_write(&mut self, threads: &thread::Set) {
let current = &threads.active().causality;
if let Some(other) = current.ahead(&self.write_access) {
location::panic("Causality violation: Concurrent write accesses to `UnsafeCell`.")
// ...
.fire();
}
if let Some(reader) = current.ahead(&self.read_access) {
location::panic(
"Causality violation: Concurrent read and write accesses to `UnsafeCell`.",
)
// ...
.fire();
}
self.write_access.join(current);
}
- 读要求之前所有的写都发生在它之前;读和读之间不检查。
- 写要求之前所有的写和读都发生在它之前。
- 「发生在之前」就是
current.ahead(记录)返回None:记录里每个线程的每一步,当前线程都已经知道。ahead返回的线程编号就是报错里的「另一个线程」。 - 读写冲突有两种文案:读的一方发现时是「…read and write accesses.」,写的一方发现时多一个「to `UnsafeCell`」。
开始时查一次,结束时再查一次
start_read / start_write(rt/cell.rs:55-84)先 synchronize(本线程时钟加一),再断言没有别的访问正在进行,再 track_*。返回的守卫 Reading / Writing 在 drop 时(rt/cell.rs:152-188):
impl Drop for Writing {
fn drop(&mut self) {
rt::execution(|execution| {
let state = self.state.get_mut(&mut execution.objects);
assert!(state.is_writing);
assert!(state.is_reading == 0);
state.is_writing = false;
if !std::thread::panicking() {
state.track_write(&execution.threads);
}
})
}
}
sequenceDiagram participant U as 用户代码 participant C as rt::Cell U->>C: with_mut 开始:start_write C->>C: synchronize:本线程分量加一 C->>C: 断言没有别的读写正在进行 C->>C: track_write:write_access / read_access 有领先分量就报错,再并入当前时钟 U->>U: 闭包执行(期间可能有原子操作,时钟继续前进) U->>C: 守卫 drop C->>C: 不在 panic 中就再 track_write 一次
结束时再跑一次 track_*,用的是此刻的时钟:如果闭包里做过原子操作,本线程时钟已经前进,记录下来的就是「访问结束时」的时钟。之后的访问要和访问的结束有先后,而不只是和开始有先后。
另一个线程在闭包还没结束时访问同一个单元格,撞上的是开始处的普通断言:start_read 的「currently writing to cell」、start_write 的「currently reading from cell」。实验(exp-loom/checkers/tests/cell.rs 的 read_while_writer_inside_closure):子线程在 with_mut 闭包里发布一个 flag,然后自旋等主线程的回应;主线程等到 flag 就 with 读。第 1 个交错报「currently writing to cell」。
再看结束时那次检查。实验(同一文件的 write_after_flag_inside_read_extra_op):子线程在 with 闭包里先读一次,flag.store(true, Release),再做一次原子操作,再读一次;主线程 flag.load(Acquire) 读到 true 就 with_mut 写。主线程和「发布 flag 之前」的读有先后,和闭包里之后的那次读没有。第 3 个交错报「Causality violation: Concurrent read and write accesses to `UnsafeCell`.」:子线程闭包结束时的那次 track_read 把更晚的时钟并进了 read_access,主线程的写看到这个分量领先。同一形状反过来(写方在 with_mut 里发布 flag 之后还有一次原子操作和一次写、读方 acquire 后读,read_during_write_closure_extra_op)也在第 3 个交错报错。
不是调度点
start_read / start_write 只调 synchronize,不调 branch,单元格访问不是调度点。loom 不需要把两个线程的访问「恰好交错在一起」才能发现竞争:只要两次访问之间没有 happens-before,不管它们在 loom 的执行顺序里隔了多远,后一次访问都会发现前一次的时钟领先。
实验 E7(exp-loom/checkers/tests/cell.rs):
| 场景 | 结果 |
|---|---|
主线程 spawn 子线程后,两个线程各对同一个单元格 with_mut 加一 | 第 1 个交错「Causality violation: Concurrent write accesses to `UnsafeCell`.」 |
同样的代码,两次 with_mut 都在同一把 loom::sync::Mutex 里 | 4 个交错通过 |
| 父线程在 spawn 之前写,子线程读 | 1 个交错通过 |
| 父线程在 spawn 之后写,子线程读 | 第 1 个交错「Causality violation: Concurrent read and write accesses.」 |
第一行的第 1 个交错里,主线程的 with_mut 先完整执行完,然后主线程 join 阻塞、子线程才开始跑。两次写在执行顺序上根本没有重叠,但子线程的时钟里没有主线程 spawn 之后那一步,ahead 返回 0,报错。开 LOOM_LOCATION 时的完整输出:
Causality violation: Concurrent write accesses to `UnsafeCell`.
created: tests/cell.rs:15:24
write one: thread #0 @ tests/cell.rs:18:9
write two: thread #1 @ tests/cell.rs:17:41
(「write one」是记录里的那次,「write two」是当前这次;格式见 死锁、分支上限与位置信息。)
ConstPtr、MutPtr 与指针逃逸
get() / get_mut() 返回的 ConstPtr / MutPtr 持有 Reading / Writing 守卫,访问区间就是守卫的生命周期。它们本质还是裸指针:deref 是 unsafe fn,loom 不检查悬垂(文档原话「Loom does not currently check for dangling pointers」);with(|ptr| ...) 把真正的裸指针交给闭包。
文档用了很长的篇幅写「Correct Usage」(unsafe_cell.rs:247-376、unsafe_cell.rs:418-531):裸指针移出闭包、从它派生出字段指针或偏移指针、把它存进别的结构,loom 都看不见,因为 loom 只知道守卫什么时候 drop。
实验(exp-loom/checkers/tests/cell.rs 的 escaped_pointer*):子线程 let guard = cell.get(); let raw = guard.with(|p| p);,主线程等子线程的 Release flag,再 with_mut 写。
| 子线程的写法 | 结果 |
|---|---|
drop 守卫 → 发布 flag → 再做一次原子操作 → 通过 raw 读 | 6 个交错通过 |
发布 flag → 再做一次原子操作 → 通过 raw 读 → drop 守卫 | 第 3 个交错「Causality violation: Concurrent read and write accesses to `UnsafeCell`.」 |
两种写法的真实行为一样:子线程的读和主线程的写之间没有 happens-before。第一种写法里,守卫在 flag 之前就 drop 了,loom 记下的读区间结束得太早,之后通过 raw 的读对它不存在。
loom::cell::Cell
cell/cell.rs(117 行,0.5.2 的 #196)是 UnsafeCell 上的一层:set / replace / take 是一次 with_mut,get 是一次 with,swap 是两个单元格的嵌套 with_mut,into_inner 直接拆。它和 std 的 Cell 一样不是 Sync,单线程里用它主要是为了让「std 版用 Cell、loom 版也要检查」的代码能直接替换。
原子的非同步访问
std 的原子有 get_mut(&mut self) -> &mut T 和 into_inner(self),这是普通内存访问,不是原子操作。loom 的原子把值存在历史里,交不出 &mut T,于是换成:
with_mut(&mut self, |&mut T| ...):闭包里改的是「最后一次写」的值,闭包结束时写回去(rt/atomic.rs:338-385)。不是调度点,不产生新的写历史。bytes 的切换层给 std 的AtomicPtr补了一个with_mut,实现就是get_mut,见 bytes 卷的 vtable 一章。unsafe fn unsync_load(&self):直接读最后一次写的值(load 的候选)。into_inner就是unsync_load(sync/atomic/int.rs:43-49)。
这两种访问也要检查和普通原子操作有没有并发。State 为此记了四个时钟:loaded_at(所有原子 load)、stored_at(所有原子 store)、unsync_loaded_at、unsync_mut_at(构造本身也算一次,atomic.rs:420-421),由四个 track_* 函数检查(atomic.rs:568-712)。RMW 先 track_load 再(成功时)track_store。规则是:只要有一方是非同步访问,两者之间就必须有 happens-before;两次普通原子操作之间不检查。
| 当前操作 \ 之前的操作 | 原子 load | 原子 store | unsync_load | with_mut |
|---|---|---|---|---|
原子 load(track_load) | — | — | — | Concurrent load and mut accesses. |
原子 store(track_store) | — | — | Concurrent atomic store and `unsync_load` accesses. | Concurrent atomic store and mut accesses. |
unsync_load | — | Concurrent `unsync_load` and atomic store. | — | Concurrent `unsync_load` and mut accesses. |
with_mut(track_unsync_mut) | Concurrent atomic load and unsync mut accesses. | Concurrent atomic store and unsync mut accesses. | Concurrent `unsync_load` and unsync mut accesses. | Concurrent unsync mut accesses. |
九种文案,前面都有「Causality violation: 」。另有一个普通断言:with_mut 闭包执行期间 is_mutating 为真,任何对同一原子的访问都会撞上「atomic cell is in `with_mut` call」。
实验(exp-loom/checkers/tests/cell.rs):
| 场景 | 结果 |
|---|---|
主线程 unsync_load,子线程并发 store(1, Relaxed) | 第 1 个交错「Causality violation: Concurrent atomic store and `unsync_load` accesses.」 |
原子放在 Box::into_raw 得到的裸指针后面,子线程 load,主线程同时 with_mut | 第 1 个交错「Causality violation: Concurrent load and mut accesses.」 |
第一行的报错文案说明发现它的是子线程的 store:第 1 个交错里主线程的 unsync_load 先执行(不是调度点),子线程后写,写的时候发现 unsync_loaded_at 领先。with_mut 要 &mut self,安全代码里拿不到「别的线程还在用」的 &mut,所以第二行要借助裸指针才能构造出来;实际代码里这类错误多出在 Drop 或 unsafe 的独占假设上。
小结
- loom 的
UnsafeCell=rt::Cell(读写两个时钟)+ stdUnsafeCell;四个访问方法,访问区间是闭包或守卫的生命周期。 - 读要求之前的写都发生在它之前,写要求之前的读写都发生在它之前;访问开始和守卫 drop 时各查一次,drop 时用的是结束时的时钟。
- 单元格访问不是调度点,竞争由时钟判定:E7 两个线程直接
with_mut,第 1 个交错就报错;加锁后 4 个交错通过。 - 另一个线程还在闭包里时访问,撞上「currently writing to cell」一类断言。
- 从
ConstPtr/MutPtr拿出来、活得比守卫久的裸指针,loom 看不见(实验:6 个交错通过)。 - 原子的
with_mut/unsync_load/into_inner与普通原子操作之间也要有 happens-before,四个时钟、九种文案。