Ticker 阻塞之后连着 tick 好几次;从 interval 搬过来第一次 tick 晚一个周期
主线位置:定时器主线的使用端。
Ticker每次next()把到期时刻交给schedule_wake,后面的路和Timer一样,见 Timer、Ticker 与超时。和 tokiointerval的完整对照见本站的定时循环搬到 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 在一个周期之后;tokiointerval第一个 tick 立即就绪。- 不想补:自己记
next_due,迟到就reset()或reset_at(..);要立即执行:先干活再next()。