Rust in Everything
基于 crossbeam-channel 0.5.17

接收端都走了,有界 channel 里的消息还没析构

主线位置:一条消息的终点之一,Receiver 的 Drop。机制在 array 和 API 与 flavor 里讲过,这里只讲怎么踩到、怎么避开。

flowchart TB
  rxd["最后一个 Receiver drop"] --> fl{flavor}
  fl -->|list| dr["disconnect_receivers:置断开位并 discard_all_messages"]
  fl -->|array| dc["disconnect:只置 mark 位、叫醒等待者"]
  dr --> gone["消息立即析构"]
  dc --> wait["消息留在缓冲里"]
  wait --> txd["最后一个 Sender drop:Counter 释放"]
  txd --> chd["Drop for Channel:析构剩余消息"]

现象

消息里攥着资源(文件句柄、连接、大块内存),消费者线程退出、接收端 drop 了,资源却没有释放;等到持有 Sender 的那个对象也 drop,才一起释放。没有报错,也没有 panic。

最小复现(exp-crossbeam/pitfalls/src/bin/rx_drop.rs,crossbeam-channel 0.5.17):一个带析构计数的 Conn,发 10 个,drop 接收端,看还活着几个;再试一次 send,最后 drop 发送端:

fn run(label: &str, s: Sender<Conn>, r: Receiver<Conn>) {
  for i in 0..10 {
    s.send(Conn::open(i)).unwrap();
  }
  drop(r);
  let after_rx = LIVE.load(Ordering::SeqCst);
  let send_err = s.send(Conn::open(99)).is_err();
  let after_send = LIVE.load(Ordering::SeqCst);
  drop(s);
  // 打印 after_rx、send_err、after_send、drop(s) 之后的 LIVE
}
crossbeam bounded(16)      live after rx drop: 10, send after rx drop is_err=true, live: 10, after tx drop: 0
crossbeam unbounded        live after rx drop:  0, send after rx drop is_err=true, live:  0, after tx drop: 0
bounded(16) + try_iter     drained 10, live after rx drop: 0

(release,跑 3 次输出相同。)有界的 10 个 Conn 在接收端 drop 后一个都没析构,发送端 drop 后才归零;无界的立即归零。断开以后的 send 两边都返回 Err,带着消息还给调用方,那条消息在调用方手里析构,不会进缓冲。

实验 E8 把同样的程序对 0.5.15(本站 Cargo.lock 锁的版本)和 std 各跑了一遍(array):0.5.15 的有界 channel 同样是 0 → 10,std 的 sync_channel(16) 和 channel() 都是接收端一走就析构。

从哪来

Receiver 的 Drop 按 flavor 选断开函数:

ReceiverFlavor::Array(chan) => chan.release(|c| c.disconnect()),
ReceiverFlavor::List(chan) => chan.release(|c| c.disconnect_receivers()),
ReceiverFlavor::Zero(chan) => chan.release(|c| c.disconnect()),

list 的 disconnect_receivers 置上断开位后接着 discard_all_messages,把缓冲里的消息全部析构。array 只有一个 disconnect,只置 tail 的 mark 位、叫醒等待者,不碰缓冲。缓冲里的消息由 Drop for Channel 析构,而它只在 Counter 被释放时运行,即发送端和接收端都走光以后(destroy.swap(true) 由后到的一方执行,见 API 与 flavor)。

zero 没有缓冲,不受影响:等待中的发送方被叫醒后从自己栈上的 Packet 取回消息(zero)。

什么时候会疼

同时满足三条才有实际影响:

  1. 用的是 bounded(n),n > 0;
  2. 接收端会先于发送端走光,而发送端活得很久:池子、全局单例、一个长期存活的后台对象手里的 Sender;
  3. 缓冲里的消息持有的资源有意义:文件句柄、socket、Arc 的强引用(会连带让别的东西活着)、大块内存。

缓冲最多 n 条,所以泄漏量有上界;但如果消费者因为出错退出,而生产者那边没有注意到 send 已经返回 Err,这 n 条会一直留到生产者被销毁。本站唯一会碰到这条的是 tantivy 的 IndexWriter,评估见 tantivy IndexWriter 的有界 channel。

怎么改

  • 消费者退出前自己排空:r.try_iter().count()(或 for _ in r.try_iter() {})再 drop。上面第三行就是这样,接收端 drop 时已经归零。注意它只排空「此刻」缓冲里的;排空和 drop 之间别的线程又发进来的,仍然要等。
  • 生产者把 send 的 Err 当信号:send / try_send 返回 Disconnected 后,持有 Sender 的一方就 drop 它(或整个对象)。最后一个 Sender 一走,Counter 就释放,缓冲随之析构。
  • 换 flavor:资源型消息、又不需要背压时用 unbounded();需要背压,又是单消费者,std::sync::mpsc::sync_channel 的行为是立即析构。
  • 升级:见下一节。

升级后行为会变

main 上的 #1121(7b573a03,2026-06-08 合入,提交说明写明移植自 rust-lang/rust#108164)给 array 加了 disconnect_senders / disconnect_receivers,Receiver 的 Drop 改调后者,接收端走光时丢弃缓冲消息。它不在 0.5.17 里(0.5.17 的发布提交 2920c984 在 v0.8 分支上,没有挑这个提交,见 怎么读这一卷)。

升级到包含 #1121 的版本以后,上面第一行会变成和 unbounded 一样的 0:消息在最后一个接收端 drop 的那一刻、在 drop 它的那个线程上析构,而不是在最后一个发送端 drop 时、在发送端的线程上析构。公开 API 没有办法在接收端走光后再取回缓冲里的消息,所以这个变化不会让正确的代码出错;会受影响的是依赖析构时机或析构线程的代码,例如消息的 Drop 里做阻塞操作、要求在某个特定线程上运行,或者测试里断言了「接收端 drop 后计数不变」。本卷写作时 #1121 还没有发布,以 CHANGELOG 为准。

小结

  • 0.5.17 和 0.5.15 的 bounded(n):最后一个接收端 drop 后,缓冲消息要等发送端也 drop 才析构;unbounded、zero、std 的两种 channel 都不这样。
  • 原因是 Receiver::drop 对 array 调的是通用 disconnect,没有丢弃消息的那一步。
  • 消费者退出前 try_iter 排空,或者生产者在 send 返回 Err 后放掉 Sender。
  • main 的 #1121 已改、未发布;升级后析构会提前到接收端 drop 时,发生在接收端的线程上。

💬 关联讨论

发起讨论
加载中...