Rust in Everything
基于 crossbeam-channel 0.5.17

bounded(0) 的 send 一直不返回

主线位置:一条消息走 zero flavor 的那条岔路。机制在 zero:会合 channel。

flowchart TB
  ts["try_send"] --> lk["拿锁"]
  lk --> q{"receivers 里有正在等的接收方?"}
  q -->|有| pair["配对:写进对方的 Packet,Ok"]
  q -->|没有,已断开| dis["Disconnected"]
  q -->|没有| full["Full:没有缓冲可放"]
  snd["send"] --> reg["消息放在自己栈上的 Packet,挂进 senders,park"]
  reg --> woke["接收方进入 recv 时选中它、读走消息"]

现象

想要「容量尽量小」的 channel,写了 bounded(0);或者在同一个线程里先 send 再 recv:

let (s, r) = crossbeam_channel::bounded(0);
s.send(1).unwrap(); // 永远不返回
let v = r.recv().unwrap();

send 卡住,没有报错。换成 try_send,又总是 Full,哪怕另一个线程里明明有接收端。

实测

exp-crossbeam/pitfalls/src/bin/zero_capacity.rs(crossbeam-channel 0.5.17,release,跑 3 次输出一致,耗时一列每次差几毫秒):

try_send, no receiver:         Err("Full(..)")
send_timeout(100ms), no recv:  Err("SendTimeoutError(..)") after 110.019167ms
len=0 capacity=Some(0) is_full=true
try_send, receiver alive but not waiting: Err("Full(..)")
send(4) returned after 52.292ms; receiver got Ok(4)
try_send, receiver parked in recv: Ok(()); receiver got Ok(5)
  • 没有接收方:try_send 是 Full,send_timeout 超时(这里 100 ms 的超时 110 ms 才返回,见 超时了还成功)。send 就是没有超时的这一种,会一直等下去。
  • 第四行:另一个线程持有接收端,但 50 ms 之后才调 recv。这时 try_send 仍是 Full:「有接收端」不等于「有接收方在等」。
  • 第五行:阻塞的 send 等到对方进入 recv 才返回,用了 52 ms。
  • 第六行:接收方已经停在 recv 里,try_send 才成功。
  • is_full() 永远是 true,len() 永远是 0。

从哪来

zero 的 try_send:

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();
    }
    Ok(())
} else if inner.is_disconnected {
    Err(TrySendError::Disconnected(msg))
} else {
    Err(TrySendError::Full(msg))
}

只有 receivers 等待队列里有一个别的线程的、还在等的接收方,才能配对。没有缓冲可以先放进去,所以「先发后收」在单线程里是死锁,在多线程里是「发送方等接收方」。is_full 直接返回 true。阻塞的 send 把消息放在自己栈上的 Packet 里挂进 senders,然后 park,被接收方选中、读完以后才返回(zero)。

std 的 sync_channel(0) 是同一份代码的移植,行为相同,文档也写明它是会合 channel。

代价:慢约 50 倍

每条消息都要一方睡下、另一方叫醒,没有缓冲把两边错开。实验 E6:1P1C 下 bounded(0) 约 0.72 Mmsg/s,bounded(1024) 约 35.2,慢约 50 倍;4 个发送方时降到 0.30(zero)。

怎么改

  • 要的是「容量很小的队列」:用 bounded(1)。它有一个槽,send 在没人收时也能先放进去。
  • 要的是「发送方必须等到对方接手」(握手、交接所有权、背压到底):bounded(0) 正是为此设计的,但发送和接收必须在不同线程,且不要在持有对方需要的锁时 send。
  • 要在没有接收方时不阻塞:try_send 并处理 Full;但要知道 zero 上 Full 的意思是「此刻没有接收方停在 recv 里」,而不是「缓冲满了」。
  • 在 select! 里用 zero 的发送分支是可以的:它注册一个堆上的 Packet,等任意一个接收方来配对(zero)。

小结

  • bounded(0) 没有缓冲,send 要等接收方正在 recv;同一线程里先发后收会永远阻塞。
  • try_send 只在有接收方已经在等时成功,否则 Full;is_full() 永远为真。
  • 本机 1P1C 吞吐比 bounded(1024) 低约 50 倍。要小容量就用 bounded(1)。

💬 关联讨论

发起讨论
加载中...