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) + d | now < delivery_time,取 delivery_time + d:严格按节拍,不漂移 | 取 now + d:不补发,从现在重新计 |
try_recv、多分支 select! | now + d | now 总比 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 ms | 10.00 ms | 10.01 ms | 1.34 ms | 0.00 ms |
tick.recv() | 25 ms | 24.75 ms | 25.00 ms | 14.80 ms | 722.5 ms |
select! { tick, never } | 3 ms | 11.55 ms | 11.56 ms | 1.55 ms | 75.9 ms |
select! { tick, never } | 25 ms | 24.75 ms | 25.00 ms | 14.75 ms | 722.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)。