原始互斥
主线位置:跨任务主线的最底层。
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
RawMutexis 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 | 什么时候用 |
|---|---|---|---|
CriticalSectionRawMutex | critical_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。
问: 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-m0.7.10 的 featurecritical-section-single-core。它的src/critical_section.rs在acquire里先读整个PRIMASK存起来,再cpsid i;release把存下的值写回PRIMASK。写回而不是cpsie i,所以嵌套调用时内层退出不会提前打开中断。 - std:
critical-section1.2.0 自己的stdfeature(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。
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 个;NoopRawMutexpanic 在try_borrow_mut;ThreadModeRawMutexpanic 在断言。