Rust in Everything
基于 embassy-sync 0.8.0

等待队列

主线位置:跨任务主线里「满了就登记 waker、对端操作后叫醒」这两步。Channel::send 发现队列满,把当前任务的 waker 存进 senders_waker;接收方取走一条,调 senders_waker.wake()。存在哪、存几个、存满了怎么办,就是本页的三个类型。

flowchart TB
  reg["register w"] --> same{"已有 waker 叫醒同一任务"}
  same -->|是| keep["什么也不做"]
  same -->|否| kind{"哪种槽"}
  kind --> one["WakerRegistration"]
  kind --> many["MultiWakerRegistration"]
  one --> evict["叫醒旧的 存新的"]
  many --> full{"满了"}
  full -->|否| push["追加"]
  full -->|是| all["全部叫醒 清空 追加"]

embassy-sync/src/waitqueue/ 一共 230 行,四个文件:atomic_waker.rs、atomic_waker_turbo.rs(开 feature turbowakers 时用它替换前者,见 mod.rs)、waker_registration.rs、multi_waker.rs。三个类型都不分配:Waker 本身是两个字(数据指针加 vtable 指针,thumbv7em 上 8 字节),Option<Waker> 借 vtable 指针非空的空位也是 8 字节。

谁用哪一个

类型方法签名用在
AtomicWakerregister(&self)、wake(&self)embassy-sync 自己不用;HAL 驱动用,例如 embassy-stm32 0.6.0 的 exti/mod.rs 每条 EXTI 线一个 static EXTI_WAKERS: [AtomicWaker; EXTI_COUNT]
WakerRegistrationregister(&mut self)、wake(&mut self)Channel、PriorityChannel、zerocopy_channel、Pipe 每方向一个;Mutex、RwLock、GreedySemaphore 各一个
MultiWakerRegistration<N>同上Watch(N 个接收者)、PubSubChannel(SUBS 和 PUBS 各一组)

&self 和 &mut self 的差别是关键。WakerRegistration 和 MultiWakerRegistration 自己不加锁,必须放进外层的 Mutex<M, RefCell<..>> 里用(见 原始互斥);AtomicWaker 自带同步,可以直接是一个 static,在中断处理函数里 wake()。Signal 和 FairSemaphore 不用这三个,各自把 waker 放在自己的状态里(见 Signal、Watch 与 PubSub 和 async Mutex 与 Semaphore)。

AtomicWaker:名字叫 Atomic,里面是临界区

0.8.0 默认的 AtomicWaker 只是 GenericAtomicWaker<CriticalSectionRawMutex> 的包装,真正的实现是 GenericAtomicWaker:

pub struct GenericAtomicWaker<M: RawMutex> {
    waker: Mutex<M, Cell<Option<Waker>>>,
}

impl<M: RawMutex> GenericAtomicWaker<M> {
    // ...
    /// Register a waker. Overwrites the previous waker, if any.
    pub fn register(&self, w: &Waker) {
        self.waker.lock(|cell| {
            cell.set(match cell.replace(None) {
                Some(w2) if (w2.will_wake(w)) => Some(w2),
                _ => Some(w.clone()),
            })
        })
    }

    /// Wake the registered waker, if any.
    pub fn wake(&self) {
        self.waker.lock(|cell| {
            if let Some(w) = cell.replace(None) {
                w.wake_by_ref();
                cell.set(Some(w));
            }
        })
    }
}

两点:

  • register 覆盖旧的 waker,不叫醒它。注释写明了用途:「The intended use case is to wake tasks from interrupts」,通常只有一个任务在等一个中断源,被覆盖的旧 waker 一般就是同一个任务的。
  • wake 用 wake_by_ref,叫醒之后把 waker 放回去。之后再来一次中断、再 wake() 一次,同一个任务会再被叫醒一次,直到它注册一个别的 waker。下面的 WakerRegistration::wake 则是 take() 走,叫一次就空了。

Cell<Option<Waker>> 的 replace / set 都在 critical_section 里做,所以名字里的「Atomic」指的是「中断里也能安全调用」,不是无锁。以前它直接写死 critical_section::with,#3559 把它泛化成 GenericAtomicWaker<M>(CHANGELOG 0.6.1 记作「Add GenericAtomicWaker utility」)。0.8.0 之后的 main 上(尚未发版)AtomicWaker 改成了真正的无锁实现,临界区版本另起名 CriticalSectionWaker。

turbowakers 版本(atomic_waker_turbo.rs)才是原子操作:一个 AtomicPtr<()>,register 是一次 store(Release),wake 是一次 load(Acquire) 加 Waker::from_turbo_ptr(ptr).wake(),同样不清空。

📝NOTE
冷知识:一个字的 waker 要先给 core 打补丁

Waker::as_turbo_ptr 和 Waker::from_turbo_ptr 不是标准库的 API。#1263(首次进入 embassy-sync 0.2.0)的提交说明写道:「This is a core patch to make wakers 1 word (the task pointer) instead of 2 (task pointer + vtable)」。好处列了四条,其中一条是「AtomicWaker can actually use atomics to load/store the waker, No critical section needed」;坏处是「You have to patch core...」,而且所有不是 embassy-executor 造的 waker 都会在运行期 panic。用法是用一个脚本给 core 打补丁、开 build-std 和 build-std-features = core/turbowakers,再在 embassy-executor、embassy-sync 上开 turbowakers。所以默认的 AtomicWaker 一直是临界区版本。executor 的 Cargo.toml 在 0.10.0 里仍然说这个 feature「Not surfaced in the docs by default due to being an complicated advanced and undocumented feature」。

WakerRegistration:只记一个,换人时叫醒旧的

WakerRegistration 是一个 Option<Waker>。register 的注释几乎把设计取舍说全了:

    pub fn register(&mut self, w: &Waker) {
        match self.waker {
            // Optimization: If both the old and new Wakers wake the same task, we can simply
            // keep the old waker, skipping the clone. (In most executor implementations,
            // cloning a waker is somewhat expensive, comparable to cloning an Arc).
            Some(ref w2) if (w2.will_wake(w)) => {}
            _ => {
                // clone the new waker and store it
                if let Some(old_waker) = mem::replace(&mut self.waker, Some(w.clone())) {
                    // We had a waker registered for another task. Wake it, so the other task can
                    // reregister itself if it's still interested.
                    //
                    // 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()
                }
            }
        }
    }

wake 是 take() 再 wake()。

「换人时叫醒旧的」是正确性要求:旧任务可能还在等,直接覆盖它就永远睡下去了;叫醒它,它会再 poll 一次,条件还不满足就重新 register。代价也写在注释里:两个任务等同一个 WakerRegistration 时会互相叫醒,「This wastes CPU but things will still work」。Channel 的发送方、Mutex 的等锁方都只有一个这样的槽,所以多个任务一起等时,执行器不会睡眠,而是在这几个任务之间来回 poll。Channel 一页量到:两个发送方等一个满的 Channel,100 ms 里各被 poll 一百多万次(std)或二十多万次(QEMU)。

will_wake 比较的是 waker 的数据指针和 vtable。embassy 执行器给同一个任务造的 waker,数据指针都是那个任务的 TaskRef(见 状态与运行队列),所以同一个任务反复 register 只在第一次 clone。注释说 clone 一个 waker「comparable to cloning an Arc」,那是对一般执行器而言;embassy 执行器的 vtable 里 clone 只是用同一个指针再造一个 RawWaker,drop 是空函数(embassy-executor/src/raw/waker.rs),clone 几乎没有成本。对 embassy 来说这个检查更要紧的作用是不让任务叫醒自己:没有它,一个任务每次 poll 都会把槽里自己的旧 waker 换出来再叫醒,变成忙等。

MultiWakerRegistration:满了就全部叫醒

MultiWakerRegistration<N> 是一个 heapless::Vec<Waker, N>。register:

    pub fn register(&mut self, w: &Waker) {
        // If we already have some waker that wakes the same task as `w`, do nothing.
        // This avoids cloning wakers, and avoids unnecessary mass-wakes.
        for w2 in &self.wakers {
            if w.will_wake(w2) {
                return;
            }
        }

        if self.wakers.is_full() {
            // All waker slots were full. It's a bit inefficient, but we can wake everything.
            // Any future that is still active will simply reregister.
            // This won't happen a lot, so it's ok.
            self.wake();
        }

        if self.wakers.push(w.clone()).is_err() {
            // This can't happen unless N=0
            // (Either `wakers` wasn't full, or it was in which case `wake()` empied it)
            panic!("tried to push a waker to a zero-length MultiWakerRegistration")
        }
    }

先线性扫一遍去重(N 通常是个位数),满了就调 wake 把所有人叫醒、清空,再放进新的。wake 先把长度置 0 再逐个按值取出来叫醒,注释说这是为了 panic=unwind 下不让别人看到一个半清空的 Vec。

📝NOTE
冷知识:以前同一个任务能占满所有槽

2023 年 5 月的 #1490(首次进入 embassy-sync 0.3.0)之前,MultiWakerRegistration<N> 是 [WakerRegistration; N],register 找第一个空槽放进去,找不到就返回 Err(w),由调用方处理:PubSubChannel 里有两个函数 register_subscriber_waker / register_publisher_waker,收到 Err 就全部叫醒再注册一次,注释说满了「can only happen when there was a subscriber that now has dropped」。旧的 register 不做 will_wake 检查,同一个任务每 poll 一次就占一个新槽。提交「sync: do will_wake check in MultiWakerRegistration.」把去重和「满了全叫醒」都挪进了 register,存储换成 heapless::Vec<Waker, N>,register 不再返回 Result,PubSub 里的两个辅助函数随之删掉。

什么时候会满?Watch<M, T, N> 最多发 N 个 Receiver,PubSubChannel 最多 SUBS 个订阅者,正常情况下正好够用。多出来的来自「已经不等了、waker 还留在里面」的情况:一个订阅者在等消息时被 drop(比如作为 select 的输家),它的 waker 不会被移除;之后又新建了一个订阅者来等,槽就满了。满了以后多叫醒的那几个任务只是白 poll 一次,所以注释说「It's a bit inefficient」,不影响正确性。

❗IMPORTANT
动脑筋:谁被叫醒、什么时候

sync-std/src/bin/multi_waker.rs 用 std::task::Wake 造三个会打印自己名字的 waker:

let (a, b, c) = (waker("A"), waker("B"), waker("C"));
let mut reg = MultiWakerRegistration::<2>::new();
println!("register A"); reg.register(&a);
println!("register A again"); reg.register(&a);
println!("register B"); reg.register(&b);
println!("register C (slots full)"); reg.register(&c);
println!("wake()"); reg.wake();
println!("wake() again"); reg.wake();

let mut one = WakerRegistration::new();
println!("WakerRegistration: register A"); one.register(&a);
println!("WakerRegistration: register B"); one.register(&b);
println!("WakerRegistration: wake()"); one.wake();

每一行 println! 之后会打印哪些「woken: X」?答案在本页末尾。

和 tokio 对照

tokio 的 AtomicWaker 是一个无锁的状态机(REGISTERING / WAKING 位),等待多个任务的地方用侵入式链表,节点放在每个等待者的 future 里,所以等待者数量没有上限,也不会互相叫醒(见 tokio 卷的 batch_semaphore)。embassy 这里只有定长数组和单槽:没有链表、没有 unsafe 的节点生命周期管理,代码很短,代价是等待者多于槽数时退化成「叫醒所有人,让他们自己重新排」。

动脑筋答案

sync-std,release,M2 Max、macOS 27、rustc 1.99.0:

register A
register A again
register B
register C (slots full)
    woken: A
    woken: B
wake()
    woken: C
wake() again
WakerRegistration: register A
WakerRegistration: register B
    woken: A
WakerRegistration: wake()
    woken: B
  • register A again:will_wake 认出是同一个 waker(槽里存的是 a 的 clone,指向同一个 Arc),直接返回,不占第二个槽。
  • register C:两个槽已满,先 wake() 叫醒 A 和 B(按存入顺序),清空,再放进 C。
  • 第一次 wake() 只剩 C;第二次 wake() 时 Vec 已经空了,什么也不打印。
  • WakerRegistration 只有一个槽:register B 时把 A 换出来并叫醒它;wake() 叫醒 B 并清空。

小结

  • AtomicWaker:&self 接口,可以是 static,给中断驱动用;register 覆盖不叫醒,wake 叫醒后保留。0.8.0 默认实现是临界区里的 Cell<Option<Waker>>,真正原子的版本要 turbowakers,而那要给 core 打补丁。
  • WakerRegistration:一个 Option<Waker>,同一任务重复注册不 clone;换成别的任务时叫醒旧的。两个任务等同一个槽会互相叫醒,CPU 不睡。
  • MultiWakerRegistration<N>:heapless::Vec<Waker, N>,先去重,满了全部叫醒再放新的。
  • 三者都不分配,都靠 will_wake 省掉 clone;没有排队顺序,也没有「只叫醒一个」的语义。

💬 关联讨论

发起讨论
加载中...