几个任务等同一个 Channel / Signal 时 CPU 不睡
主线位置:跨任务主线的「满 / 空时登记 waker」。
WakerRegistration只记一个 waker、换人时叫醒旧的,见等待队列;Channel每侧一个槽、多个发送方的实测见 Channel 的「满了以后:多个发送方」。本页讲最常见的形状:几个 worker 一起等一个空的队列。
sequenceDiagram participant A as worker 0 participant Q as 唯一的槽 participant B as worker 1 A->>Q: register waker A B->>Q: register waker B Q->>A: wake A A->>Q: register waker A Q->>B: wake B B->>Q: register waker B Q->>A: wake A
实验 crate exp-embassy/pita-qemu,bin busy_waiters 和 busy_fixed(thumbv7em-none-eabihf,release);QEMU 11.1.2 b-l475e-iot01a,-icount shift=0,时间是 SysTick 驱动数出来的 1 ms tick(模板的 SysTick 驱动,复制在 src/systick.rs);embassy-stm32 用 exp-embassy/qemu-patched(和 0.6.0 只差注释掉 rcc/bd.rs 里的 while !cr.read().dbp() {})。
现象
从 tokio 或线程池带来的写法:一个任务队列,几个 worker 从里面取活。
static JOBS: Channel<CriticalSectionRawMutex, u32, 4> = Channel::new();
static READY: Signal<CriticalSectionRawMutex, u32> = Signal::new();
#[embassy_executor::task(pool_size = 2)]
async fn worker(slot: usize) {
let job = Counted { inner: JOBS.receive(), slot }.await;
hprintln!("worker {} got job {}", slot, job);
}
#[embassy_executor::task(pool_size = 2)]
async fn waiter(slot: usize) {
let v = Counted { inner: READY.wait(), slot }.await;
hprintln!("waiter {} got {}", slot, v);
}
Counted 是一个透明的包装,每次 poll 给对应的计数加一。主任务先只 spawn 一个 worker 等 100 ms,再 spawn 第二个等 100 ms,然后发两个活;最后 spawn 两个等同一个 Signal 的任务,100 ms 里不发信号:
1 worker idle 100 ms: polls [1, 0, 0, 0]
2 workers idle 100 ms: polls [301842, 301842, 0, 0]
worker 0 got job 7
worker 1 got job 8
2 signal waiters 100 ms: polls [301843, 301843, 336932, 336933]
- 一个 worker 等的时候,100 ms 里它只被 poll 了 1 次:执行器在
WFE里睡着,只被 SysTick 叫醒(一次 Timer::after)。 - 两个 worker 一起等,什么事都没发生,100 ms 里各被 poll 30 万次,执行器一刻也没睡。
- 活来了以后两个 worker 都拿到了,结果是对的,问题只在空闲的那段时间:CPU 在白转,电池在掉。
- 两个任务等一个
Signal也一样,各 33 万次。
出处
Channel 的状态里每侧只有一个槽(channel.rs 的 receiver_waker: WakerRegistration)。WakerRegistration::register 换人时叫醒旧的那个,注释自己写了后果:
// If two tasks are waiting on the same thing concurrently, this will cause them
// to wake each other in a loop fighting over this WakerRegistration. This wastes
// CPU but things will still work.
//
// If the user wants to have two tasks waiting on the same thing they should use
// a more appropriate primitive that can store multiple wakers.
old_waker.wake()
Signal 没用 WakerRegistration,但 poll_wait 写的是同一个逻辑:状态是 Waiting(w) 而来的不是同一个任务时,换上新的 waker、w.wake()。被叫醒的任务再 poll 一次,条件还不满足,再登记,把对方换出来……运行队列永远不空,执行器的 poll 循环就永远走不到 WFE。
在 embassy-sync 0.8.0 里只有一个槽的:Channel(两侧)、PriorityChannel、zerocopy_channel、Pipe、Signal、Mutex、RwLock、两种 Semaphore。用 MultiWakerRegistration<N> 的只有 Watch 和 PubSubChannel(grep 整个 src/ 得到的结果)。
修法
每个 worker 一个 Channel,由生产者分派。 busy_fixed 里 JOBS 变成两个 channel,worker slot 只收自己那个,主任务轮流往里发:
static JOBS: [Channel<CriticalSectionRawMutex, u32, 4>; 2] = [Channel::new(), Channel::new()];
// worker: Counted { inner: JOBS[slot].receive(), slot }.await
// main:
for (n, job) in [7, 8].into_iter().enumerate() {
JOBS[n % 2].send(job).await; // round-robin dispatch
}
几个任务都要看到同一个值,用 Watch。 每个任务一个 Receiver,changed() 等版本号变化:
static READY: Watch<CriticalSectionRawMutex, u32, 2> = Watch::new();
// waiter:
let Some(mut rx) = READY.receiver() else { /* no receiver slot left */ return; };
let v = Counted { inner: rx.changed(), slot }.await;
同样的时间线:
1 worker idle 100 ms: polls [1, 0, 0, 0]
2 workers idle 100 ms: polls [1, 1, 0, 0]
worker 1 got job 8
worker 0 got job 7
2 watch receivers 100 ms: polls [2, 2, 1, 1]
waiter 2 got 5
waiter 3 got 5
两个空闲 worker 各 poll 1 次;每个 worker 被叫醒后又 poll 1 次拿到活(于是 2)。两个 Watch 接收者各 1 次,send(5) 后两个都拿到了值。Signal 换成 Watch 还改了语义:Signal 的值只给一个人,两个等待者时另一个可能永远等不到,见两个任务等一个 Signal,一个永远拿不到。
其他做法:一个分派任务独占 JOBS.receive(),再按 worker 空闲情况转发;多个发送方等一个满的 channel 时,先过一个 FairSemaphore(async Mutex 与 Semaphore)。
怎么确认没中招
- 对每个
Channel、Signal、Mutex、Semaphore,数一数同时可能有几个任务在.await它的同一侧(receive一侧、send一侧、lock、wait)。超过一个就是这个坑。 - 在芯片上量空闲:用
trace钩子数_embassy_trace_poll_start(开了 trace),什么都不该发生的一秒里它应当只有个位数到每个 tick 一次,而不是几十万。 - 功耗测量时电流降不下来,或调试器停下来总停在 poll 循环里,也是同一个信号。
小结
Channel、Signal、Mutex等只有一个 waker 槽,第二个等待者会把第一个叫醒;两个以上任务等同一侧时互相叫醒,执行器不进WFE。- QEMU 上两个空闲 worker 100 ms 里各被 poll 30 万次;结果仍然正确,所以功能测试发现不了。
- 每个 worker 一个
Channel;多个接收者看同一个值用Watch(MultiWakerRegistration),空闲时各只 poll 1 次。