Timer、Ticker 与超时
主线位置:定时器主线的第一步和最后一步。任务第一次 poll
Timer,它把到期时刻和 waker 交给schedule_wake,返回Pending;闹钟响过、任务被唤醒,再 poll 一次,返回Ready。中间的驱动和队列见时间驱动和定时器队列。
stateDiagram-v2 [*] --> Created: Timer after Created --> Waiting: 第一次 poll Waiting --> Waiting: 未到期 poll Waiting --> Done: 到期后 poll Done --> [*]
Timer
Timer 有两个字段:
pub struct Timer {
expires_at: Instant,
yielded_once: bool,
}
Timer::after 在构造时就算出 Instant::now() + duration;Timer::at 直接收下一个时刻,after_millis、after_micros 这些只是先调 Duration::from_*(向上取整,见 Instant 与 Duration)。构造不碰驱动,也不登记,登记发生在 poll:
impl Future for Timer {
type Output = ();
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
if self.yielded_once && self.expires_at <= Instant::now() {
Poll::Ready(())
} else {
embassy_time_driver::schedule_wake(self.expires_at.as_ticks(), cx.waker());
self.yielded_once = true;
Poll::Pending
}
}
}
两点:
- 第一次 poll 一定是
Pending。 哪怕时刻已经过去,也先登记、返回Pending,让执行器去跑别的任务;驱动看到过去的时刻会立刻唤醒(next_expiration当场处理),下一次 poll 才Ready。所以Timer::after(Duration::from_ticks(0))相当于让出一次,只是唤醒要经过驱动:stm32 的 TIM 驱动在schedule_wake里当场唤醒,std 驱动要等闹钟线程醒来处理。 - 每次
Pending都重新登记。 任务可能因为别的原因被唤醒(select里的另一个分支、上一页说的残留节点),这时Timer还没到期,再调一次schedule_wake。同一个时刻重复登记,通用队列返回false,集成队列返回true、让驱动再扫一遍队列(见定时器队列最后一节),结果都是什么也没变;waker 换了(future 被挪到另一个任务里 poll)也能跟上。
Timer 在 Waiting 状态被 drop 不需要任何清理,队列里那个节点到点会白叫醒一次任务,见定时器队列。
和 tokio 对照:tokio 的 Sleep 也在创建时记下 deadline、第一次 poll 才登记,但 deadline 换成毫秒 tick 时向上取整,sleep(Duration::ZERO) 通常要等 1 毫秒左右(见 Sleep 的一生);embassy 的 Timer::after(0) 只是让出一次。
超时:with_timeout 与 with_deadline
with_timeout 是一个普通函数,返回一个手写的 future:
pub fn with_timeout<F: Future>(timeout: Duration, fut: F) -> TimeoutFuture<F> {
TimeoutFuture {
timer: Timer::after(timeout),
fut,
}
}
// ...
impl<F: Future> Future for TimeoutFuture<F> {
type Output = Result<F::Output, TimeoutError>;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let this = unsafe { self.get_unchecked_mut() };
let fut = unsafe { Pin::new_unchecked(&mut this.fut) };
let timer = unsafe { Pin::new_unchecked(&mut this.timer) };
if let Poll::Ready(x) = fut.poll(cx) {
return Poll::Ready(Ok(x));
}
if let Poll::Ready(_) = timer.poll(cx) {
return Poll::Ready(Err(TimeoutError));
}
Poll::Pending
}
}
with_deadline(at, fut) 一样,只是用 Timer::at(at);WithTimeout trait 给所有 future 加上 .with_timeout(d) / .with_deadline(at) 的写法。读出三条规则:
- 先 poll 内层 future,再 poll 定时器。两者在同一次 poll 里都就绪,内层赢。
- 定时器是在调用
with_timeout时构造的,截止时刻从那一刻算起,而不是从第一次.await算起。 - 超时就返回
Err(TimeoutError),内层 future 随TimeoutFuture一起被 drop。
tokio 的 Timeout::poll 也是先 poll 内层(见 Sleep 的一生)。
std 上逐条验证(M2 Max、rustc 1.99.0,release,三次,下面是第一次;毫秒数每次差几毫秒,poll 次数完全相同)。count_polls 是一个包一层、数 poll 次数的小函数;C 构造 with_timeout(100ms, Timer::after_millis(200)) 后先 std::thread::sleep(150ms) 再 await:
A inner 50ms, timeout 100ms: Ok(()) after 54 ms
B inner 100ms, timeout 50ms: Err(TimeoutError) after 53 ms
C created, blocked 150ms, then awaited: Err(TimeoutError) after 155 ms, 2 polls
D ready future, zero timeout: Ok(7), 1 polls
E pending future, zero timeout: Err(TimeoutError), 2 polls
F Timer::after(0): 2 polls
G Timer::at(past): 2 polls
H done: Ok(())
- C:截止时刻在构造时已定,阻塞 150 ms 后它早过了;第一次 poll 内层没好、定时器按规矩先返回
Pending,第二次 poll 就超时,总共只多了一次让出。 - D:内层第一次 poll 就
Ready,零超时也赢不了它。 - E、F、G:零时长和过去的时刻都要两次 poll,就是
Timer的yielded_once。
0.5.0 之前,with_timeout 是这样写的:
pub async fn with_timeout<F: Future>(timeout: Duration, fut: F) -> Result<F::Output, TimeoutError> {
let timeout_fut = Timer::after(timeout);
match select(pin!(fut), timeout_fut).await {
Either::Left((r, _)) => Ok(r),
Either::Right(_) => Err(TimeoutError),
}
}
async fn 的函数体要到第一次 poll 才执行,Timer::after(timeout) 也就在那时才读 Instant::now();WithTimeout 里的方法也是 async fn。#4123(「Manually implement the future for with_timeout」)换成现在的 TimeoutFuture 结构体:返回值从匿名的 async 块变成一个有名字的类型,Timer 在调用时就构造。上面的 C 用例正是这个差别:按旧写法,超时定时器要到 150 ms 第一次 poll 时才构造、250 ms 到期,而内层的 Timer::after_millis(200) 作为参数在调用时就构造了、200 ms 到期,结果是约 200 ms 时返回 Ok;现在是 150 ms 处立刻超时。旧写法的行为是按源码推的,没有跑旧版本。首个包含它的版本是 embassy-time 0.5.0,CHANGELOG 只写了「Manually implement the future for with_timeout」,没有提这个语义变化。
Ticker:按网格走
loop { work(); Timer::after(d).await } 的周期是 d 加上 work() 的耗时,会越走越偏。Ticker 记住下一个到期时刻,每次只往后加一个周期:
pub struct Ticker {
expires_at: Instant,
duration: Duration,
}
impl Ticker {
/// Creates a new ticker that ticks at the specified duration interval.
pub fn every(duration: Duration) -> Self {
let expires_at = Instant::now() + duration;
Self { expires_at, duration }
}
// ...
pub fn next(&mut self) -> impl Future<Output = ()> + Send + Sync + '_ {
poll_fn(|cx| {
if self.expires_at <= Instant::now() {
let dur = self.duration;
self.expires_at += dur;
Poll::Ready(())
} else {
embassy_time_driver::schedule_wake(self.expires_at.as_ticks(), cx.waker());
Poll::Pending
}
})
}
}
- 第一个 tick 在
every之后一个周期,不是立刻。 next()没有yielded_once:到期了第一次 poll 就Ready,不让出。expires_at += dur,不是now + dur。错过了几个周期,expires_at就落后几个周期,接下来的几次next()每次都立刻Ready,一直补到expires_at追上现在,之后回到原来的网格上。- 只有
Ready分支改expires_at,next()返回的 future 被 drop 不丢 tick,这就是文档说的「cancel safe」。
想丢掉错过的 tick,就自己调 reset(expires_at = now + duration)、reset_at(deadline)(deadline + duration)或 reset_after(after)(now + after + duration)。Ticker 还实现了 Stream(poll_next 是同一段逻辑)和永不结束的 FusedStream。
和 tokio 对照:tokio::time::interval 第一个 tick 立刻到,错过 tick 时默认也是 Burst(一次补齐),另有 Delay、Skip 两种策略可选(见 Sleep 的一生);embassy 只有补齐这一种,别的行为靠 reset* 自己做。两者的完整对照见本站的定时循环搬到 embassy。
std 上验证:Ticker::every(100ms),第 2 个 tick 之后让执行器线程 std::thread::sleep(350ms),再对照同样阻塞一次的 Timer::after 循环(M2 Max、rustc 1.99.0,release,三次,下面是第一次;负载 29.8):
ticker tick 0 at 105 ms
ticker tick 1 at 201 ms
ticker tick 2 at 305 ms
ticker tick 3 at 657 ms
ticker tick 4 at 657 ms
ticker tick 5 at 657 ms
ticker tick 6 at 705 ms
ticker tick 7 at 805 ms
timer tick 0 at 104 ms
timer tick 1 at 209 ms
timer tick 2 at 312 ms
timer tick 3 at 767 ms
timer tick 4 at 872 ms
timer tick 5 at 977 ms
timer tick 6 at 1082 ms
timer tick 7 at 1238 ms
阻塞在 655 ms 左右结束,这时 400、500、600 三个时刻都过了:tick 3、4、5 同时到,tick 6、7 回到 700、800 的网格上(std 驱动大约晚 5 ms)。Timer 循环每一步都从「上一次醒来」起算,阻塞的 350 ms 和每次唤醒的几毫秒延迟都累积下来。
把上面的程序搬到 QEMU(SysTick 驱动,tick-hz-1_000,时间都用 Instant 读),阻塞换成 block_for(Duration::from_millis(350)):开着中断忙等 350 ms,SysTick 照常计数,执行器不运行。Timer 循环的 tick 3 又会打印在第几毫秒?答案在本页末尾。
Delay 与 block_for
block_for 是忙等:
pub fn block_for(duration: Duration) {
let expires_at = Instant::now() + duration;
while Instant::now() < expires_at {}
}
它不让出 CPU,同一个执行器上的其他任务在这段时间里都不会被 poll,上面 QEMU 版的阻塞就是用它做的。它也依赖 now() 在前进:在 SysTick 这种靠中断计数的驱动上,关着中断调它会永远不返回(时间驱动里的动脑筋)。
Delay 是一个零大小类型,给生态里的驱动库用,实现了两套 trait:
| trait | 实现 | 会不会阻塞执行器 |
|---|---|---|
embedded_hal::delay::DelayNs(1.0,阻塞) | block_for(Duration::from_nanos/micros/millis(..)) | 会,忙等 |
embedded_hal_async::delay::DelayNs | Timer::after_nanos/micros/millis(..) | 不会 |
embedded_hal 0.2 的 DelayMs<u8/u16/u32>、DelayUs<u8/u16/u32> | block_for | 会 |
同一个 Delay 值,传给阻塞版 trait 和异步版 trait 的行为完全不同。一个只接受 embedded_hal::delay::DelayNs 的传感器驱动库,在 embassy 任务里调 delay_ms(10) 就是忙等 10 ms。
纳秒级的延时也受 tick 限制:delay_ns(100) 在 1 MHz 下是 1 tick(1 µs),在 32768 Hz 下是 1 tick(30.5 µs),而且如前面说的,实际可能比 1 tick 还短。
动脑筋答案
QEMU 11.1.2 b-l475e-iot01a(embassy-stm32 用打过一行 PWR 补丁的副本,见时间驱动),模板的 1 kHz SysTick 驱动,release;时间全是 SysTick 计数,不是硬件时间:
ticker tick 0 at 100 ms
ticker tick 1 at 200 ms
ticker tick 2 at 300 ms
ticker tick 3 at 650 ms
ticker tick 4 at 650 ms
ticker tick 5 at 650 ms
ticker tick 6 at 700 ms
ticker tick 7 at 800 ms
timer tick 0 at 100 ms
timer tick 1 at 200 ms
timer tick 2 at 300 ms
timer tick 3 at 750 ms
timer tick 4 at 850 ms
timer tick 5 at 950 ms
timer tick 6 at 1050 ms
timer tick 7 at 1150 ms
Ticker:tick 2 在 300 ms 打印,block_for到 650 ms 结束。这时expires_at是 400,next()立刻Ready,加到 500,又立刻Ready,加到 600,再立刻Ready,加到 700:tick 3、4、5 都打印在 650。之后 700、800 回到网格上。和 std 的形状完全一样,只是没有 std 驱动那几毫秒的延迟,数字是整的。Timer循环:tick 2 在 300 ms 打印后阻塞到 650 ms,下一个Timer::after_millis(100)从 650 起算,tick 3 在 750 ms。之后每次加 100,整个序列从此比网格晚 50 ms(750、850……),不会追回来。
小结
Timer在构造时定下到期时刻,第一次 poll 一定返回Pending并登记,每次Pending都重新登记;Timer::after(0)等于让出一次。with_timeout是手写的TimeoutFuture:先 poll 内层、后 poll 定时器,截止时刻在调用时算;0.5.0 之前它是async fn,截止时刻从第一次 poll 才算(#4123)。Ticker每次只加一个周期,阻塞之后错过的 tick 连续补齐,std 和 QEMU 上都是 3 个 tick 同时到,然后回到网格;不想补就reset*。block_for和Delay的阻塞 trait 都是忙等;异步 trait 才走Timer。