Signal、Watch 与 PubSub
主线位置:跨任务主线的三个变体。
Channel是「每条消息恰好一个人收到」;这里的三个类型改的正是这一点:Signal只留最新的一个值,Watch让多个接收者都看最新值,PubSubChannel让每个订阅者都收到每一条。锁和 waker 槽和Channel一样,见 原始互斥 和 等待队列。
flowchart TB
q{"每条消息给谁"} -->|恰好一个接收者| ch["Channel"]
q -->|所有订阅者| ps["PubSubChannel"]
q -->|只要最新值| last{"几个接收者"}
last -->|一个| sig["Signal"]
last -->|多个| w["Watch"]
文件:signal.rs(144 行)、watch.rs(1127 行,第 398 行以后是 Sender / Receiver / AnonReceiver 及其 Dyn 版本的包装,786 行以后是测试)、pubsub/(mod.rs 789 行,publisher.rs 320 行,subscriber.rs 195 行)。实验都在 exp-embassy/sync-std(platform-std 执行器,release,M2 Max、macOS 27、rustc 1.99.0)。
Signal:一个 Cell,三种状态
Signal<M, T> 是 Mutex<M, Cell<State<T>>>,State 三选一:
enum State<T> {
None,
Waiting(Waker),
Signaled(T),
}
值和 waker 不会同时存在:有人在等的时候里面没有值,有值的时候没人在等。signal 无条件把状态换成 Signaled(val),换出来的是 Waiting 就叫醒它,换出来的是旧的 Signaled 就让它随之 drop。poll_wait:
fn poll_wait(&self, cx: &mut Context<'_>) -> Poll<T> {
self.state.lock(|cell| {
let state = cell.replace(State::None);
match state {
State::None => {
cell.set(State::Waiting(cx.waker().clone()));
Poll::Pending
}
State::Waiting(w) if w.will_wake(cx.waker()) => {
cell.set(State::Waiting(w));
Poll::Pending
}
State::Waiting(w) => {
cell.set(State::Waiting(cx.waker().clone()));
w.wake();
Poll::Pending
}
State::Signaled(res) => Poll::Ready(res),
}
})
}
Cell 里的值要先 replace 出来才能看,看完再放回去,所以每个分支都有一次 set。第三个分支和 WakerRegistration 的规则一样:别的任务来等,就把原来的等待者换出来叫醒。Signal 的文档说它是「for a single consumer」,两个任务同时 wait() 的后果是互相叫醒,直到值来了被其中一个拿走:
static SIG2: Signal<CriticalSectionRawMutex, u32> = Signal::new();
#[embassy_executor::task(pool_size = 2)]
async fn waiter(id: usize) {
let v = Counted { inner: Box::pin(SIG2.wait()), id }.await;
println!(" waiter {id} got {v} at {} ms, polls {}", Instant::now().as_millis(), POLLS[id].load(Ordering::Relaxed));
}
sync-std/src/bin/signal_watch.rs 先演示覆盖,再 spawn 两个 waiter,100 ms 后 signal(42):
signaled 1,2,3; signaled() = true
wait() -> 3
try_take() after wait -> None
after 100 ms: polls 1202027 1202027
waiter 2 got 42 at 100 ms, polls 1202028
after another 100 ms: polls 1202028 1202028, signaled() = false
- 连着
signal(1)、signal(2)、signal(3),wait()只拿到 3,1 和 2 没有任何痕迹。这是Signal的本意:传「状态」而不是「事件」,接收方只关心最新的。 - 两个等待者 100 ms 里各被 poll 120 万次。值来了以后,正好排在前面的那个(这次是 waiter 2,再跑一次是 waiter 1)拿走它;另一个最后一次 poll 看到
None,登记自己,然后安静地睡下去,再也收不到这个 42。 wait()是 cancel-safe 的:值只在返回Ready的那一次 poll 里被取走,future 中途被 drop 不会丢值。0.8.0 前夕的提交98012435e「document signal cancel safety」把这句写进了文档。
try_take 只在 Signaled 时取走值,其他状态原样放回;reset 就是调它。signaled() 只看不取。
2025 年 8 月的 #4598(首次进入 embassy-sync 0.8.0,CHANGELOG 记作「Fix wakers getting dropped by Signal::reset」)之前,reset 是一行 self.state.lock(|cell| cell.set(State::None));。如果此时有任务在 wait(),状态是 Waiting(waker),这一行把它连同 waker 一起丢掉;之后 signal(v) 换出来的是 None,没有人可叫,等待的任务就一直睡着,直到它碰巧因为别的原因被 poll。提交「embassy-sync: Don't drop wakers in Signal::reset」把它改成 self.try_take();:try_take 遇到 Waiting 会原样放回去。try_take 自己在 2023 年 12 月加入时就有「Put waiting state back if any」这一步(提交 13c107e81),reset 当时没有跟着改。
Watch:一个值,一个版本号
Watch<M, T, N> 的状态 WatchState:
struct WatchState<T: Clone, const N: usize> {
data: Option<T>,
current_id: u64,
wakers: MultiWakerRegistration<N>,
receiver_count: usize,
}
send 写入 data、current_id += 1、叫醒 wakers 里所有人。每个接收者记着自己看过的版本号 at_id,poll_changed 只在 current_id > *id 时返回值(clone 一份)并把 *id 更新成 current_id;poll_get 不管版本号,有值就返回。
- 接收者有上限:
receiver在receiver_count == N时返回None,Receiverdrop 时减回去。N同时是MultiWakerRegistration<N>的槽数,所以正常情况下槽够用。 anon_receiver不计数,也只有try_get/try_changed这些不等待的方法,从不登记 waker,可以有任意多个。中断处理函数里想看一眼最新值就用它。- 新接收者的
at_id是 0。Watch::new()的current_id也从 0 开始,第一次send之后才是 1,所以第一次changed()能等到它。Watch::new_with(v)带着初始值但版本号仍是 0,changed()认为「没变过」(sync-std/src/bin/watch_init.rs):
get(): Ready(7)
changed(): Pending
third receiver: false
after send(8), changed(): Ready(8)
signal_watch.rs 的第三段:两个接收者各自循环 changed() 两次,主任务连发 1、2、3,10 ms 后再发 4:
sent 1,2,3 back to back
watcher 1 changed -> 3 at 220 ms
watcher 2 changed -> 3 at 220 ms
sent 4
watcher 2 changed -> 4 at 233 ms
watcher 1 changed -> 4 at 233 ms
三次 send 发生在接收者被 poll 之前,current_id 从 0 跳到 3,接收者醒来只看到 3。中间值被合并,和 tokio 的 watch 一样(tokio 卷 oneshot、watch、broadcast、Notify 里「中间值被合并」的实验);区别在于 tokio 用读写锁护值、等待者数量不限,这里是一把 RawMutex、最多 N 个会等待的接收者。
clear() 把 data 置为 None,既不加版本号也不叫醒任何人。send_if_modified(f) 只在闭包返回 true 时加版本号、叫醒。
PubSubChannel:每条消息记着还有几个人没读
PubSubChannel<M, T, CAP, SUBS, PUBS> 有五个参数:容量、订阅者上限、发布者上限。PubSubState:
struct PubSubState<T: Clone, const CAP: usize, const SUBS: usize, const PUBS: usize> {
/// The queue contains the last messages that have been published and a countdown of how many subscribers are yet to read it
queue: Deque<(T, usize), CAP>,
/// Every message has an id.
/// Don't worry, we won't run out.
/// If a million messages were published every second, then the ID's would run out in about 584942 years.
next_message_id: u64,
/// Collection of wakers for Subscribers that are waiting.
subscriber_wakers: MultiWakerRegistration<SUBS>,
/// Collection of wakers for Publishers that are waiting.
publisher_wakers: MultiWakerRegistration<PUBS>,
/// The amount of subscribers that are active
subscriber_count: usize,
/// The amount of publishers that are active
publisher_count: usize,
}
每条消息在队列里带一个倒计数:发布时等于当时的订阅者数,每个订阅者读一次减一。每个 Subscriber 自己记 next_message_id;队首消息的编号是 next_message_id - queue.len(),算出来就知道自己要读的那条在队列的第几个。
try_publish:没有订阅者时直接返回 Ok(()),消息被丢掉;队列满了返回 Err(message);否则 push_back((message, subscriber_count))、编号加一、叫醒所有订阅者。Publisher::publish().await 在 Err 时登记到 publisher_wakers 等着。
fn get_message(&mut self, message_id: u64) -> Option<WaitResult<T>> {
let start_id = self.next_message_id - self.queue.len() as u64;
if message_id < start_id {
return Some(WaitResult::Lagged(start_id - message_id));
}
let current_message_index = (message_id - start_id) as usize;
if current_message_index >= self.queue.len() {
return None;
}
// We've checked that the index is valid
let queue_item = self.queue.iter_mut().nth(current_message_index).unwrap();
// We're reading this item, so decrement the counter
queue_item.1 -= 1;
let message = if current_message_index == 0 && queue_item.1 == 0 {
let (message, _) = self.queue.pop_front().unwrap();
self.publisher_wakers.wake();
// Return pop'd message without clone
message
} else {
queue_item.0.clone()
};
Some(WaitResult::Message(message))
}
- 订阅者要读的编号比队首还小,说明它要的消息已经不在队列里了,返回
Lagged(差几条),调用方把自己的编号加上这个数,下次从队首读。 - 最后一个读者读的若是队首,消息被
pop_front移出来直接交给它,不 clone,并叫醒等着的发布者。 - 正常的
publish永远不会让人Lagged:队首那条只要还有一个订阅者没读,计数就不为 0,不会出队,队列满了发布者就等。能挤掉没读完的消息的只有publish_immediate:满了先pop_front,再发。ImmediatePublisher不占PUBS的名额,也从不等待,所以中断处理函数里发布就用它。
订阅者 drop 时,unregister_subscriber 把它还没读的那些消息的计数都减一,再把队首计数为 0 的消息弹出,必要时叫醒发布者。否则一个被丢掉的订阅者会让队列永远卡满。
sync-std/src/bin/pubsub_lag.rs 第二段:PubSubChannel<_, u32, 2, 2, 1>,一个订阅者立刻读,另一个每条之前睡 100 ms,发布者用会等待的 publish:
published 0 at +0 ms
published 1 at +0 ms
fast sub got Message(0) at 1 ms
fast sub got Message(1) at 1 ms
slow sub got Message(0) at 104 ms
published 2 at +102 ms
fast sub got Message(2) at 104 ms
slow sub got Message(1) at 208 ms
published 3 at +206 ms
fast sub got Message(3) at 208 ms
slow sub got Message(2) at 313 ms
published 4 at +311 ms
fast sub got Message(4) at 313 ms
slow sub got Message(3) at 418 ms
slow sub got Message(4) at 523 ms
容量 2,前两条立刻发出去;第三条要等慢订阅者读完 0,0 才出队。之后发布者和快订阅者都被慢订阅者拖成每 100 ms 一条。这是 publish 的语义:所有人都要收到,最慢的人决定速度。
pubsub_lag.rs 第一段:
static PS: PubSubChannel<CriticalSectionRawMutex, u32, 4, 2, 1> = PubSubChannel::new();
let mut a = PS.subscriber().unwrap();
let mut b = PS.subscriber().unwrap();
let p = PS.immediate_publisher();
for v in 0..6 {
p.publish_immediate(v);
}
println!("len after 6 publish_immediate: {}", PS.len());
println!("a: {:?}", a.try_next_message());
println!("a: {:?}", a.try_next_message());
订阅者的内部类型 Sub(Subscriber 解引用得到它)实现了 Stream,它的 poll_next 遇到 Lagged 不返回,而是 wake_by_ref 再返回 Pending,下一次 poll 从队首继续,相当于自动跳过丢失的部分;想自己处理就用 next_message(),想忽略就用 next_message_pure()。
怎么选
| 需求 | 用 | 满 / 慢的时候 |
|---|---|---|
| 每条消息恰好一个接收者处理 | Channel | 发送方等(或 try_send 失败) |
| 一个接收者,只要最新状态 | Signal | 新值覆盖旧值,不等 |
| 多个接收者,只要最新状态 | Watch | 新值覆盖旧值,不等;中间值被合并 |
| 多个订阅者,每条都要收到 | PubSubChannel + publish | 发布者等最慢的订阅者 |
| 多个订阅者,发布方不能等(中断) | PubSubChannel + publish_immediate | 挤掉最旧的,慢订阅者收到 Lagged |
动脑筋答案
len after 6 publish_immediate: 4
a: Some(Lagged(2))
a: Some(Message(2))
- 容量 4,发到第 5 条(值 4)时队列满了,
publish_immediate弹出 0;发第 6 条时弹出 1。队列里剩 2、3、4、5,长度 4。 a的编号还是 0,队首编号是6 - 4 = 2,0 < 2,返回Lagged(2),a的编号跳到 2。- 第二次读到 2,计数从 2 减到 1(
b还没读),所以 2 留在队列里,是 clone 出来的。b接着读会得到[Lagged(2), Message(2), Message(3), Message(4), Message(5)](同一程序的下一行输出)。
小结
Signal:Cell<State>,None/Waiting(Waker)/Signaled(T)三选一;signal覆盖旧值;第二个等待者会把第一个换出来叫醒,两者互相 poll 直到有值,没拿到的那个睡下去。0.8.0 修了reset丢 waker 的问题。Watch:一个Option<T>加u64版本号,send叫醒全部N个槽;changed比版本号,中间值合并;new_with的初始值不算一次「变化」;AnonReceiver不登记 waker、不占名额。PubSubChannel:Deque<(T, 剩余读者数), CAP>加全局u64编号;publish等最慢的订阅者;publish_immediate挤掉队首,慢订阅者得到Lagged(n);最后一个读者把消息移走不 clone;没有订阅者时发布的消息直接丢弃。