Rust in Everything
基于 crossbeam-channel 0.5.17

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>,
}

(Packet、Inner、Channel。)

和 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)
1P1C0.72 Mmsg/s0.7335.2
4P1C0.300.3015.9
4P4C0.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 倍。

💬 关联讨论

发起讨论
加载中...