tick 的间隔越来越长
主线位置:主线 1 的旁支,按时间造消息的 tick flavor。机制和实验 E11 的完整表格在 at、tick、never。
flowchart TB
tk["tick(d)"] --> how{怎么收}
how -->|"recv / for / 单 recv 分支的 select!"| r1["下一次 = max(delivery_time, now) + d"]
how -->|"try_recv / 多分支 select!"| r2["下一次 = now + d"]
r1 --> nd["跟得上时严格按节拍"]
r2 --> dr["每次唤醒延迟都累积:漂移"]
fix["改法:at(next),触发后 next += d"] --> nd
现象
一个每 10 ms 干一次活的循环,放进 select! 里和别的 channel 一起等:
let ticker = crossbeam_channel::tick(Duration::from_millis(10));
loop {
select! {
recv(ticker) -> _ => do_periodic_work(),
recv(cmds) -> cmd => handle(cmd),
}
}
每次的活只要 3 ms,消费者明明跟得上,跑一阵子却发现节拍越来越落后:本机 50 个节拍后晚了约 76 ms。同样的循环写成 for _ in ticker.iter() 就不漂。
规划时以为「tick 会漂移」是普遍的,实测要分路径:recv 路径跟得上时不漂,漂的是 try_recv 和多分支 select!。
实测
实验 E11(exp-crossbeam/tick-semantics/,tick(10ms),接收方每次忙等 3 ms 或 25 ms,收 50 次,3 次取中位数):
| 路径 | 每次处理 | 时间戳平均间隔 | 平均陈旧度 | 第 50 条的漂移 |
|---|---|---|---|---|
tick.recv() | 3 ms | 10.00 ms | 1.34 ms | 0.00 ms |
tick.recv() | 25 ms | 24.75 ms | 14.80 ms | 722.5 ms |
select! { tick, never } | 3 ms | 11.55 ms | 1.55 ms | 75.9 ms |
select! { tick, never } | 25 ms | 24.75 ms | 14.75 ms | 722.6 ms |
(timers 有收到时刻的间隔和测量负载。)「陈旧度」是收到时刻减去消息里的时间戳。
从哪来
try_recv(也是 select! 走的 SelectHandle::try_select)成功时把下一次投递时间设成 now + duration;recv 设成 delivery_time.max(now) + duration:
| 路径 | 下一次 | 跟得上时 | 慢的时候 |
|---|---|---|---|
recv、for _ in rx、只有一个 recv 分支的 select! | max(delivery_time, now) + d | 取 delivery_time + d,严格按节拍 | 取 now + d,不补发 |
try_recv、多分支 select! | now + d | 每次醒来晚一点(本机约 1.5 ms),这一点被加进下一次:漂 | 同左 |
select! 多分支时,run_select 按 tick 的 deadline() 去 park_timeout,醒来后用 try_select 收,所以走的是 now + d。只有一个 recv 分支的 select! 被宏展开成 rx.recv()(select),反而不漂;加一个分支,行为就变了,这也是这条坑容易在重构时冒出来的原因。
两条路径的共同点:错过多少个节拍都只给一条,不积压;返回的值是「本该投递的时刻」,消费者慢的时候它比现在早,处理 25 ms 时早约 15 ms。
怎么改
单独一个定时循环:用
for _ in tick(d)或recv(),跟得上时不漂。tantivy 的 warmer GC 线程就是这么写的(timers)。要放进
select!又要严格节拍:自己维护目标时刻,用at(next)做分支,触发后next += d(落后太多时可以跳到now之后的下一个整拍):let mut next = Instant::now() + d; loop { select! { recv(at(next)) -> _ => { do_periodic_work(); next += d; } recv(cmds) -> cmd => handle(cmd), } }每轮新建一个
at接收端是一次Arc分配;节拍很密时可以只在触发后重建。对照(
exp-crossbeam/pitfalls/src/bin/tick_at.rs,同样 10 ms 节拍、每拍忙等 3 ms、50 拍,第二个分支是一个永不就绪的cmds,量的是第 50 拍收到的时刻比start + 500 ms晚多少;开跑时 1 分钟负载 2.4–2.7,比 E11 那次高):select!{tick, cmds} 50th beat received late by 170.1 ms select!{at(next), cmds} 50th beat received late by 2.3 ms select!{tick, cmds} 50th beat received late by 172.3 ms select!{at(next), cmds} 50th beat received late by 2.4 ms select!{tick, cmds} 50th beat received late by 153.8 ms select!{at(next), cmds} 50th beat received late by 0.9 msat(next)版本的误差只是最后一拍本身的唤醒延迟,不累积;tick 版本在这次负载下漂了 150 多毫秒。要知道真实时刻:别用 tick 消息里的时间戳当「现在」,自己调
Instant::now()。漂移量和平台有关:机制与平台无关,漂多少取决于唤醒延迟;本机在负载更高时
select!那格从 76 ms 变成 167 ms(timers)。
小结
recv路径:max(delivery_time, now) + d,跟得上不漂,慢了不补发。try_recv与多分支select!:now + d,每次唤醒延迟都累积,E11 里 50 拍漂约 76 ms。- 单
recv分支的select!展开成recv(),加分支后行为改变。 - 严格节拍放进
select!:用at(next)自己推进目标时刻。