Rust in Everything
基于 loom 0.7.2

UnsafeCell 与原子的非同步访问

主线位置:主线 3 全部。UnsafeCell::with_mut → rt::Cell::start_write:synchronize 自增时钟 → track_write:上一次写 / 读的时钟有某个分量领先于当前线程,就是并发访问 → location::panic(...).fire()。守卫 drop 时再查一次。

loom 的 UnsafeCell 长什么样

cell/unsafe_cell.rs:9-13:

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) 切换层。

读写两个时钟

rt/cell.rs:10-32:

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原子 storeunsync_loadwith_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(读写两个时钟)+ std UnsafeCell;四个访问方法,访问区间是闭包或守卫的生命周期。
  • 读要求之前的写都发生在它之前,写要求之前的读写都发生在它之前;访问开始和守卫 drop 时各查一次,drop 时用的是结束时的时钟。
  • 单元格访问不是调度点,竞争由时钟判定:E7 两个线程直接 with_mut,第 1 个交错就报错;加锁后 4 个交错通过。
  • 另一个线程还在闭包里时访问,撞上「currently writing to cell」一类断言。
  • 从 ConstPtr / MutPtr 拿出来、活得比守卫久的裸指针,loom 看不见(实验:6 个交错通过)。
  • 原子的 with_mut / unsync_load / into_inner 与普通原子操作之间也要有 happens-before,四个时钟、九种文案。

💬 关联讨论

发起讨论
加载中...