两个任务等一个 Signal,一个永远拿不到
主线位置:跨任务主线的「只留最新值」变体。
Signal的状态和三个分支见 Signal、Watch 与 PubSub;这一页只讲把它交给两个消费者的后果。
flowchart TB
s["signal(v)"] --> st["State::Signaled(v)"]
st --> q{"谁先被 poll"}
q -->|任务 A| a["A 拿走 v"]
q -->|任务 B| b["B 拿走 v"]
a --> rest["另一个看到 None 睡下"]
b --> rest
fix["Watch 加两个 Receiver"] --> both["A 和 B 都看到 v"]
实验 crate exp-embassy/pitb-std,bin p15_signal(platform-std 执行器,release);M2 Max、macOS 27、rustc 1.99.0。
现象
两个任务都在等「网络起来了」这个信号,各等一次:
static START: Signal<CriticalSectionRawMutex, ()> = Signal::new();
#[embassy_executor::task(pool_size = 2)]
async fn worker(name: &'static str) {
START.wait().await; // "wait until the network is up", once
println!(" {name} started at {} ms", Instant::now().as_millis());
}
// main:spawn worker("mqtt") 和 worker("ntp"),10 ms 后 START.signal(()),再等 1 s
三次运行(p15_signal once):
mqtt started at 12 ms
after 1 s: START.signaled() = false
mqtt started at 12 ms
after 1 s: START.signaled() = false
ntp started at 11 ms
after 1 s: START.signaled() = false
每次只有一个任务启动,另一个永远停在 wait() 上;哪一个启动不固定。signaled() 是 false:值已经被拿走了,没有任何东西留给第二个任务。
换成循环消费(两个任务都 loop { CONFIG.wait().await },主任务每 100 ms 发一个新配置,共 6 个),每个值仍然只到一个任务手里:
led got 1 at 12 ms
log got 2 at 117 ms
led got 3 at 222 ms
led got 4 at 327 ms
led got 5 at 432 ms
led got 6 at 536 ms
led got 5, log got 1 of 6
另外两次的分配是 4 / 2 和 3 / 3。两个任务各自以为自己拿到了全部配置,实际上各丢了一部分。
出处
Signal 的文档第一句就是「Single-slot signaling primitive for a single consumer」。它的状态是 None / Waiting(Waker) / Signaled(T) 三选一,只有一个 waker 槽。poll_wait 里第二个等待者到来时走这一支:
State::Waiting(w) => {
cell.set(State::Waiting(cx.waker().clone()));
w.wake();
Poll::Pending
}
State::Signaled(res) => Poll::Ready(res),
两个等待者轮流把对方换出来、叫醒对方(这段时间执行器一直不睡,见几个任务等同一个 Channel / Signal 时 CPU 不睡)。signal(v) 把状态换成 Signaled(v),下一个被 poll 的任务走 Signaled 分支,把 v 移出来,状态回到 None。另一个任务随后被 poll,看到 None,登记自己的 waker,返回 Pending。之后不再有人 signal,它就不会再被叫醒。值是被「移走」的,不是被「看到」的,所以每个值恰好一个任务拿到;谁拿到取决于运行队列里谁排在前面。
修法
多个任务都要看到最新值,用 Watch:它保存一个值和一个版本号,每个 Receiver 记着自己看过的版本,send 叫醒所有登记的接收者。N 是接收者上限,同时是 waker 槽数:
static CONFIG_W: Watch<CriticalSectionRawMutex, u32, 2> = Watch::new();
#[embassy_executor::task(pool_size = 2)]
async fn watcher(name: &'static str) {
let mut rx = CONFIG_W.receiver().unwrap();
loop {
let v = rx.changed().await;
println!(" {name} changed -> {v} at {} ms", Instant::now().as_millis());
}
}
// main:let tx = CONFIG_W.sender(); 每 100 ms tx.send(v),v = 1..=3
led changed -> 1 at 12 ms
log changed -> 1 at 12 ms
log changed -> 2 at 114 ms
led changed -> 2 at 114 ms
led changed -> 3 at 219 ms
log changed -> 3 at 219 ms
两个任务都收到了每一个值。注意两点:
Watch和Signal一样只保留最新值,接收者来不及看时中间值会被合并;每条都不能丢,用PubSubChannel。- 第三个
receiver()返回None(上限N= 2)。中断里只想读一眼,用不占名额的anon_receiver()。
「网络起来了」这种一次性事件,Watch 也适用:send 之后才建的接收者,第一次 changed() 仍然立刻返回(它的版本号从 0 开始,当前版本已经是 1),不会像 Signal 那样被先到的任务拿走。p15_signal late 先 send(7),10 ms 后才 spawn 接收者:
late changed -> 7 at 12 ms
怎么确认没中招
grep -rn 'Signal<' src/,对每个Signal数一数有几处调.wait()。超过一处就是这个坑,除非能证明它们永远不会同时在等。- 症状是「偶尔有一个任务没启动」或「某个任务漏掉了部分更新」,而且换一次运行结果就变。
- 一对一的通知(中断告诉一个任务「数据到了」)用
Signal正好;一对多改Watch;多对一、每条都要处理用Channel。
小结
Signal只有一个 waker 槽,值被wait()移走;两个等待者时每个值只到一个任务,等一次的那个可能永远醒不来。- 哪个任务拿到不固定,取决于运行队列顺序。
- 多个接收者看最新值用
Watch<M, T, N>,每个任务一个Receiver,changed()按版本号判断。