定时器队列
主线位置:定时器主线的「定时器队列」一步。驱动的
schedule_wake(at, waker)把这一对放进队列;闹钟响了,驱动调next_expiration(now)把到期的取出来唤醒,再用返回的时刻设下一个闹钟。
flowchart TB
sw["schedule_wake at waker"] --> sel{"generic-queue-N"}
sel -->|没开| integ["集成队列"]
sel -->|开了| gen["通用队列"]
integ --> item["from_embassy_waker"]
item --> hdr["任务头里的队列项"]
gen --> vec["heapless Vec N 项"]
hdr --> ne["next_expiration now"]
vec --> ne
ne --> wake["到期的 wake"]
ne --> next["返回最早时刻"]
一个接口,两种实现
驱动只看到 embassy_time_queue_utils::Queue 两个方法。Queue 是一层薄包装,背后是哪种实现由 feature 决定:
#[cfg(all(not(feature = "integrated-timers"), feature = "_generic-queue"))]
type QueueImpl = queue_generic::Queue;
#[cfg(any(feature = "integrated-timers", not(feature = "_generic-queue")))]
type QueueImpl = queue_integrated::Queue;
// ...
impl Queue {
// ...
pub fn schedule_wake(&mut self, at: u64, waker: &Waker) -> bool {
self.queue.schedule_wake(at, waker)
}
/// Dequeues expired timers and returns the next alarm time.
pub fn next_expiration(&mut self, now: u64) -> u64 {
self.queue.next_expiration(now)
}
}
默认是集成队列;开任意一个 generic-queue-8/16/32/64/128 就换成通用队列。用户通常在 embassy-time 上开这个 feature,它转发到 embassy-time-queue-utils;HAL 的驱动也依赖同一个 crate,cargo 的 feature 合并让 HAL 驱动里的 Queue 一起换掉。驱动不用改一行。
这是 0.3.2(2026-04-21)的写法。#5929(「ETQU: Make generic-queues replace integrated timers by default」)之前,0.3.1 里两种实现都会编译进来;现在开了通用队列,queue_integrated 模块就不再编译,除非另开 integrated-timers。
集成队列:节点在任务里
队列项在哪
队列项的内存在执行器那边。每个任务头 TaskHeader 里有一个 timer_queue_item: TimerQueueItem 字段(任务头的完整布局见任务存储)。这个类型定义在单独的 crate embassy-executor-timer-queue 里,只是一块对齐到 8 的原始内存,TimerQueueItem:
const ITEM_WORDS: usize = if cfg!(feature = "timer-item-size-8-words") {
8
} else if cfg!(feature = "timer-item-size-6-words") {
6
} else if cfg!(feature = "timer-item-size-4-words") {
4
} else {
0
};
#[repr(align(8))]
pub struct TimerQueueItem {
data: [usize; ITEM_WORDS],
}
执行器依赖它时不开任何 feature;embassy-time-queue-utils 依赖它时开 timer-item-size-6-words,并且不分集成还是通用。所以只要依赖图里有 embassy-time-queue-utils(std、mock、wasm 驱动和 embassy-stm32 的驱动都依赖它),每个任务都多 6 个字。打印 size_of(TaskStorage<core::future::Ready<()>> 是一个 future 几乎为空的任务):
thumbv7em: TimerQueueItem 24 bytes, TaskStorage<Ready<()>> 48 bytes
aarch64, generic-queue-8: TimerQueueItem 48 bytes, TaskStorage<Ready<()>> 88 bytes
aarch64, no embassy-time: TimerQueueItem 0 bytes, TaskStorage<Ready<()>> 40 bytes
(第一行在 QEMU 11.1.2 上用 semihosting 打印,embassy-stm32 打了 PWR 补丁;后两行 M2 Max、rustc 1.99.0,release。)第二行开的是通用队列,任务头里的 48 字节照样在,只是没人用。第三行只依赖执行器,项的大小是 0。
把内存放进单独的 crate,是 #4550(「Make timer queue item opaque, move to new crate」,随 embassy-executor 0.9.0 发布)做的;crate 自己的文档说目的是让定时器队列和执行器各自发版。队列实现用 as_ref::<T>() / as_mut::<T>() 把这块内存解释成自己的类型,validate 在编译期断言 T 放得下、对齐不超过 8。注意 as_mut 的签名是 unsafe fn as_mut<T>(&self) -> &mut T:从共享引用给出可变引用,独占性完全靠调用方保证(集成队列靠 &mut self 和「全系统只有一个集成队列」的约定)。
从 waker 找回任务
schedule_wake 只拿到一个 &Waker。TimerQueueItem::from_embassy_waker 调一个外部函数,由执行器在 raw/mod.rs 里定义,__embassy_time_queue_item_from_waker:
#[unsafe(no_mangle)]
extern "Rust" fn __embassy_time_queue_item_from_waker(waker: &Waker) -> &'static mut TimerQueueItem {
unsafe { task_from_waker(waker).timer_queue_item() }
}
task_from_waker 比较 waker 的 vtable 地址是不是执行器自己的那张表,是就把 data 指针当成 TaskHeader 指针(waker 怎么构造见状态与运行队列),不是就 panic。同一个文件里还有一个 __try_embassy_time_queue_item_from_waker,返回 Option,0.10.0 新加的;0.1.0 的 embassy-executor-timer-queue 还没调用它。
这里有两种失败,取决于程序里有没有链接执行器。std 上用 futures::executor::block_on 去 await 一个 Timer(M2 Max、rustc 1.99.0,release):
程序没用
embassy-executor:没人定义这个符号,链接失败。= note: Undefined symbols for architecture arm64: "___embassy_time_queue_item_from_waker", referenced from: __RNvMCs3NsixoHMXeV_28embassy_executor_timer_queueNtB2_14TimerQueueItem18from_embassy_waker in libembassy_executor_timer_queue-11efd1c94672e0b3.rlib[4](embassy_executor_timer_queue-11efd1c94672e0b3.embassy_executor_timer_queue.2c3be616eceac4fb-cgu.0.rcgu.o)程序链接了执行器(加一行
use embassy_executor as _;),但 waker 不是它的:运行时 panic。thread 'main' (4715454) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/embassy-executor-0.10.0/src/raw/waker.rs:35:5: unwrap of `try_task_from_waker(waker)` failed: Found waker not created by the Embassy executor. Unless the generic timer queue is enabled, `embassy_time::Timer` only works with the Embassy executor.: NoneError thread '<unnamed>' (4715455) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/embassy-time-0.5.1/src/driver_std.rs:54:36: called `Result::unwrap()` on an `Err` value: PoisonError { .. }第二个 panic 是连带的:第一个 panic 发生在 std 驱动的
schedule_wake里,当时持着驱动的Mutex,锁被毒化;闹钟线程正是这次schedule_wake里的init刚起的,它在第 54 行第一次lock().unwrap()就 panic 了。
两份文档各说对了一半:embassy-time 0.5.1 的 Cargo.toml 写「It will panic if you try to await any timer when using another executor」,embassy-time-queue-utils 0.3.2 的 Cargo.toml 写「It will fail to build if you try to use it without an embassy executor in the project」。开 generic-queue-8 后同一个 block_on 程序正常返回(slept 12 ms)。
节点和三种情况
集成队列把那块内存解释成 QueueItem:
struct QueueItem {
/// The next item in the queue.
///
/// If this field contains `Some`, the item is in the queue. The last item in the queue has a
/// value of `Some(dangling_pointer)`
pub next: Cell<Option<NonNull<QueueItem>>>,
/// The time at which this item expires.
pub expires_at: u64,
/// The registered waker. If Some, the item is enqueued in the timer queue.
pub waker: Option<Waker>,
}
32 位目标上 4 + 8 + 8 = 20 字节,对齐后 24,正好 6 个字。队列本身只有一个 head 指针,是一条不排序的单链表。(next 的注释说最后一项是 dangling 指针,代码里最后一项的 next 是插入时的旧 head,即 None;判断「在不在队列里」实际用的是 waker 字段。)
let item = unsafe { item.as_mut::<QueueItem>() };
match item.waker.as_ref() {
Some(_) if at <= item.expires_at => {
// If expiration is sooner than previously set, update.
item.expires_at = at;
// The waker is always stored in its own queue item, so we don't need to update it.
// Trigger a queue update in case this item can be immediately dequeued.
true
}
Some(_) => {
// Queue item does not need to be updated, the task will be scheduled to be woken
// before the new expiration.
false
}
None => {
// If not in the queue, add it and update.
let mut item_ptr = NonNull::from(item);
let prev = self.head.replace(Some(item_ptr));
let item = unsafe { item_ptr.as_mut() };
item.expires_at = at;
item.waker = Some(waker.clone());
item.next.set(prev);
// ...
true
}
}
每个任务只有一个节点,所以:
- 一个任务等两个定时器(比如
join(Timer::after_millis(100), Timer::after_millis(300))):节点只记更早的那个。100 ms 到期,任务被唤醒、节点出队;任务重新 poll,300 ms 那个Timer还没到期,再调一次schedule_wake,节点重新入队。晚的定时器不会丢,只是每次被唤醒后重新登记。 - 更晚的时刻直接返回
false:任务反正会在更早的时刻醒来,到时候再登记。 - 插入是 O(1),放在链表头,不排序。
next_expiration 遍历整条链表:
pub fn next_expiration(&mut self, now: u64) -> u64 {
let mut next_expiration = u64::MAX;
self.retain(|item| {
if item.expires_at <= now {
// Timer expired, process task.
if let Some(waker) = item.waker.take() {
waker.wake();
}
false
} else {
// Timer didn't yet expire, or never expires.
next_expiration = min(next_expiration, item.expires_at);
item.expires_at != u64::MAX
}
});
next_expiration
}
到期的唤醒并移出;没到期的留下并参与求最小值;expires_at == u64::MAX(Timer::at(Instant::MAX))的移出但不唤醒:它永远不会到期,留在链表里只会拖慢遍历。每次闹钟响都是 O(n),n 是正在等定时器的任务数。(这个函数的文档还写着「The provided callback will be called for each expired task」,是旧接口留下的,现在没有回调参数。)
和 tokio 对照:tokio 的分层时间轮插入、删除、找最近都是 O(1)(见分层时间轮),因为一个运行时里可能有几十万个 Sleep;embassy 的任务数在编译期就定了,通常是个位数到几十,一条不排序的链表就够了,而且不用分配。
定时器被 drop,节点不出队
没有「取消」操作。Timer 被 drop 时什么也不做,节点留在队列里,到点照样唤醒任务。std 上的一个例子:with_timeout(100ms, SIG.wait()),另一个任务在 50 ms 时发信号;with_timeout 返回后,任务转去等一个永远 Pending、每次被 poll 就打印时刻的 future(M2 Max、rustc 1.99.0,release,两次):
with_timeout returned Ok(()) at 60 ms
polled at 60 ms
polled at 110 ms
[runq: killed after 5s]
110 ms 那一次 poll 就是 100 ms 的超时定时器留下的节点:它已经没有对应的 Timer 了,任务被白叫醒一次。future 本来就要容忍虚假唤醒,这是 Future 的约定;集成队列没有取消操作,代价就是这样一次多余的 poll。
上面 retain 的移出分支在 0.3.0 里是这样的:
// Remove it
prev.set(item.next.get());
item.next.set(None);
节点被摘下了,waker 却还留着。「在不在队列里」看的是 waker 是否为 Some,于是一个 expires_at == u64::MAX 的节点被摘下后,schedule_wake 仍以为它在队列里:新时刻比 u64::MAX 早,走第一个分支,只改 expires_at 就返回,节点再也不会被遍历到。#4736 的提交说明是「fix incomplete timer unqueuing that prevent any further timer in the same task」,补的就是现在那三行注释和 item.waker = None;;同一个提交在 tests/stm32/src/bin/timer.rs 里加了 select(Timer::at(Instant::MAX), yield_now()) 之后再 Timer::after_millis(100) 的用例。首个包含它的版本是 embassy-time-queue-utils 0.3.1(2026-04-16)。
把 0.3.0 的源码打进 [patch.crates-io],std 上跑同一个用例(M2 Max、rustc 1.99.0,release,各三次,输出相同)。std 驱动在闹钟线程里处理队列,所以 select 之后先睡 10 ms 让它跑一遍 next_expiration;stm32 驱动在 schedule_wake 里当场处理,不需要这一步:
worker: yield_now won the select
main: after 1000 ms, worker done = false
worker: yield_now won the select
worker: Timer::after_millis(100) returned after 108 ms
main: after 1000 ms, worker done = true
通用队列:定长数组
通用队列不碰任务头,节点存在队列自己的数组里,所以任何执行器的 waker 都能用。ConstGenericQueue 是一个 heapless::Vec<Timer, N>,每项是 at: u64 加一个 Waker。schedule_wake:
pub fn schedule_wake(&mut self, at: u64, waker: &Waker) -> bool {
self.queue
.iter_mut()
.find(|timer| timer.waker.will_wake(waker))
.map(|timer| {
if timer.at > at {
timer.at = at;
true
} else {
false
}
})
.unwrap_or_else(|| {
let mut timer = Timer {
waker: waker.clone(),
at,
};
loop {
match self.queue.push(timer) {
Ok(()) => break,
Err(e) => timer = e,
}
self.queue.pop().unwrap().waker.wake();
}
true
})
}
查重用 Waker::will_wake,同一个任务的 waker 只占一项,取更早的时刻,和集成队列的规则一样。不同的是插入要线性查找,以及满了怎么办:push 失败就把数组最后一项(pop)拿出来立刻唤醒,腾出位置再插。被挤掉的任务醒来一看没到期,又去 schedule_wake,又挤掉一个。
Driver::schedule_wake 没有错误返回,「提前唤醒」是接口允许的唯一退路,正确性没问题,代价在 CPU。std 上开 generic-queue-8,main 等 1000 ms,同时 spawn 若干个只等一次 500 ms 的任务,统计每个任务被 poll 的次数(M2 Max、rustc 1.99.0,release,三次):
7 sleepers, polls per task: [2, 2, 2, 2, 2, 2, 2], total 14 in 1004 ms
8 sleepers, polls per task: [1941415, 1941415, 2, 2, 2, 2, 2, 2], total 3882842 in 1003 ms
9 sleepers, polls per task: [1070919, 2141837, 1070920, 2, 2, 2, 2, 2, 2], total 4283688 in 1005 ms
7 sleepers, polls per task: [2, 2, 2, 2, 2, 2, 2], total 14 in 1001 ms
8 sleepers, polls per task: [1928182, 1928183, 2, 2, 2, 2, 2, 2], total 3856377 in 1000 ms
9 sleepers, polls per task: [1041205, 2082407, 1041204, 2, 2, 2, 2, 2, 2], total 4164828 in 1005 ms
...
main 自己也占一项,所以 7 个 sleeper 加 main 正好 8 项,每个任务 poll 两次(登记一次、到期一次)。8 个 sleeper 是 9 项,其中两个任务(这里是 0 号和 1 号)互相挤,500 ms 里各被 poll 近 200 万次,直到它们的定时器到期;9 个 sleeper 是三个任务轮流挤。同样的程序用集成队列,9 个 sleeper 每个都是 2 次:
9 sleepers, polls per task: [2, 2, 2, 2, 2, 2, 2, 2, 2], total 18 in 1005 ms
队列满不报错、不 panic、不打日志,表现只是 CPU 被占满、功耗上去。N 至少要等于同时在等定时器的任务数,main 也算;一个任务同时等几个定时器只占一项。
next_expiration 线性扫一遍,到期的 swap_remove 并唤醒。它没有集成队列那条 u64::MAX 的特殊处理:永不到期的项一直占着一个位置,直到同一个任务登记一个更早的时刻。
问: 集成队列这么省,为什么还要通用队列?
答: 集成队列要求 waker 是 embassy 执行器造的,因为它得从 waker 找到任务头里那块内存。别的执行器(RTIC、一个手写的 block_on、std 上的 futures::executor)里 await Timer 就只能用通用队列;mock 驱动的单元测试想用自己做的 waker 去 poll,也得用它(见时间驱动)。代价是 N 个 (u64, Waker) 的静态数组(64 位上每项 24 字节)、线性查找,以及上面那种满了以后的空转。0.3.1 起集成队列还多了一个 schedule_wake_queue_item 入口,让没有 embassy 任务的场合自己提供一块 TimerQueueItem(#4934 的说明举的例子是「RTOS-aware block_on」),但 Queue::schedule_wake 这条路径仍然只认 embassy 的 waker。
schedule_wake 的返回值
两种队列的 schedule_wake 都返回「要不要重设闹钟」:新入队返回 true,时刻更晚返回 false。时刻相同时两者不一样:集成队列的条件是 at <= item.expires_at,相等也返回 true(注释是「Trigger a queue update in case this item can be immediately dequeued」:新时刻可能已经过去,驱动接下来调的 next_expiration 会当场唤醒它);通用队列的条件是 timer.at > at,相等返回 false。所以在集成队列下,一个还没到期的 Timer 每被多 poll 一次,驱动就多扫一遍队列、多设一次闹钟。驱动拿到 true 后的循环见时间驱动里的参考写法。
Timer 每次返回 Pending 都会调一次 schedule_wake(见 Timer、Ticker 与超时),这就是上面那种「同一时刻重复登记」。
小结
Queue默认是集成队列,开generic-queue-N换成通用队列;0.3.2 起开了通用队列就不编译集成队列。- 集成队列的节点在任务头的
TimerQueueItem里(6 个字,32 位上 24 字节,只要用了embassy-time-queue-utils就每个任务都有),靠__embassy_time_queue_item_from_waker比对 vtable 找回任务;非 embassy waker 时,没链接执行器是链接错误,链接了是 panic。 - 每个任务一个节点,只记最早的时刻;链表不排序,插入 O(1),闹钟时 O(n) 遍历;
u64::MAX的节点直接移出;Timer被 drop 不出队,到点会多一次虚假唤醒。 - 0.3.0 移出
u64::MAX节点时忘了清waker,同一任务之后的定时器全部失效,0.3.1 修复(#4736),std 上复现。 - 通用队列是
heapless::Vec<(u64, Waker), N>,满了就提前唤醒最后一项;std 上 9 项挤 8 个位置时,两个任务半秒内各被 poll 近 200 万次。