Rust in Everything
基于 crossbeam-channel 0.5.17

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 ms10.00 ms1.34 ms0.00 ms
tick.recv()25 ms24.75 ms14.80 ms722.5 ms
select! { tick, never }3 ms11.55 ms1.55 ms75.9 ms
select! { tick, never }25 ms24.75 ms14.75 ms722.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 ms
    

    at(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) 自己推进目标时刻。

💬 关联讨论

发起讨论
加载中...