Rust in Everything
基于 embassy-time 0.5.1

Ticker 阻塞之后连着 tick 好几次;从 interval 搬过来第一次 tick 晚一个周期

主线位置:定时器主线的使用端。Ticker 每次 next() 把到期时刻交给 schedule_wake,后面的路和 Timer 一样,见 Timer、Ticker 与超时。和 tokio interval 的完整对照见本站的定时循环搬到 embassy。

stateDiagram-v2
  [*] --> Waiting: every(100ms)
  Waiting --> Fired: 到期
  Fired --> Waiting: expires_at += 100ms
  Fired --> Fired: 加完仍已过期

实验 crate exp-embassy/pitb-std,bins p18_ticker、p18_first(release,各 3 次);M2 Max、macOS 27、rustc 1.99.0。std 驱动每次唤醒晚 1 到 5 ms,所以数字不是整百。

现象

一个 100 ms 的周期任务,第 2 个 tick 之后做了一件阻塞 350 ms 的事(擦一块 flash、一次阻塞的 I²C 事务,这里用 embassy_time::block_for 代替):

let mut ticker = Ticker::every(Duration::from_millis(100));
for n in 0..8 {
  ticker.next().await;
  // 记下这一 tick 的时刻
  if n == 1 {
    block_for(Duration::from_millis(350)); // e.g. a slow flash erase, done blocking
  }
}
ticks at: 105 205 555 555 555 605 705 804
ticks at: 101 204 554 554 554 600 705 805
ticks at: 104 205 555 555 555 605 705 805

阻塞在 555 ms 结束,接下来三个 tick 在同一毫秒连着来。如果循环体是「采一次样、发一包数据」,下游就在一瞬间收到三包。

第二种现象出在从 tokio 搬代码时。同一个三次循环,tokio::time::interval 和 Ticker::every 各跑一遍(p18_first):

 1 104 202  <- tokio interval
 105 205 301  <- embassy Ticker, same loop
 0 103 203  <- embassy Ticker, work before next()

tokio 的第一个 tick 立刻就绪,embassy 的第一个在一个周期之后。原代码依赖「启动就先跑一次」,搬过来以后第一次工作晚了一个周期。

出处

Ticker 只有 expires_at 和 duration 两个字段。every 把 expires_at 设成 Instant::now() + duration,所以第一个 tick 在一个周期之后。next:

        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
            }
        })

到期就只加一个周期并返回 Ready,不看加完之后是不是还在过去。阻塞结束时 expires_at 是 300,now 是 555:第一次 next() 加到 400 返回,第二次加到 500 返回,第三次加到 600 返回,第四次才 Pending。这就是 tokio 的 MissedTickBehavior::Burst;embassy 只有这一种行为,没有 Delay / Skip 选项。

阻塞本身是另一个坑(它让同一执行器上的所有任务都停 350 ms,见一个任务忙等,其他任务全停)。但即使把阻塞改成 .await,只要一轮工作超过一个周期,同样会补 tick。

修法

不想补 tick:自己记一份下一次到期时刻(Ticker 不暴露 expires_at),迟到超过一个周期就 reset,从现在重新起网格:

let mut ticker = Ticker::every(period);
let mut next_due = Instant::now() + period; // our own copy: Ticker does not expose expires_at
loop {
  ticker.next().await;
  let now = Instant::now();
  if now > next_due + period {
    // late by more than a period: drop the missed ticks, restart the grid from now
    ticker.reset();
    next_due = now + period;
  } else {
    next_due += period;
  }
  // ...
}
ticks at: 102 205 555 660 759 858 960 1058
ticks at: 102 205 555 660 760 860 960 1060
ticks at: 105 205 555 660 758 860 957 1060

555 只 tick 一次,之后从 555 起每 100 ms 一次(相当于 tokio 的 Delay)。想留在原网格上、只跳过错过的(Skip),用 reset_at(grid - period),reset_at 的语义是「把这个时刻当作刚 tick 过」,推导见本站的定时循环搬到 embassy。

要第一次立刻执行:把工作挪到 next() 前面,见上面 p18_first 的第三行(0、103、203)。从 tokio 搬过来时,原代码里专门「吃掉第一个 tick」的那行 interval.tick().await 要删掉,否则又多等一个周期。

怎么确认没中招

  • 在循环体里记录相邻两次 tick 的间隔;出现接近 0 的间隔,就是在补 tick。
  • grep -rn 'Ticker::every' src/,对每一处问两个问题:循环体最坏耗时会不会超过周期?第一次执行要不要立刻发生?
  • 从 tokio 移植的代码,搜 interval( 和 MissedTickBehavior,原来设了 Delay / Skip 的地方要手写上面的 reset。

小结

  • Ticker::next 每次只加一个周期,错过的 tick 连续补齐(tokio 的 Burst),没有别的策略。
  • Ticker::every 第一个 tick 在一个周期之后;tokio interval 第一个 tick 立即就绪。
  • 不想补:自己记 next_due,迟到就 reset() 或 reset_at(..);要立即执行:先干活再 next()。

💬 关联讨论

发起讨论
加载中...