Rust in Everything
基于 crossbeam-channel 0.5.17

select! 没有按书写顺序选

主线位置:一条消息的「多选一」版本,run_select 的第一步洗牌。机制在 select。

flowchart TB
  sel["select! / Select::new"] --> sh["线程本地 xorshift 洗牌(固定种子)"]
  sh --> tr["按洗牌后的顺序 try_select"]
  bsel["select_biased! / Select::new_biased"] --> ord["按书写顺序 try_select"]
  tr --> fair["多个就绪时各分支机会均等"]
  ord --> prio["前面的分支一直就绪,后面的被饿死"]

现象

把控制消息的分支写在前面,以为它就绪时会先被处理:

loop {
  select! {
    recv(ctrl) -> msg => handle_ctrl(msg),
    recv(data) -> msg => handle_data(msg),
  }
}

两条 channel 都有消息时,ctrl 并不优先,大约一半的时候先处理的是 data。tokio 的 select! 默认同样随机、要写 biased; 才按顺序,两边的直觉是一致的;容易踩的是以为「写在前面就先查」。

实测

实验 E7(exp-crossbeam/select-fairness/,release):a、b 预先各放 10 万条,始终就绪;c 只在每 10 次里就绪 1 次。选 10 万次:

select! (a, b, c)                  a= 48554 b= 48129 c=  3317
select_biased! (a, b, c)           a=100000 b=     0 c=     0
select_biased! (c, a, b)           a= 90000 b=     0 c= 10000
Select::new (a, b, c)              a= 48312 b= 48239 c=  3449
Select::new_biased (a, b, c)       a=100000 b=     0 c=     0

两分支的版本:select! 50228 / 49772,select_biased! 100000 / 0。连续跑两次,输出逐位相同。

从哪来

run_select 非偏置时先把所有分支洗一遍再逐个 try_select,谁排第一谁赢:

if !is_biased {
    // Shuffle the operations for fairness.
    utils::shuffle(handles);
}

select! 和 select_biased! 展开成同一个内部宏,区别只是 const _IS_BIASED: bool 是 false 还是 true(select)。shuffle 的随机数来自线程本地 xorshift,种子固定为 1_406_868_647:同一个线程、同样的调用序列,每次运行洗出来的顺序都一样。所以「随机」在测试里不会表现为不稳定,测试写成依赖某个顺序也能一直通过,换个调用序列才露馅。

例外:只有一个 recv 分支的 select! 展开成 r.recv(),不经过 run_select,也就谈不上顺序。

怎么改

  • 要优先级:select_biased!(0.5.13,#1040)或 Select::new_biased()(0.5.14,#1150),按书写顺序尝试。代价是写在前面的分支只要一直就绪,后面的分支一次也轮不到(上面 select_biased! (a, b, c) 那一行,b 和 c 都是 0)。优先级分支应当是「偶尔才有」的那种,比如控制消息、关闭信号。
  • 要优先级、又不想饿死:每次 select! 之前先 try_recv 高优先级的 channel,有就处理,没有再 select!。高优先级分支一直有消息时同样会饿死低优先级,这是优先级本身的含义。
  • 要公平:用默认的 select! 就是公平的;不要依赖分支顺序。
  • 本站锁的 crossbeam-channel 0.5.15 已经有 select_biased! 和 Select::new_biased(两者所在的 0.5.13、0.5.14 被 yank,0.5.15 重新带出来,见 list)。本站代码和它的依赖都没有用 select! / Select。

小结

  • select! / Select::new 多分支同时就绪时随机选,靠一次 Fisher–Yates 洗牌;种子固定,所以运行间可复现。
  • 优先级用 select_biased! / Select::new_biased,前面的分支一直就绪时后面的会被饿死。
  • E7:随机版三分支分布与期望一致,偏置版严格按书写顺序。

💬 关联讨论

发起讨论
加载中...