Rust in Everything
基于 crossbeam-channel 0.5.17

select

主线位置:一条消息的「多选一」版本。select! 把每个分支的 channel 包成 &dyn SelectHandle,交给 run_select;它对每个 flavor 调 start_send / start_recv 的同一套预订接口,阻塞时复用 Context 与 Waker 的一个 Context。

flowchart TB
  start["run_select(handles, timeout, biased)"] --> sh{"biased?"}
  sh -->|否| shuffle["线程本地 xorshift 洗牌"]
  sh -->|是| try1
  shuffle --> try1["逐个 handle.try_select(token)"]
  try1 -->|有一个成功| done["返回 (token, index, ptr)"]
  try1 -->|都不行| reg["Context::with:逐个 register(oper, cx)"]
  reg -->|"某个 register 返回 true"| abort["CAS Aborted,记下 index_ready"]
  reg --> dl["算最早 deadline:timeout 与各 handle.deadline()"]
  dl --> wait["cx.wait_until(deadline)"]
  wait --> unreg["逐个 unregister"]
  abort --> unreg
  unreg -->|"Operation(op)"| acc["找到 op 对应的 handle,accept"]
  unreg -->|"Aborted / Disconnected"| try1
  acc --> done

SelectHandle:8 个方法

每个 flavor 的 Receiver<'_, T> / Sender<'_, T>(flavor 内部的同名包装,不是公开的那个)都实现 SelectHandle:

方法作用array 接收端怎么实现
try_select(&mut Token) -> bool不阻塞地预订start_recv
deadline() -> Option<Instant>这个操作自带的截止时间None;at / tick 返回投递时间
register(oper, cx) -> bool挂进等待队列;返回 true 表示「刚刚就绪了」receivers.register,返回 is_ready()
unregister(oper)从等待队列摘下receivers.unregister
accept(&mut Token, cx) -> bool被选中后完成预订再调一次 start_recv
is_ready() -> bool现在就绪吗!is_empty() || is_disconnected()
watch(oper, cx) -> bool只观察就绪(Select::ready 用)receivers.watch
unwatch(oper)取消观察receivers.unwatch

array 的 Sender 一侧也是这样;list 的 Sender 永远就绪,register 什么也不挂。zero 的 register 挂堆上的 Packet,accept 等对方送来指针(见 zero)。never 永远返回「没就绪」,deadline 是 None。

accept 和 try_select 分开,是因为被唤醒时情况不同:对 array / list,「被选中」只说明「刚才有东西」,要重新 start_recv 去抢;对 zero,「被选中」说明对方已经和我配好对了,accept 只需要取包。

run_select

run_select 的参数是一个切片 &mut [(&dyn SelectHandle, usize, *const u8)]:handle、分支编号、channel 的地址(后面用来校验调用方传回的是不是同一个 channel)。

一、洗牌,然后逐个尝试

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

let mut token = Token::default();

// Try selecting one of the operations without blocking.
for &(handle, i, ptr) in handles.iter() {
    if handle.try_select(&mut token) {
        return Some((token, i, ptr));
    }
}

公平性就靠这一下洗牌:多个分支同时就绪时,谁排在前面谁赢,洗牌让每个分支排第一的概率相同。shuffle 是 Fisher–Yates,随机数来自线程本地的 32 位 xorshift,种子固定为 1_406_868_647,取模用 Lemire 的乘法移位((x as u64 * n as u64) >> 32)代替 %。种子固定意味着同一个线程、同样的调用序列,洗出来的顺序每次运行都一样,下面的 E7 两次运行结果逐位相同。

biased 时跳过洗牌,按分支书写的顺序尝试。

二、注册全部,等最早的那个

都没就绪,就进 Context::with,在同一个 Context 上把每个操作注册一遍。Operation::hook(handle) 用切片元素的地址当操作 id,所以每个分支的 id 不同:

for (handle, i, _) in handles.iter_mut() {
    registered_count += 1;

    // If registration returns `false`, that means the operation has just become ready.
    if handle.register(Operation::hook::<&dyn SelectHandle>(handle), cx) {
        // Try aborting select.
        sel = match cx.try_select(Selected::Aborted) {
            Ok(()) => {
                index_ready = Some(*i);
                Selected::Aborted
            }
            Err(s) => s,
        };
        break;
    }

    // If another thread has already selected one of the operations, stop registration.
    sel = cx.selected();
    if sel != Selected::Waiting {
        break;
    }
}

(源码注释写的是「returns false」,代码判断的是 true:register 返回 true 表示已经就绪,注释和代码不一致,以代码为准。)

注册过程中有两种提前结束:某个分支 register 时发现已经就绪,就自己 CAS 成 Aborted、记下它的下标;或者注册到一半,已经有别的线程通过先注册的分支把我选中了。

还在 Waiting 的话,算出最早的截止时间:调用方给的 timeout,和每个 handle 的 deadline()(at / tick 的投递时间)取最小值,然后 cx.wait_until(deadline)。所以 select! 里放一个 tick 分支,线程会按 tick 的节拍醒来,不需要额外的计时线程。

醒来后把注册过的分支全部 unregister(只摘前 registered_count 个),再看结果:

sel处理
Operation(op)找到地址等于 op 的那个 handle,accept,成功就返回
Aborted 且有 index_ready对那个分支再 try_select 一次
Aborted(超时)/ Disconnected落到循环末尾,再逐个 try_select 一遍;超时了就返回 None

断开也算「就绪」:recv 一个已断开且空的 channel 会立即「成功」,分支里拿到 Err(RecvError)。

run_ready:只报就绪

run_ready 是 Select::ready() 用的变体:同样洗牌,但用 is_ready() / watch() 代替 try_select() / register(),只返回一个就绪分支的下标,不预订任何东西。调用方拿到下标后自己去 try_recv,可能失败(被别的线程抢走),文档要求调用方处理这种情况。它挂在 Waker 的 observers 上,就绪时被 notify 全部叫醒。

Select 结构体

Select 是运行时构造的 select:分支数量不固定时用它。

let mut sel = Select::new();          // 或 Select::new_biased()(0.5.14,#1150)
let i1 = sel.recv(&r1);
let i2 = sel.send(&s2);
let oper = sel.select();
match oper.index() {
    i if i == i1 => { let msg = oper.recv(&r1); }
    i if i == i2 => { oper.send(&s2, 10).unwrap(); }
    _ => unreachable!(),
}

sel.select() 返回的 SelectedOperation 里装着 Token:预订已经做了,调用方必须调它的 recv / send 把操作完成。两个方法都先 assert! 传进来的 channel 地址和预订时的一致(「passed a receiver that wasn't selected」),再调 flavor 的 read / write,最后 mem::forget(self) 跳过 Drop。

不完成就 drop,Drop 直接 panic:

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

被预订的槽已经从 head / tail 里划走了,不完成就会留下一个「已预订、未写入」的槽,panic 是为了不让这种状态悄悄发生。如果绕过 Drop(mem::forget),这个槽就一直悬着,list 的 Drop 在 0.5.17 才修好由此引起的 UB。

select! 宏展开成什么

select! 和 select_biased!(0.5.13,#1040)是 select_macro.rs 里同一个内部宏 crossbeam_channel_internal! 的两个入口,唯一区别是定义了 const _IS_BIASED: bool 为 false 还是 true(select_macro.rs:1122、1143)。宏的解析规则本卷不逐条讲,只看展开结果。exp-crossbeam/select-expand/ 里一个两分支的例子:

select! {
    recv(r1) -> msg => println!("r1 {:?}", msg),
    send(s2, 10) -> res => println!("s2 {:?}", res),
}

cargo expand(nightly)的输出,去掉格式化后的嵌套,主干是:

const _IS_BIASED: bool = false;
const _LEN: usize = 1 + (1 + 0);
let _handle: &dyn SelectHandle = &never::<()>();
let mut _sel = [(_handle, 0, ptr::null()); _LEN];
// 每个分支一段:
_sel[0usize] = (_oper0, 0usize, _oper0 as *const Receiver<_> as *const u8);
_sel[1usize] = (_oper1, 1usize, _oper1 as *const Sender<_> as *const u8);

let _oper: SelectedOperation<'_> = {
    let _oper = internal::select(&mut _sel, _IS_BIASED);
    unsafe { mem::transmute(_oper) }  // 抹掉生命周期,让 _sel 可以提前 drop
};
if _oper.index() == 0usize {
    let _res = _oper.recv(_oper0);
    { _sel };
    let msg = _res;
    println!("r1 {:?}", msg);
} else if _oper.index() == 1usize {
    let _res = _oper.send(_oper1, 10);
    { _sel };
    let res = _res;
    println!("s2 {:?}", res);
} else {
    unreachable!("internal error in crossbeam-channel: invalid case");
}

几个看点:

  • handle 数组在栈上,长度是编译期常数 _LEN,先用 never() 填满再逐个覆盖,不分配堆内存。宏内部预备了 _oper0 到 _oper31 共 32 个标签,分支数超过 32 会在展开时报错。
  • 每个分支先 recv / send 完成操作,再 { _sel } 把 handle 数组 move 掉(drop),然后才进入用户写的分支体。分支体里可以随便用这些 channel,不会和 select 的借用冲突。
  • 有 default 分支时调 internal::try_select,有 default(timeout) 时调 select_timeout。
  • 只有一个分支时有专门的规则(select_macro.rs:537-592):单个 recv 直接展开成 r.recv(),带 default 的展开成 try_recv(),带 default(t) 的展开成 recv_timeout(t),完全不经过 run_select。两个 recv 的优化规则写了但被注释掉了,里面留着 TODO: Implement this optimization。这个单分支特例会影响 tick 的行为,见 timers。

实验 E7:三个分支的公平性

规划时的两分支实验(各 10 万条始终就绪):select! 50228 / 49772,select_biased! 100000 / 0。这里补三分支:a、b 预先各放 10 万条,始终就绪;c 只在每第 10 次迭代时恰好有一条(每轮开始前先清空 c,i % 10 == 0 时再放一条)。选 10 万次,计数。程序在 exp-crossbeam/select-fairness/,release:

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
  • 随机版:c 就绪的 1 万次里,三者各占约三分之一,期望 c ≈ 3333,实测 3317 / 3449;其余 9 万次 a、b 对半分。a ≈ b ≈ 48333 的期望也对得上。
  • 偏置版按书写顺序:a 写在前面就永远是 a,c 只要排在第一,它就绪的那 1 万次全归它(反向验证:把 c 放到第一位,c = 10000、a = 90000)。
  • Select::new / Select::new_biased 与宏行为一致。
  • 连续跑两次,输出逐位相同:洗牌的随机数种子是固定的。

偏置版的含义:写在前面的分支只要一直就绪,后面的分支就永远不会被选中。拿它做优先级(例如先处理控制消息、再处理数据)没问题;拿它做多路合并会饿死后面的分支。

小结

  • 每个 flavor 用 8 个方法的 SelectHandle 接入 select;预订(try_select)、挂起(register)、被选中后完成(accept)三步分开。
  • run_select:非偏置时先 Fisher–Yates 洗牌(线程本地 xorshift,固定种子),逐个尝试;都不行就在一个 Context 上注册全部,等最早的截止时间(at / tick 的 deadline() 也算进去),醒来后全部注销、按结果完成或重试。
  • SelectedOperation 不完成就 drop 会 panic;mem::forget 它会留下一个已预订未写入的槽。
  • select! 展开成栈上的 handle 数组加 internal::select,再按 index() 分派;单个 recv 分支被特殊处理成直接 recv()。最多 32 个分支。
  • E7:三分支时随机版的分布与期望一致(c 3317 / 期望 3333),偏置版严格按书写顺序;两种 Select 结构体与宏一致。

💬 关联讨论

发起讨论
加载中...