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:三分支时随机版的分布与期望一致(
c3317 / 期望 3333),偏置版严格按书写顺序;两种Select结构体与宏一致。