接收端都走了,有界 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)。
什么时候会疼
同时满足三条才有实际影响:
- 用的是
bounded(n),n > 0; - 接收端会先于发送端走光,而发送端活得很久:池子、全局单例、一个长期存活的后台对象手里的
Sender; - 缓冲里的消息持有的资源有意义:文件句柄、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 时,发生在接收端的线程上。