Rust in Everything
基于 embassy-sync 0.8.0

几个任务等同一个 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 次。

💬 关联讨论

发起讨论
加载中...