Rust in Everything
基于 embassy-time 0.5.1

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) 的写法。读出三条规则:

  1. 先 poll 内层 future,再 poll 定时器。两者在同一次 poll 里都就绪,内层赢。
  2. 定时器是在调用 with_timeout 时构造的,截止时刻从那一刻算起,而不是从第一次 .await 算起。
  3. 超时就返回 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。
📝NOTE
冷知识:with_timeout 以前是 async fn,截止时刻从第一次 poll 才开始算

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 和每次唤醒的几毫秒延迟都累积下来。

❗IMPORTANT
动脑筋:同样的 Ticker 放到 QEMU 上,tick 3 到 7 打印在第几毫秒?

把上面的程序搬到 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::DelayNsTimer::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。

💬 关联讨论

发起讨论
加载中...