Rust in Everything
基于 crossbeam-channel 0.5.17

dropped SelectedOperation without completing the operation

主线位置:「多选一」的最后一步,Select 把预订好的操作交给调用方去完成。机制在 select。

flowchart TB
  s["Select::select()"] --> pre["已在选中的 channel 上预订(start_recv / start_send)"]
  pre --> op["返回 SelectedOperation"]
  op -->|"oper.recv / oper.send"| done["完成操作,mem::forget(self)"]
  op -->|"直接 drop"| pan["Drop:panic"]
  op -->|"mem::forget"| hang["留下已预订未写入的槽"]

现象

用运行时构造的 Select,拿到选中的分支后只看了 index(),忘了调 recv:

let (s, r) = unbounded::<i32>();
s.send(1).unwrap();
let mut sel = Select::new();
let i = sel.recv(&r);
let oper = sel.select();
println!("selected index {} (recv index {})", oper.index(), i);
drop(oper); // 应该是 oper.recv(&r)

实测(exp-crossbeam/pitfalls/src/bin/selected_dropped.rs,crossbeam-channel 0.5.17,退出码 101,本机路径已去掉):

selected index 0 (recv index 0)

thread 'main' (4035757) panicked at .../crossbeam-channel-0.5.17/src/select.rs:1297:9:
dropped `SelectedOperation` without completing the operation
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

带参数 complete 运行同一个程序(调了 oper.recv(&r))则正常:recv -> Ok(1)、still running; len = 0。

常见的变体:match oper.index() 里某个分支只是 continue 或 return;在分支里先做了一件可能 ? 提前返回的事,再调 oper.recv;或者把 oper 存起来、晚些再处理,中途 drop 了。

从哪来

Select::select() 返回之前已经在选中的 channel 上做了「预订」(flavor 的 start_recv / start_send,结果记在 Token 里);剩下的「把消息读出来 / 写进去」由调用方通过 SelectedOperation::send / recv 完成(预订和完成为什么要分两步,见 API 与 flavor)。这两个方法完成后 mem::forget(self) 跳过 Drop;走到 Drop 就说明没完成:

impl Drop for SelectedOperation<'_> {
    fn drop(&mut self) {
        panic!("dropped `SelectedOperation` without completing the operation");
    }
}

这个 panic 是为了不让「已预订、未完成」的状态悄悄留下。

另外两个相关的断言在 recv / send 里:传进去的 channel 必须是预订时的那一个(「passed a receiver that wasn't selected」),否则同样 panic。

mem::forget 不是出路

mem::forget 是安全函数,可以绕过这个 panic,但绕不过问题本身:会留下一个已预订、未写入的槽。0.5.17 的 #1295 修的就是由此引起的一个 UB:list(无界)channel 的 Drop 以前会对这样的槽调析构,读的是一块未初始化的内存;修复后 Drop 只析构带 WRITE 位的槽(list)。所以在 0.5.17 之前的版本上,forget 一个预订了无界 channel 发送操作的 SelectedOperation,是安全代码能触发的 UB。

不管哪个版本,都不要用 forget 处理 SelectedOperation:把操作完成,或者改用不预订的接口。

怎么改

  • 每个 index() 分支都完成操作:先 let msg = oper.recv(&r);,再做可能提前返回的事。select! 宏会替你做这一步:它展开后在每个分支体之前先调 recv / send(select),所以用宏不会踩到这条。
  • 只想知道「谁就绪了」:用 Select::ready() / try_ready() / ready_timeout()。它们不预订任何东西,只返回一个就绪分支的下标,之后自己 try_recv,要处理被别的线程抢先的情况(select)。
  • 分支数量编译期已知:直接用 select!。

在本站

本站锁的 crossbeam-channel 是 0.5.15,在 #1295 之前。在本站仓库里搜 Select、select!、crossbeam,本站自己的代码一处都没有;本站依赖里用到 crossbeam-channel 的 tantivy、tao、wry、muda、tray-icon、global-hotkey 也都没有用 Select 或 select!(10 在本站中)。所以这条在本站没有触发路径。

小结

  • SelectedOperation 代表一个已经预订、还没完成的操作;不调 recv / send 就 drop 会 panic,原文是「dropped SelectedOperation without completing the operation」。
  • mem::forget 能绕过 panic,但会留下一个已预订未写入的槽;0.5.17 修了 list 的 Drop 因此析构未初始化内存的 UB。
  • 用 select! 宏,或只要就绪信息时用 Select::ready()。
  • 本站代码和依赖都不用 Select。

💬 关联讨论

发起讨论
加载中...