zero:会合 channel
主线位置:一条消息的「没有缓冲」变体。
bounded(0)的send必须等到一个recv同时出现,消息直接从发送方的栈搬到接收方手里;等的那一方同样交给 Context 与 Waker 去park。
sequenceDiagram participant S as 发送方 participant M as Mutex Inner participant R as 接收方 S->>M: lock,receivers 里没人 S->>M: 栈上建 Packet(msg),register_with_packet 进 senders S->>S: unlock,wait_until:park R->>M: lock,senders.try_select() 选中 S M-->>S: CAS select = Operation,unpark R->>R: unlock,从 S 的 Packet 取出 msg,ready = true S->>S: 醒来,wait_ready 看到 ready,返回 Ok
数据结构
struct Packet<T> {
on_stack: bool,
ready: AtomicBool,
msg: UnsafeCell<Option<T>>,
}
struct Inner {
senders: Waker,
receivers: Waker,
is_disconnected: bool,
}
pub(crate) struct Channel<T> {
inner: Mutex<Inner>,
_marker: PhantomData<T>,
}
和 array、list 不同,zero 整个是有锁的:两个等待队列加一个断开标志,都在一把 Mutex 里。这里没有可以无锁操作的缓冲区,每次收发都要和对面的等待者配对,配对本身就要同时看两个队列,一把锁最简单。这把 Mutex 是 crate::utils::Mutex,忽略 poison(c0c466e5「channel: Use non-poison Mutex」把 zero 和 SyncWaker 从 std Mutex 的 .lock().unwrap() 改了过来,随 0.5.16 发布,CHANGELOG 没有列这一条),std 的移植版仍是 .lock().unwrap()。
Waker 在这里不是 SyncWaker:外面已经有锁了,不用再包一层。Waker 的每个条目(Entry)带一个 packet: *mut () 字段,就是给 zero 用的:等待者把自己的 Packet 地址挂在条目上,配对的人直接去读写它。
先到的人挂包,后到的人取包
send:
let token = &mut Token::default();
let mut inner = self.inner.lock();
// If there's a waiting receiver, pair up with it.
if let Some(operation) = inner.receivers.try_select() {
token.zero.0 = operation.packet;
drop(inner);
unsafe {
self.write(token, msg).ok().unwrap();
}
return Ok(());
}
if inner.is_disconnected {
return Err(SendTimeoutError::Disconnected(msg));
}
Context::with(|cx| {
// Prepare for blocking until a receiver wakes us up.
let oper = Operation::hook(token);
let mut packet = Packet::<T>::message_on_stack(msg);
inner
.senders
.register_with_packet(oper, &mut packet as *mut Packet<T> as *mut (), cx);
inner.receivers.notify();
drop(inner);
// Block the current thread.
let sel = cx.wait_until(deadline);
match sel {
Selected::Waiting => unreachable!(),
Selected::Aborted => { /* 超时:unregister,取回 msg */ }
Selected::Disconnected => { /* 断开:unregister,取回 msg */ }
Selected::Operation(_) => {
// Wait until the message is read, then drop the packet.
packet.wait_ready();
Ok(())
}
}
})
两种情况:
- 对面已经有人在等:
receivers.try_select()挑一个别的线程的接收者,CAS 它的Context选中它、unpark它,并把它挂的Packet指针拿过来。放锁,write把消息写进那个Packet、ready.store(true, Release)。接收方醒来后wait_ready看到ready,取走消息。 - 对面没人:把消息装进自己栈上的
Packet(message_on_stack),挂进senders,放锁,park。被选中后不能马上返回:接收方可能还在从我的栈上读消息,我一返回栈帧就没了。所以要wait_ready,等接收方读完把ready置真,才算完成。
recv 是镜像:挂一个空的栈上 Packet(empty_on_stack),被选中后 wait_ready 等发送方写好。
Packet 放在栈上,避免了每条消息一次堆分配;代价是双方都要等 ready 这一步握手。wait_ready 用 Backoff::snooze 自旋 / 让出,不 park:对面已经拿到包,写或读只是几条指令,很快就会置 ready。
注意 send 里挂完自己以后调了 inner.receivers.notify():它通知的是 Waker 的 observers,即 Select::ready() 一类「只等就绪、不完成操作」的观察者(见 select),不是普通的阻塞接收者。
select 时 Packet 在堆上
select! 里一个 zero 接收分支,注册时还不知道最后会不会选中它,函数也不会停在那里等:run_select 注册完所有分支就去 wait_until,醒来再挑一个。没有一个能活到配对完成的栈帧,所以 SelectHandle::register 改用堆上的包:
fn register(&self, oper: Operation, cx: &Context) -> bool {
let packet = Box::into_raw(Packet::<T>::empty_on_heap());
let mut inner = self.0.inner.lock();
inner
.receivers
.register_with_packet(oper, packet.cast::<()>(), cx);
inner.senders.notify();
inner.senders.can_select() || inner.is_disconnected
}
被选中后,accept 调 cx.wait_packet() 拿到这个指针(选中我的那个线程会用 store_packet 把它写进我的 Context),read 看到 on_stack == false,等 ready 以后读出消息并 Box::from_raw 释放。没被选中的分支在 unregister 里释放自己的堆包。on_stack 字段就是用来区分这两种生命周期的。
can_select() 是 Waker 里专门给 zero 的方法:对面队列里有没有一个「别的线程的、还处于 Waiting 的」条目。有就说明现在注册就能配对成功,register 返回 true,run_select 会立即去 try_select 它。
std 的移植版没有 select,所以删掉了 empty_on_heap、SelectHandle 实现和 can_select,Packet 只剩栈上一种(见 vs-std)。
两个数量级?实测约 50 倍
实验 E6(exp-crossbeam/channel-throughput/,每轮 20 万条 usize,每个进程 5 次取中位数,整表 3 次取中位数,测量前 1 分钟负载 2.5–3):
| 形状 | crossbeam bounded(0) | std sync_channel(0) | crossbeam bounded(1024) |
|---|---|---|---|
| 1P1C | 0.72 Mmsg/s | 0.73 | 35.2 |
| 4P1C | 0.30 | 0.30 | 15.9 |
| 4P4C | 0.22 | —(std 只有单消费者) | 29.4 |
1P1C 下 zero 比 bounded(1024) 慢约 50 倍,规划时写的「两个数量级」偏多。每条消息约 1.4 微秒,量级上是一次 park / unpark 来回:两边总有一方要睡下、被另一方叫醒,没有缓冲可以把两边错开。crossbeam 和 std 的 zero 在这里没有可见差别。
多个发送方时更慢(0.30),因为所有人挤在同一把锁上,被选中的接收方每次只能配对一个。
断开
disconnect 在锁里把 is_disconnected 置真,然后对两个 Waker 各调一次 disconnect():把所有等待者 CAS 成 Selected::Disconnected 并 unpark。等待的发送方醒来走 Disconnected 分支,从自己栈上的 Packet 取回消息,放进 SendTimeoutError::Disconnected(msg) 还给调用方,消息不会丢。zero 没有缓冲,也就没有 array 那种「接收端走了消息还在」的问题。
小结
bounded(0)是 zero flavor:一把Mutex管senders/receivers两个Waker和一个断开标志,没有任何缓冲。- 先到的一方把消息(或空位)装进自己栈上的
Packet,挂进队列后park;后到的一方选中它、直接读写那个Packet,再置ready。被选中的一方要wait_ready握手后才能让栈帧消失。 select!里注册时用堆上的Packet,accept通过Context::wait_packet拿到指针,on_stack区分谁来释放;std 没有 select,删掉了这一套。- 0.5.16 起 zero 的锁不再传播 poison;std 的版本仍会。
- 本机实测 1P1C 约 0.7 Mmsg/s,crossbeam 与 std 相同,比
bounded(1024)慢约 50 倍。