Rust in Everything
基于 crossbeam-channel 0.5.17

at、tick、never

主线位置:主线 1 的旁支。这三种 flavor 没有发送端、不存消息,消息是接收时按当前时间「造」出来的;它们接入 select 时,用 SelectHandle::deadline() 告诉 run_select 什么时候该醒。

flowchart TB
  tk["tick(d):delivery_time = now + d"] --> path{怎么收}
  path -->|"recv() / for _ in rx"| rv["CAS delivery_time → max(delivery_time, now) + d"]
  rv --> early{"now < delivery_time?"}
  early -->|是| sl["sleep 到 delivery_time,返回它:不漂移"]
  early -->|否| late["立即返回旧的 delivery_time:时间戳陈旧"]
  path -->|"select! 多分支 / try_recv"| tr["now >= delivery_time 才成功"]
  tr --> cas2["CAS delivery_time → now + d:每次晚到都累积"]

三个文件加起来不到五百行:at.rs 197 行,tick.rs 167 行,never.rs 110 行。构造函数见 API 与 flavor:after(d) 和 at(t) 都是 at flavor,tick(d) 是 tick,never() 是 never。at / tick 包在 Arc 里,克隆接收端共享同一个状态;never 是零大小的。

at:一个时间点,一条消息

pub(crate) struct Channel {
    delivery_time: Instant,
    received: AtomicBool,
}

try_recv:时间没到返回 Empty;到了就 received.swap(true, SeqCst),抢到的那个接收者拿到消息(值就是 delivery_time,不是收到的时刻),其他人一律 Empty。前面先用 Relaxed 读一次 received 做快速判断,注释说这只是「optional optimistic check」。

recv 用 thread::sleep 睡到投递时间(或调用方的截止时间,取早的),然后同样 swap。消息已经被别人拿走时,0.5.17 的行为是「睡到调用方的截止时间再返回 Timeout」;没有截止时间就永远睡下去(utils::sleep_until(None) 每次睡 1000 秒,循环)。

0.5.17 修的「已收到后行为不一致」(d883f812,CHANGELOG #1296)就在这个分支:recv 的快速判断看到 received 已为真时,会 sleep_until(deadline) 后返回 Timeout;而慢路径里 swap 发现已被拿走时,以前是 sleep_until(None) 加 unreachable!(),即无视调用方的截止时间、永远阻塞。现在两条路径一致,recv_timeout 在 at channel 已被消费后会按时返回 Timeout。

at 不在乎有没有接收者在等,消息不会「过期」:投递时间过了很久才来收,照样能收到,拿到的仍是原定的投递时间。

never:永不就绪

never 的 try_recv 永远 Empty,recv 睡到截止时间返回 Timeout,SelectHandle 的 try_select 永远 false、deadline 是 None、register 不挂任何东西(never.rs:74)。它的用处在 select! 里做一个「可选分支」:

let timeout = match deadline {
    Some(d) => crossbeam_channel::at(d),
    None => crossbeam_channel::never(),
};
select! {
    recv(rx) -> msg => { /* ... */ }
    recv(timeout) -> _ => { /* 超时 */ }
}

select! 宏自己也用它:handle 数组先用 &never::<()>() 填满,再逐个覆盖(见 select)。0.5.16 起 never() 是 const fn。

tick:一个会自己往前挪的时间点

pub(crate) struct Channel {
    /// The instant at which the next message will be delivered.
    delivery_time: AtomicCell<Instant>,

    /// The time interval in which messages get delivered.
    duration: Duration,
}

delivery_time 是 crossbeam-utils 的 AtomicCell<Instant>。macOS 上 Instant 是 16 字节,AtomicCell 找不到同样大小的原生原子类型,退回全局 seqlock(AtomicCell 的实验 E2:AtomicCell::<Instant>::is_lock_free() 为 false)。每次 tick 的 load / compare_exchange 都要过一把锁,不过一个 tick 只有几次操作,这点开销看不出来。

两条读路径,规则不一样。

try_recv(也是 SelectHandle::try_select 用的路径):

loop {
    let now = Instant::now();
    let delivery_time = self.delivery_time.load();

    if now < delivery_time {
        return Err(TryRecvError::Empty);
    }

    if self
        .delivery_time
        .compare_exchange(delivery_time, now + self.duration)
        .is_ok()
    {
        return Ok(delivery_time);
    }
}

recv:

loop {
    let delivery_time = self.delivery_time.load();
    let now = Instant::now();

    if let Some(d) = deadline {
        if d < delivery_time {
            if now < d {
                thread::sleep(d - now);
            }
            return Err(RecvTimeoutError::Timeout);
        }
    }

    if self
        .delivery_time
        .compare_exchange(delivery_time, delivery_time.max(now) + self.duration)
        .is_ok()
    {
        if now < delivery_time {
            thread::sleep(delivery_time - now);
        }
        return Ok(delivery_time);
    }
}

差别在下一次投递时间怎么算:

路径下一次投递时间消费者跟得上时消费者慢时
recv(含 for _ in rx、单分支 select!)max(delivery_time, now) + dnow < delivery_time,取 delivery_time + d:严格按节拍,不漂移取 now + d:不补发,从现在重新计
try_recv、多分支 select!now + dnow 总比 delivery_time 晚一点(醒来的延迟),每次都把这点延迟加进去:漂移同左

两条路径都不补发:错过多少个节拍都只给一条消息,所以慢消费者不会积压。返回的值永远是旧的 delivery_time,也就是「这条消息本该投递的时刻」,不是收到的时刻;消费者慢的时候,这个时间戳比当前时间早。

recv 是先 CAS 把下一次时间定好、再睡,所以多个线程共用一个 tick 接收端时,每个节拍只有一个线程拿到(CAS 只成功一次),其他线程会拿到后面的节拍。

实验 E11

tick(10ms),接收方每次收到后忙等 W 毫秒(不用 sleep,让线程一直在跑),收 50 次,统计返回时间戳的平均间隔、收到时刻的平均间隔、平均陈旧度(收到时刻减时间戳),以及第 50 条的时间戳比理想时刻 start + 50 × 10ms 晚多少。select! 版本放了第二个分支 never():只有一个 recv 分支的 select! 会被宏展开成 rx.recv()(select),测不到 select 路径。程序在 exp-crossbeam/tick-semantics/,release,跑 3 次取中位数:

路径每次处理时间戳平均间隔收到时刻平均间隔平均陈旧度第 50 条的漂移
tick.recv()3 ms10.00 ms10.01 ms1.34 ms0.00 ms
tick.recv()25 ms24.75 ms25.00 ms14.80 ms722.5 ms
select! { tick, never }3 ms11.55 ms11.56 ms1.55 ms75.9 ms
select! { tick, never }25 ms24.75 ms25.00 ms14.75 ms722.6 ms

三次运行前的 1 分钟负载 2.8–3.0;三次之间每格的差别不超过 0.15 ms(漂移一列 select! 3 ms 那格是 74.7 / 75.9 / 82.6 ms)。

  • recv、处理 3 ms:时间戳间隔正好 10.00 ms,50 次后漂移为 0。按节拍走,不累积。
  • recv、处理 25 ms:每 25 ms 收到一条,没有补发;时间戳比收到时刻早约 15 ms(now + 10ms 定下的投递时间,到下次来收时已经过了 15 ms)。50 次后比理想节拍晚约 0.72 秒,相当于约 72 个节拍被跳过,这正是「不补发」的代价。
  • 多分支 select!、处理 3 ms:消费者明明跟得上,间隔却是 11.55 ms,50 次漂了约 76 ms。原因是 select 路径用 try_recv 的规则 now + d:线程 park_timeout 到投递时间,醒来时已经晚了一点(本机测得每次约 1.5 ms),这点延迟每个节拍都被加进下一次投递时间。
  • 多分支 select!、处理 25 ms:和 recv 一样,因为两条路径在「已经晚了」时规则相同。

本机醒来的延迟不小(recv 路径在跟得上时陈旧度也有约 1.3 ms,这是 thread::sleep 睡过头的量;在负载更高的时段试跑时是 2.5 ms,select! 那格的漂移随之变成 167 ms)。为什么能睡过头一毫秒以上,本页没有查,推测和 macOS 的定时器合并有关。漂移的机制和平台无关,漂多少取决于平台的唤醒延迟。

想要严格节拍、又要放进 select!,可以自己维护下一次的目标时刻,用 at(next) 做分支,每次触发后 next += d。

以 tantivy 的 warmer GC 为例

tantivy 0.27.0 的 warmer 起一个 GC 线程(tantivy 卷 warmer 线程泄漏):

for _ in crossbeam_channel::tick(GC_INTERVAL) {
    // upgrade Weak,失败就跳过;成功就 gc_maybe
}

GC_INTERVAL 是 1 秒。for 走的是 Receiver::iter(),即 recv 路径:GC 一轮远小于 1 秒,所以严格按每秒一次的节拍醒,不漂移;某一轮 GC 超过 1 秒的话,下一次从 GC 结束时刻起再等 1 秒,中间错过的节拍不补。tick 接收端永远不会断开(没有发送端可以 drop),recv 永远不返回 Err,所以这个 for 没有自然结束的一天,这就是那一页讲的线程泄漏的来源。

小结

  • at / tick / never 只有接收端,消息按时间现造;接入 select 时靠 deadline() 让 run_select 按时醒来。
  • at:投递时间到了以后只有一个接收者能拿到(received.swap),值是原定投递时间;0.5.17 修正了已被拿走时 recv 无视截止时间、永远阻塞的问题。
  • never:永不就绪,select! 里当可选分支,宏内部也用它填充 handle 数组。
  • tick:delivery_time 是 AtomicCell<Instant>,macOS 上走全局锁;recv 路径下一次是 max(delivery_time, now) + d,跟得上时不漂移;try_recv 和多分支 select! 用 now + d,每次唤醒延迟都会累积成漂移。
  • 两条路径都不补发错过的节拍,慢消费者拿到的时间戳是陈旧的(E11:处理 25 ms 时陈旧约 15 ms)。

💬 关联讨论

发起讨论
加载中...