Rust in Everything
基于 embassy-sync 0.8.0

原始互斥

主线位置:跨任务主线的最底层。Channel::send 每次 poll 都要先进 Channel::lock,它调 blocking_mutex::Mutex::lock,再调 M::lock。M 是什么,决定了这一步是关中断、什么也不做,还是检查当前是不是线程模式。

flowchart TB
  ch["Channel::lock"] --> bm["Mutex::lock"]
  bm --> m{"M 是哪种"}
  m --> cs["CriticalSection"]
  m --> noop["Noop"]
  m --> tm["ThreadMode"]
  cs --> with["critical_section::with"]
  with --> impl1["cortex-m 关中断"]
  with --> impl2["std 全局 Mutex"]
  noop --> f["直接调闭包"]
  tm --> chk["检查 VECTACTIVE"]
  chk --> f

代码在 embassy-sync/src/blocking_mutex/:raw.rs(151 行)定义 trait 和三种实现,mod.rs(208 行)是带数据的 Mutex<R, T>。本页的实验:std 部分在 exp-embassy/sync-std,QEMU 部分在 exp-embassy/sync-qemu(全部 release;QEMU 11.1.2 b-l475e-iot01a,embassy-stm32 用的是注释掉 rcc/bd.rs 里 while !cr.read().dbp() {} 那一行的补丁版本)。

RawMutex:只有一个闭包

RawMutex 只有两个成员:

pub unsafe trait RawMutex {
    /// Create a new `RawMutex` instance.
    ///
    /// This is a const instead of a method to allow creating instances in const context.
    const INIT: Self;

    /// Lock this `RawMutex`.
    fn lock<R>(&self, f: impl FnOnce() -> R) -> R;
}

没有 lock() / unlock() 一对方法,也没有 guard。锁只在闭包执行期间有效,闭包返回就解锁。这是有意的:关中断这种「锁」不能跨 .await 持有,也不能交给别的函数在别处释放,闭包形式让编译器保证它一定成对。INIT 是关联常量而不是 new(),因为 trait 里的方法在 stable 上不能是 const fn,而 static CH: Channel<..> = Channel::new() 需要在常量上下文里造出一个 M。

trait 是 unsafe 的,文档写明了实现者要保证什么,以及不保证什么:

Note that, unlike other mutexes, implementations only guarantee no concurrent access from other threads: concurrent access from the current thread is allowed. For example, it's possible to lock the same mutex multiple times reentrantly.

Therefore, locking a RawMutex is only enough to guarantee safe shared (&) access to the data, it is not enough to guarantee exclusive (&mut) access.

关中断天然可重入(critical_section::with 嵌套调用是合法的),所以 RawMutex 只能给出 &T。要改数据,得在里面再放一个 Cell 或 RefCell。这就是 Channel 的字段是 Mutex<M, RefCell<ChannelState<T, N>>> 的原因。

三种实现

类型lock 做什么Send / Sync什么时候用
CriticalSectionRawMutexcritical_section::with(|_| f())都是跨执行器、跨中断;多核要看 critical-section 的实现
NoopRawMutex直接 f()只有 Send同一个执行器里的任务之间
ThreadModeRawMutex断言在线程模式,然后 f()都是单核、只在线程模式访问,但要放进 static

NoopRawMutex 用一个 PhantomData<*mut ()> 把自己变成 !Sync,然后只补了 Send:

pub struct NoopRawMutex {
    _phantom: PhantomData<*mut ()>,
}

unsafe impl Send for NoopRawMutex {}

ThreadModeRawMutex 的 lock 是一个运行期断言:

    unsafe impl RawMutex for ThreadModeRawMutex {
        const INIT: Self = Self::new();
        fn lock<R>(&self, f: impl FnOnce() -> R) -> R {
            assert!(in_thread_mode(), "ThreadModeMutex can only be locked from thread mode.");

            f()
        }
    }

in_thread_mode 在 Cortex-M 上读 ICSR(地址 0xE000ED04)的低 9 位 VECTACTIVE,为 0 表示当前不在任何异常处理函数里;开了 std feature 时改成判断当前线程名是不是 "main"。整个模块套在 #[cfg(any(cortex_m, doc, feature = "std"))] 里,cortex_m 这个 cfg 由 build.rs 按目标三元组设置(thumbv6m-、thumbv7m-、thumbv7em-、thumbv8m.*)。在 riscv32imac-unknown-none-elf 上没有这个类型。它还实现了 Drop,同样断言在线程模式:注释说 ThreadModeMutex<T> 即使 T: !Send 也是 Send,不检查的话可以把它送进中断里 drop 掉,等于偷偷「发送」了一个 T。

📝NOTE
没有蠢问题

问: NoopRawMutex 的 lock 什么也不做,凭什么说它在同一个执行器里是安全的?

答: 因为闭包里没有 .await。同一个执行器上的任务是一个接一个 poll 的,一个任务进了 lock 的闭包,在闭包返回之前,执行器不可能去 poll 别的任务,所以不会有第二个任务同时进来。能在闭包中途插进来的只有中断处理函数(以及另一个核、另一个执行器的线程),NoopRawMutex 不防这些。!Sync 就是在类型上把「别的执行上下文」挡在外面:拿不到 &'static,中断处理函数就碰不到它。

Mutex:Send 和 Sync 跟着 R 走

带数据的 Mutex<R, T> 是 raw: R 加 data: UnsafeCell<T>。它自己的 Sync 完全由 R 决定:

unsafe impl<R: RawMutex + Send, T: ?Sized + Send> Send for Mutex<R, T> {}
unsafe impl<R: RawMutex + Sync, T: ?Sized + Send> Sync for Mutex<R, T> {}

lock 把 &T 交给闭包;0.7.0 加的 lock_mut 给 &mut T,但它是 unsafe fn,原因正是上一节那段文档:同一个上下文里重入 lock_mut 会造出两个 &mut。

Channel、Signal 等类型都是在这个 Mutex 外面再包一层,所以 Channel<NoopRawMutex, ..> 也不是 Sync。放进 static 时编译器直接拒绝(sync-qemu/src/bin/noop_static.rs,thumbv7em-none-eabihf):

error[E0277]: `*mut ()` cannot be shared between threads safely
   --> src/bin/noop_static.rs:10:12
    |
 10 | static CH: Channel<NoopRawMutex, u32, 4> = Channel::new();
    |            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `*mut ()` cannot be shared between threads safely
    |
    = help: within `NoopRawMutex`, the trait `Sync` is not implemented for `*mut ()`
...
    = note: required for `embassy_sync::blocking_mutex::Mutex<NoopRawMutex, RefCell<channel::ChannelState<u32, 4>>>` to implement `Sync`
note: required because it appears within the type `embassy_sync::channel::Channel<NoopRawMutex, u32, 4>`
...
    = note: shared static variables must have a type that implements `Sync`

NoopRawMutex 的 Channel 通常放在 main 里用 StaticCell 造出来,再把 &'static 传给同一个 Spawner 生出的任务。中断处理函数没有参数,只能读 static,于是自然够不着它。

ThreadModeMutex<T> 是另一个类型,不走 Mutex<R, T>:它对任何 T 都是 Send + Sync,因为它只允许线程模式这一个执行上下文碰数据,T 根本不会被「送」到别处。mod.rs 里的注释说这需要特化才能用泛型 Mutex 表达,所以单独写了一份;而 Channel<ThreadModeRawMutex, T, N> 走泛型 Mutex,仍然要求 T: Send。

critical-section:锁从哪来

CriticalSectionRawMutex 自己不知道怎么关中断,它调 critical-section 这个 crate。那个 crate 只定义接口,实现由最终的二进制挑一个,通过链接期符号接上(和 时间驱动 是同一种做法)。本卷的两个实验环境各用一种:

  • Cortex-M 单核:cortex-m 0.7.10 的 feature critical-section-single-core。它的 src/critical_section.rs 在 acquire 里先读整个 PRIMASK 存起来,再 cpsid i;release 把存下的值写回 PRIMASK。写回而不是 cpsie i,所以嵌套调用时内层退出不会提前打开中断。
  • std:critical-section 1.2.0 自己的 std feature(src/std.rs)。一个进程级的 static GLOBAL_MUTEX: Mutex<()>,加一个线程局部的 IS_LOCKED 标志处理重入。也就是说,在 std 上所有 CriticalSectionRawMutex 共用同一把锁,两个不相关的 Channel 也会互相阻塞(持锁时间都很短,实验里感觉不到)。

多核芯片由 HAL 提供跨核的实现,例如 embassy-rp 的 feature critical-section-impl 在 src/critical_section_impl.rs 里注册了基于硬件自旋锁的 RpSpinlockCs,embassy-sync 不用改。这就是把「锁」做成可替换实现的好处:同一份 Channel 代码,在单核上是读写 PRIMASK 的几条指令,在 std 上是一把 std::sync::Mutex,在同一个执行器里什么也不做。

参数还是编译期的:lock 是泛型方法,NoopRawMutex::lock 内联后没有任何指令。代价是每换一种 M 就单态化一份代码。sync-qemu 里同一个「生产者任务送 10 个 u32 给消费者任务」的程序,Channel<_, u32, 4> 用 CriticalSectionRawMutex 时 .text 13160 字节,换成 ThreadModeRawMutex 是 13392 字节,多出的 232 字节是断言和 panic 信息(完整的体积对比在 Channel 一页)。

实验:中断和线程抢同一个 Channel

sync-qemu/src/race.rs:线程模式里一个死循环不停地 try_receive,SysTick 每约 1000 个时钟周期进一次中断,在中断里 try_send 一个递增的序号。线程这边检查收到的序号是否连续,收满 20000 个就退出。为了让 NoopRawMutex 版本也能编译,Channel 包在一个 unsafe impl Sync 的结构体里:

struct Shared(Channel<$m, u32, 4>);
// Needed to put a NoopRawMutex channel in a static at all (see noop_static.rs).
unsafe impl Sync for Shared {}

#[exception]
fn SysTick() {
  let n = SENT.load(Ordering::Relaxed) + 1;
  match CH.0.try_send(n) {
    Ok(()) => SENT.store(n, Ordering::Relaxed),
    Err(_) => {
      FULL.fetch_add(1, Ordering::Relaxed);
    }
  }
}

$m 换成三种 RawMutex,各跑一次(QEMU 11.1.2,release):

mutex: embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex
received 20000 in order; try_receive calls 176562391, full 0

mutex: embassy_sync::blocking_mutex::raw::NoopRawMutex
panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/embassy-sync-0.8.0/src/channel.rs:845:38:
unwrap of `rc.try_borrow_mut()` failed: BorrowMutError

CriticalSectionRawMutex:线程在 try_receive 的闭包里时中断被挡住,等闭包返回才进 SysTick,两边轮流拿到 ChannelState,20000 个序号一个不少、顺序正确。

NoopRawMutex:线程正在 try_receive 的闭包里,RefCell 已经被 try_borrow_mut 借出;SysTick 打断它,try_send 再次进 Channel::lock,第二次 try_borrow_mut 失败,unwrap! 宏 panic。加 -icount shift=0 再跑两次,都是同一句 panic。这是 RefCell 那一层的价值:没有它,两个上下文会同时改 Deque 的下标,结果是丢消息或读到半写的数据,而且不一定当场暴露。有了它,错误用法在第一次撞上时就变成一个指向 channel.rs:845 的 panic。

❗IMPORTANT
动脑筋:换成 ThreadModeRawMutex 会怎样?

同一个 race.rs,$m 换成 ThreadModeRawMutex。它是 Sync 的,不需要 unsafe impl Sync 也能放进 static。程序会正常收完 20000 个、panic 在 channel.rs:845,还是别的地方?答案在本页末尾。

动脑筋答案

sync-qemu/src/bin/race_thread.rs,QEMU 11.1.2,release:

mutex: embassy_sync::blocking_mutex::raw::thread_mode::ThreadModeRawMutex
panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/embassy-sync-0.8.0/src/blocking_mutex/raw.rs:120:13:
ThreadModeMutex can only be locked from thread mode.

panic 在 raw.rs:120,也就是 ThreadModeRawMutex::lock 的断言。第一次 SysTick 进来调 try_send,ICSR.VECTACTIVE 是 SysTick 的异常号 15,不是 0,断言当场失败,根本走不到 RefCell。和 NoopRawMutex 不同,它不依赖「中断恰好打断了临界区」这个时机:只要在中断里碰一下就 panic。所以 ThreadModeRawMutex 适合「static、只在线程模式用」的场合,中断里要用的东西必须是 CriticalSectionRawMutex。

小结

  • RawMutex 只有 INIT 和闭包式的 lock,允许重入,只保证 & 访问;可变状态都放在 RefCell 或 Cell 里。
  • CriticalSectionRawMutex 调 critical_section::with:cortex-m 单核实现是存 PRIMASK 再 cpsid i,std 实现是一把进程级 Mutex。
  • NoopRawMutex 什么也不做,靠 !Sync 把自己限制在一个执行上下文里;static 里放不进去。
  • ThreadModeRawMutex 在 lock 和 drop 时断言 VECTACTIVE == 0(std 上是线程名为 main),只在 Cortex-M 和 std 上存在。
  • QEMU 实测:中断里 try_send、线程里 try_receive,CriticalSectionRawMutex 收齐 20000 个;NoopRawMutex panic 在 try_borrow_mut;ThreadModeRawMutex panic 在断言。

💬 关联讨论

发起讨论
加载中...