Appearance
第14章 select! 宏展开与公平调度
"A good macro is one whose expansion teaches you more than the macro itself." —— 笔者
本章要点
tokio::select!是一个macro_rules!声明宏——不是过程宏。整个实现在tokio/src/macros/select.rs里,约 200 行- 展开工作流:解析输入 → 归一化每个分支(把所有形态统一成"
( $skip ) $pat = $fut, if $c => $handle,")→ 生成一个poll_fn循环 start = $start变量是公平性的核心——默认$start = thread_rng_n(BRANCHES)(随机起点),加biased;则$start = 0(严格顺序)- disabled bitmap(
__tokio_select_util::Mask):一个整数的 bit 标记哪些分支已经 Ready 或条件为 false,避免重复 poll - 展开后的每次 poll:从 start 开始循环所有分支,poll 未 disabled 的分支、发现 Ready 就返回、全 Pending 就返回 Pending
- 所有分支 Future 必须 cancel-safe——select 只会取其中一个 Ready 结果、其他 Future 被 drop。如果分支 Future 状态一半、drop 时丢数据就是 bug
biased;改变公平性:严格按代码顺序 poll、第一个 Ready 就返回。适合"希望某分支优先"(比如 shutdown signal 优先)
14.0½ 一个 select! 的真实生产案例
在进入展开分析前,看一个典型的生产 select! 用法——让你对它的复杂性有具体感受:
rust
// 一个 HTTP 服务器的 connection handler
async fn handle_connection(
mut socket: TcpStream,
mut shutdown: broadcast::Receiver<()>,
state: Arc<State>,
) -> Result<()> {
let (mut read_half, mut write_half) = socket.split();
let (tx_out, mut rx_out) = mpsc::channel::<Frame>(64);
// 发送循环和接收循环并行
loop {
tokio::select! {
biased; // shutdown 优先
_ = shutdown.recv() => {
info!("connection closing on shutdown");
break;
}
result = read_frame(&mut read_half) => {
match result {
Ok(frame) => process_frame(frame, &state, &tx_out).await?,
Err(e) if is_closed(&e) => break,
Err(e) => return Err(e.into()),
}
}
Some(out) = rx_out.recv() => {
write_frame(&mut write_half, out).await?;
}
_ = tokio::time::sleep(Duration::from_secs(60)) => {
info!("connection idle timeout");
break;
}
}
}
Ok(())
}这段 20 行代码用到本章 7/8 个概念:
- 4 个分支(biased 优先级)
- 不同类型 Future(broadcast recv / async fn / mpsc recv / timer)
- pattern matching(
Some(out) = ...) - 各种错误处理路径
- 循环中的 select(每次循环同一套 Future)
- shutdown 优先 + timeout 兜底
这就是实际生产代码里 select! 的用法——本章带你理解它为什么这样写、每一行背后的机制。
14.1 为什么要学宏展开
tokio::select! 是你用过最多的 Tokio 宏之一:
rust
tokio::select! {
msg = rx.recv() => { handle(msg); }
_ = shutdown.recv() => { break; }
}但它究竟怎么工作的?
- 两个 Future 同时 poll?还是顺序?
- 如果 msg 和 shutdown 同时 Ready,选哪个?
- 为什么官方强调"分支必须 cancel-safe"?
biased;到底改变了什么?
这些问题的答案全在宏展开代码里。学 select! 的展开不是为了"写一个 select! 替代品"——是为了理解它的行为特征,从而用对、避坑。
本章只干一件事:把 select! 展开的代码当普通 Rust 代码读——你会发现它没有魔法、只是一段设计精巧的逻辑。
14.1½ 如果你自己要实现 select:问题的复杂度
设想没有 select!,你自己要实现"等 N 个 Future 任一完成"。最朴素的做法:
rust
// 朴素版本
async fn select_2<A, B>(a: A, b: B) -> Either<A::Output, B::Output>
where A: Future, B: Future {
let a = pin!(a);
let b = pin!(b);
poll_fn(|cx| {
if let Poll::Ready(x) = a.poll(cx) { return Poll::Ready(Either::Left(x)); }
if let Poll::Ready(x) = b.poll(cx) { return Poll::Ready(Either::Right(x)); }
Poll::Pending
}).await
}看起来简单。但试试扩展到 3、4、5 个 Future —— 每个数字一份代码。所以需要宏来统一处理任意数量分支。
再加上:
- if guard
- 模式匹配 failing 时 disable
- biased 模式
- 公平性
- 每次 poll 随机起点
朴素版本变成巨大的 if-else 链。select! 宏的价值:把这套复杂性压成 5 行用户代码。
这是好宏的标志——用户看到的是简洁、实现是复杂。让"复杂性在 API 下方" 而不是让用户扛。
14.2 select! 的宏签名
打开 tokio/src/macros/select.rs。select! 是一个 declarative macro(macro_rules!)——不是过程宏(proc_macro)。这是个关键选择:
- 声明宏 用模式匹配展开,性能好、错误信息相对原生
- 过程宏 能做任意代码转换,但需要额外编译步骤、错误信息容易失去精确性
Tokio 选声明宏——让 select! 的"展开"对人类可读。
入口规则
宏的入口规则分几类:
rust
// 单独 else 分支
($(biased;)? else => $else:expr $(,)? ) => {{ $else }};
// biased 模式:start = 0
(biased; $p:pat = $($t:tt)* ) => {
$crate::select!(@{ start=0; () } $p = $($t)*)
};
// 默认模式:start = thread_rng_n(BRANCHES)
( $p:pat = $($t:tt)* ) => {
$crate::select!(@{ start={ $crate::macros::support::thread_rng_n(BRANCHES) }; () } $p = $($t)*)
};
// 空 select!
() => {
compile_error!("select! requires at least one branch.")
};核心分叉在这里:
- 有
biased;关键字 →start = 0(第一个分支优先) - 没有 →
start = thread_rng_n(BRANCHES)(随机起点,公平)
其他参数格式全部转发给 "@" 前缀的规则——后者做归一化。
@ 前缀约定
展开规则用 @ 前缀——例如 (@ { start=$start:expr; $($t:tt)* } $p:pat = ... )。
这是 macro_rules! 社区约定:"带 @ 的规则是内部递归、用户不应该直接调用"。编译器不检查这个约定——只是社区惯例。
为什么需要这个约定:macro_rules! 没有"私有规则"的语法——所有规则都暴露给用户。用 @ 明确标记"这是内部递归"让用户和其他宏代码区分。
这种**"社区约定替代语言缺失特性"** 在 Rust 生态很常见——snake_case vs CamelCase、/// doc comment、#[must_use] 等——代码交流的文化约定比语法还重要。
宏的错误信息挑战
声明宏的一个痛点:错误信息可能指向宏展开的位置、不是用户代码。
Tokio 的 select! 用了几个技巧让错误更好:
compile_error!捕捉空输入- 命名规则让错误提示相对明确
- 但对"分支里用了错误类型" 的错误,仍然可能指向宏内部
这是声明宏 vs 过程宏的主要 trade-off——过程宏能更精细地报错、但实现复杂。Tokio 选声明宏、接受错误信息的不完美。
如果你用 select! 遇到难懂的编译错误,很多时候问题是:分支里的 Future 不满足 Unpin / Send / 类型不匹配 —— 从这几个角度先排查。
14.3 归一化:让所有分支长一个样
用户可以以多种方式写 select! 分支:
rust
tokio::select! {
x = fut1() => { /* ... */ } // 无 if guard,无尾逗号
y = fut2() => { /* ... */ }, // 带尾逗号
z = fut3(), if cond => { /* ... */ } // 带 if guard
_ = fut4() => do_thing(), // 表达式 body
}宏的归一化阶段把所有形态转成统一内部形式:
( $skip ) $pat = $fut, if $cond => $handle,$skip 是一串下划线——代表这个分支前面有几个分支(count! 宏把它数成整数用)。
归一化规则(有 10+ 条) 长这样:
rust
// 补全"无 if guard"为"if true"
(@ { start=$start:expr; ( $($s:tt)* ) $($t:tt)* } $p:pat = $f:expr => $h:block, $($r:tt)* ) => {
$crate::select!(@{ start=$start; ($($s)* _) $($t)* ($($s)*) $p = $f, if true => $h, } $($r)*)
};
// 带 if guard
(@ { start=$start:expr; ( $($s:tt)* ) $($t:tt)* } $p:pat = $f:expr, if $c:expr => $h:block, $($r:tt)* ) => {
$crate::select!(@{ start=$start; ($($s)* _) $($t)* ($($s)*) $p = $f, if $c => $h, } $($r)*)
};
// ... 处理 expression body, 尾逗号 / 无尾逗号等这些规则的共同模式:拆出一个分支 → 在 $($s)* 里加一个 _ → 把分支追加到累积列表 → 递归处理剩下。
这就是 "tt-muncher"(token tree muncher) 宏模式——一次处理一个 token tree、递归消化整个输入。Rust 生态里凡是复杂的 macro_rules!(lazy_static、anyhow、tokio 自己的好几个宏)都用这个模式。
归一化完成后,进入最终的生成阶段。
归一化的 UX 价值
归一化不改变语义、只改变形式。它的唯一目的是让用户代码可以有多种写法——带不带尾逗号、block body 还是 expression body、带不带 if guard——宏都处理、不给用户增加约束。
好宏的标志:用户可以按自己习惯写、宏兼容各种形态。
反例:早期一些 Rust 宏要求精确格式,一个尾逗号错了就编译失败。那种宏用起来痛苦——心智负担大。Tokio 的 select! 在这方面做得极好——写任何合理格式都能工作。
归一化阶段的这 10 条规则看起来重复繁琐、但正是它们让用户体验丝滑。
14.4 生成阶段:展开后的真实代码
归一化后的宏进入 @ 前缀的最终规则——生成大段实际代码。简化版(去掉错误处理等):
rust
(@ {
start=$start:expr;
( $($count:tt)* )
$( ( $($skip:tt)* ) $bind:pat = $fut:expr, if $c:expr => $handle:expr, )+
; $else:expr
}) => {{
// 1. 生成一个输出 enum,一个 variant 对应一个分支
mod __tokio_select_util {
$crate::select_priv_declare_output_enum!( ( $($count)* ) );
}
use $crate::macros::support::Future;
use $crate::macros::support::Pin;
use $crate::macros::support::Poll::{Ready, Pending};
const BRANCHES: u32 = $crate::count!( $($count)* );
// 2. disabled bitmap:记录哪些分支已经不再 poll
let mut disabled: __tokio_select_util::Mask = Default::default();
// 3. 先检查 if guard、为 false 的分支置 disabled
$(
if !$c {
let mask: __tokio_select_util::Mask = 1 << $crate::count!( $($skip)* );
disabled |= mask;
}
)*
// 4. 把所有 Future 存入 tuple
let mut output = {
let futures_init = ($( $fut, )+);
let mut futures = ($( IntoFuture::into_future(count_field!( futures_init.$($skip)* )), )+);
let mut futures = &mut futures;
// 5. poll_fn 循环
$crate::macros::support::poll_fn(|cx| {
let mut is_pending = false;
let start = $start;
for i in 0..BRANCHES {
let branch = (start + i) % BRANCHES;
match branch {
$(
$crate::count!( $($skip)* ) => {
let mask = 1 << branch;
if disabled & mask == mask { continue; }
let ( $($skip,)* fut, .. ) = &mut *futures;
let mut fut = unsafe { Pin::new_unchecked(fut) };
let out = match Future::poll(fut, cx) {
Ready(out) => out,
Pending => { is_pending = true; continue; }
};
disabled |= mask;
// 模式匹配检查
match &out {
$crate::select_priv_clean_pattern!($bind) => {}
_ => continue,
}
return Ready(/* 对应 variant */(out));
}
)*
_ => unreachable!(),
}
}
if is_pending { Pending } else { Ready(__tokio_select_util::Out::Disabled) }
}).await
};
// 6. 根据 output 的 variant 执行对应 handler
match output {
$( /* variant_n(pat) => $handle_n, */ )*
__tokio_select_util::Out::Disabled => $else,
}
}};核心就是那个 poll_fn 闭包——它被 await 时就开始 poll。每次 poll 做:
- 从
start开始循环所有分支 - 对每个分支:检查 disabled → poll Future → Ready 就返回 / Pending 就标记 is_pending 后 continue
- 所有分支都 Pending:返回 Pending
- 所有分支 disabled:返回 Ready(Disabled) → else 分支
cargo expand 实战看展开
你可以用 cargo expand 工具实际看 select! 展开成什么:
bash
# 安装
cargo install cargo-expand
# 在你的项目里跑
cargo expand --bin my_binary
# 或特定模块
cargo expand module::path看 select! 展开的好处:
- 遇到 select! 编译错误时,看展开代码定位问题
- 学习时对照本章讲解 + 真实展开代码、加深理解
- 检查宏展开的代码大小、性能敏感场景评估开销
实测:4 分支的 select! 展开约 80-120 行代码——不少但可读。复杂 select(6+ 分支、带 if guard、带 else)能到 200+ 行。
这种"用工具看清宏真面目"的习惯建议养成——所有复杂宏都值得 cargo expand 一次。
展开后是一段普通 Rust 代码
没有黑魔法。就是:
- 一个 enum
- 几个 if 检查
- 一个 tuple 存 Future
- 一个 poll_fn 循环
- 一个 match 分发
你可以手写这段代码 —— 宏的作用是让你不用写。
select_priv_declare_output_enum!
展开代码最开始这一块:
rust
mod __tokio_select_util {
$crate::select_priv_declare_output_enum!( ( $($count)* ) );
}这个辅助宏生成一个 enum,每个分支对应一个 variant:
rust
// 如果有 3 个分支,展开为:
enum Out<T0, T1, T2> {
_0(T0),
_1(T1),
_2(T2),
Disabled,
}为什么要这个 enum:因为不同分支的 Future 可能返回不同类型(T0, T1, T2 可能各异)。要统一存到 poll_fn 的 Ready(...) 里、必须套一个 enum。
mod __tokio_select_util {} 把它包进一个匿名模块——避免污染用户的命名空间。用户如果自己有一个 Out 类型不会冲突。
这种"宏内部 mod 避免污染"是 macro_rules! 最佳实践。自己写复杂宏时学这招——用 mod {} 包裹内部辅助类型。
count! 宏的技巧
展开代码里多次出现 count!( $($skip)* )——这个辅助宏把"一串下划线"数成整数:
rust
macro_rules! count {
() => { 0 };
(_) => { 1 };
(_ _) => { 2 };
(_ _ _) => { 3 };
// ... 到 64
}为什么要这么做:因为 macro_rules! 里没有"计数器变量"——只有模式匹配。要"数有多少个 token tree"只能穷举匹配规则。
Tokio 的 count! 支持到 64——所以 select! 最多 64 个分支。"展开时的常量"就靠这种硬编码。
这是 declarative macro 的限制——procedural macro 能任意计算、declarative 不能。对应的宏设计要在规则数和灵活性之间权衡。64 的上限对实际用途(select 几个 future)够用。
14.5 公平性的实现:start = thread_rng_n(BRANCHES)
看到 start = $start 那一行——这是 select 公平性的核心。
默认情况:$start = thread_rng_n(BRANCHES) —— 每次 poll_fn 被 poll 时 生成一个 0 到 BRANCHES-1 的随机数。loop 从 start 开始、按 (start + i) % BRANCHES 顺序检查分支。
意义:如果两个分支同时 Ready,哪个被选中是随机的。统计上每个分支被选的概率均等——公平。
thread_rng_n 的实现
rust
// tokio/src/macros/support.rs
pub fn thread_rng_n(n: u32) -> u32 {
crate::runtime::context::thread_rng_n(n)
}内部是一个线程本地的 FastRand(第 5 章讲过的 xor-shift)—— 每次调用生成下一个伪随机数,mod n 取余。极快、均匀。
注意**"线程本地"** —— 每个 worker 线程有自己的 rng 状态。不需要原子操作、不需要同步、零成本。
biased; 的效果
rust
tokio::select! {
biased;
_ = shutdown.recv() => { /* ... */ }
msg = rx.recv() => { /* ... */ }
}有 biased; 时宏展开为 start = 0——从第一个分支开始。如果第一个 Ready 就立刻返回第一个、永远优先。
用例:shutdown signal 必须优先,不能被其他繁忙的分支饿死。下面这个模式是生产代码里的标配:
rust
loop {
tokio::select! {
biased;
_ = shutdown.recv() => break,
msg = work_queue.recv() => process(msg).await,
}
}如果不带 biased:随机选分支——shutdown 信号到达时可能被跳过、work_queue 一直被选到——关不掉的服务。
biased; 是关键 bug 修复关键字——关键分支(shutdown、高优先级消息等)必须 biased。
公平性的统计实验
为了让 "随机 start 的公平性"具象化,做一个简单实验:
rust
let mut counts = [0; 3];
for _ in 0..10000 {
tokio::select! {
_ = async { /* always ready */ } => counts[0] += 1,
_ = async { /* always ready */ } => counts[1] += 1,
_ = async { /* always ready */ } => counts[2] += 1,
}
}
// counts 应该接近 [3333, 3333, 3333]实测分布(具体跑一次):可能是 [3312, 3356, 3332]——每个分支 33.3% 左右。波动在 ±1%——随机性的正常结果。
对比 biased 模式:
rust
for _ in 0..10000 {
tokio::select! {
biased;
_ = ... => counts[0] += 1,
_ = ... => counts[1] += 1,
_ = ... => counts[2] += 1,
}
}
// counts = [10000, 0, 0] —— 第一个分支 100% 独占biased 的 "第一个优先" 是绝对的——只要第一个 Ready、永不到后面。这个差别用在 shutdown 场景是有意义的(shutdown 优先绝对)、用错会饿死其他分支。
为什么不保留上次 start 继续
你可能想:如果上次 start=5 返回 Ready、下次 start 应该 = 6 以 "轮询"?这样更公平?
不是——因为两次 select poll 之间Future 状态是独立的。随机 start 的统计公平性足够、不需要 "轮转"机制。
"轮转"还会引入额外状态(需要记住上次位置)、复杂度增加。随机更简单且等效。**设计选择反映"保持最小状态"**的哲学。
14.6 disabled bitmap:记录已处理分支
展开代码里这行:
rust
let mut disabled: __tokio_select_util::Mask = Default::default();Mask 通常是 u64 或更大——每个分支对应一个 bit。
disabled 被设置的三种情况:
- if guard 返回 false:预先置 disabled(那条分支永不 poll)
- 分支 Ready:置 disabled(下次不再 poll、如果在 else 路径走)
- 模式匹配失败:即 Future 返回 Ready 但
$bind:pat不匹配(比如Some(x) = rx.recv()返回 None)——分支也被 disabled
每次 poll 前 check:
rust
if disabled & mask == mask { continue; }disabled 的意义:如果所有分支都 disabled,select 返回 else 分支(或 panic 如果没写 else)。
整个机制让 select! 行为确定:
- Ready 分支只被处理一次
- 模式不匹配的 Ready 被忽略
- if guard false 的分支不 poll
- 全 disabled 走 else
disabled bitmap vs HashMap<Index, Bool>
如果分支数很多,你可能想:为什么不用 HashMap<usize, bool>?
因为 bitmap 更快更省:
- bit 检查:
&操作 ~1 CPU cycle - HashMap lookup:几十纳秒 + hash 计算 + 可能 resize
- bitmap 存储:8-64 bits(取决于分支数)
- HashMap 存储:KB 级(最小 HashMap)
对几十个分支的 select!,bitmap 是明显胜出。HashMap 只在"分支数可变 / 不确定上限"时才值得——select! 的分支是编译期已知的,bitmap 完美。
这是"按已知约束选最优数据结构"的例子——编译期常量 N 用 bitmap、动态规模用 HashMap。识别约束 = 做出正确选择。
14.7 所有分支 Future 必须 cancel-safe
select! 最重要的使用要求:所有分支的 Future 必须 cancel-safe。
为什么:select 返回 Ready 时,其他分支的 Future 被 drop。如果那些 Future 在 drop 时丢数据(比如 recv 拿了一半消息但没完成 push 到 buffer),下次就丢了。
Tokio 所有 channel 和 I/O 的 .await 都 cancel-safe(第 13 章讲过)。但你自己写的 async fn 默认不 cancel-safe——想想这段:
rust
async fn my_task() -> Result<()> {
data.lock().await.mutation_one(); // 步骤 1
data.lock().await.mutation_two(); // 步骤 2
Ok(())
}如果 select 选了 timeout,my_task 在步骤 1 后被 drop——步骤 2 永远不执行、状态不一致。这种 async fn 在 select! 里不安全。
关于 cancel safety 的一个 深度洞察
cancel safety 不是 "函数本身的属性"——是 "函数在哪个点可以安全 cancel"。同一个 async fn,在某些 await 点 cancel 安全、在另一些不安全。
看这个例子:
rust
async fn process(state: &mut State, tx: &Sender<Done>) {
state.mutate_part_1(); // 点 A
tokio::time::sleep(Duration::from_millis(1)).await; // 点 B: await
state.mutate_part_2(); // 点 C
tx.send(Done).await.ok(); // 点 D: await
}在哪些 await 点 cancel 安全:
- 点 B 被 cancel:state 处于 part_1 done、part_2 not done 的中间状态——不安全
- 点 D 被 cancel:state 已完成、但 send 被 cancel、消息丢失——部分不安全
cancel safety 要求 async fn 在"每个 await 点 drop 都能让系统状态一致"。上面的函数两个 await 点都不满足——不是 cancel safe 的。
让它 cancel safe 的方法:
- 把 state 的修改原子化(比如用 Mutex 保护、一次性修改)
- send 改成 try_send(不 await)、消息 fire-and-forget
cancel safety 是函数级的语义——写 async fn 时就要考虑。
如何写 cancel-safe Future
- 避免 partial mutation:状态修改要么完整发生、要么不发生
- 用单一
.await点:select 只在 await 边界 cancel、同步操作不受影响 - 包装成原语:用 channel / Notify 等原语代替自己的 state machine
- 外层处理分批:把复杂逻辑放 select 外,select 只等消息
实践建议:只在 select! 里等 channel / timer / I/O——这些 Tokio 保证 cancel-safe。业务逻辑放 select 外。
rust
// 推荐
loop {
let msg = tokio::select! {
m = rx.recv() => m,
_ = shutdown.recv() => break,
};
process_msg(msg).await; // select 外、不被 cancel 影响
}Mask 类型:跟随分支数
__tokio_select_util::Mask 的实际类型由宏生成——根据分支数选:
- 0-8 个分支:
u8 - 9-16:
u16 - 17-32:
u32 - 33-64:
u64
用最小够用的类型——又是 Tokio 的"精打细算"风格(第 5 章的 tick: u32、第 11 章的 occupied: u64 等)。
为什么不直接用 u64:因为 Mask 存在 poll_fn 闭包的栈上、每次 poll 都访问。用 u8 vs u64 的差别在寄存器使用和 cache 占用——虽然微小但累积起来影响热路径性能。
这种"每个字段精选类型"是工业级代码的普遍特征——普通项目不需要这么细、但一旦你做基础库、每个细节都可能被放大到百万次/秒。
14.8 Pin<&mut fut>:为什么 unsafe
展开代码里这行:
rust
let mut fut = unsafe { Pin::new_unchecked(fut) };为什么要 unsafe?因为 Pin 的安全 API 只能 pin 那些 Unpin 的类型(或者 Box<T>)。select! 的 Future 存在栈上的 tuple 里——不是 Box、也不一定 Unpin。
安全性保证:
futurestuple 通过let mut futures = ...声明在函数栈上——作用域结束前不会 move- 每个 Future 通过
Pin::new_unchecked当作已 pinned 对待 - 调用方(用户代码)通过宏确保 tuple 不会被 move
这个 unsafe 是 select! 的关键——让栈上 tuple 的 Future 可以被 poll。没有这一步、需要全 Box::pin、每个 Future 多一次堆分配。性能价值巨大。
这是 Rust pin 世界里的经典案例——unsafe 换性能、同时由宏保证安全上下文。
IntoFuture::into_future 的作用
展开代码里:
rust
let mut futures = ($( IntoFuture::into_future(...), )+);为什么需要 IntoFuture::into_future?
因为 select! 的分支不一定是 Future——可能是 "能转成 Future 的东西":
async { ... }→ 展开成 Future(自动)- 实现 Future 的类型(比如 JoinHandle)→ 直接 Future
- 实现 IntoFuture 的类型(比如 sqlx::Query)→ 需要
.into_future()
IntoFuture::into_future(x) 的好处:
- 如果 x 已经是 Future,
into_future是fn into_future(self) -> Self—— noop - 如果 x 是 IntoFuture 但不是 Future,转成 Future
这让 select! 的分支更通用——任何 "能跑起来的异步值" 都行,不只是严格的 Future。
这种"通过 blanket impl 扩展泛型"是 Rust 生态的常见扩展点——IntoIterator、IntoFuture、TryInto 等——让 API 对更多类型友好。
14.9 select! 的常见陷阱
陷阱 1:不带 biased 的 shutdown
rust
// ❌ 错误:shutdown 可能被 starve
loop {
tokio::select! {
msg = rx.recv() => process(msg).await,
_ = shutdown.recv() => break,
}
}修正:加 biased。
陷阱 2:分支 Future 非 cancel-safe
rust
// ❌ 错误:复杂 async fn 在 select 里
tokio::select! {
r = my_complex_async_fn() => { /* r */ }
_ = timeout => { /* ... */ }
}修正:把 my_complex_async_fn 换成简单 Future(channel.recv / timer),或保证它 cancel-safe。
陷阱 3:变量所有权转移
rust
// ❌ 错误:x 被第一次循环移走了
let x = vec![1, 2, 3];
for _ in 0..10 {
tokio::select! {
_ = sleep(Duration::from_secs(1)) => {
process(x); // ← 第二次循环 x 已经被 move
}
}
}修正:借用或 clone。
陷阱 4:pin! 和 select! 的混用
有些 Future 不 Unpin、不能在 tuple 里直接存。需要预先 tokio::pin!:
rust
let fut = my_async_fn();
tokio::pin!(fut);
loop {
tokio::select! {
r = &mut fut => { return r; } // &mut Pin 可以 poll
_ = timer => { /* ... */ }
}
}&mut fut 的 pin` 是用户代码负责——select 宏自己不处理"已经在栈上需要 pin" 的情况。
陷阱 5:select 循环里的 fut 被反复创建
rust
// ❌ 每次循环创建新 fut,每次都从头开始
loop {
tokio::select! {
r = some_task() => break r, // some_task() 每次循环新建
_ = timer => continue,
}
}如果 some_task 内部有累积进度——每次循环丢失。修正:在 loop 外 pin、select 里用 &mut:
rust
let task = some_task();
tokio::pin!(task);
loop {
tokio::select! {
r = &mut task => break r, // 每次循环 poll 同一个 task
_ = timer => continue,
}
}这些陷阱分别覆盖 select! 使用里 90% 常见 bug——知道它们存在 = 避免大多数 select 相关问题。
14.9⅔ 对比:Go select 和 Tokio select! 的本质差异
Go 的 select { case <-ch1: ... } 和 Tokio 的 tokio::select! { ... } 表面极像、本质有微妙差别:
| 维度 | Go select | Tokio select! |
|---|---|---|
| 实现层级 | 语言关键字,runtime 内置 | macro_rules! 库宏 |
| 公平性 | 随机选 Ready 分支 | 随机选 + start 偏移 |
| 默认偏好 | 无 | 无 biased |
| 偏置模式 | 无直接支持 | biased; |
| cancellation | channel 自动清理(取决于语义) | 其他 Future 被 drop |
| 超时 | case <-time.After(d): | _ = sleep(d) => |
| 分支类型 | 只能 channel 操作 | 任意 Future |
| 宏展开可见 | 不可见(runtime 行为) | 可见(cargo expand) |
Tokio 比 Go 更灵活:分支可以是任意 Future、biased 模式、展开代码可读。 Go 比 Tokio 更简洁:关键字一级、语法轻量。
trade-off:Tokio 选 "灵活 + 透明"、Go 选 "简洁 + 隐藏"。两种哲学各有合理性。
14.9¾ 一个深入问题:为什么 Tokio 没做 "futures::select"
Futures crate 有 futures::select / futures::select_biased 等——提供类似功能但 API 形状不同。Tokio 为什么自己做一套?
几个原因:
- API 风格一致性:tokio::select! 和 tokio::join! 等宏风格统一
- 性能优化:Tokio 自己可以针对其 runtime 优化(比如 thread_rng_n 走 Tokio 的 thread-local)
- cancel safety 对齐:Tokio 的 select! 对 Tokio Future(channel、timer)的 cancel safety 有明确文档
但大体上,futures::select 和 tokio::select! 的语义几乎等价——如果你的代码必须不依赖 Tokio(比如给 smol 用),用 futures::select。
生态现状:Tokio 用户用 tokio::select!,smol 用户用 futures::select / 其他——多个选项共存、按环境选。
14.9⅚ select! 的 "姐妹宏":tokio::join! 和 tokio::try_join!
Tokio 还有两个形状相似、语义不同的宏:
tokio::join!:等所有 Future 都 Ready
rust
let (a, b, c) = tokio::join!(fut1, fut2, fut3);- 所有分支并发 poll(单 task 内的 interleaving)
- 等到所有都 Ready 才返回
- 返回值是 tuple (a.Output, b.Output, c.Output)
tokio::try_join!:同上,但任一返回 Err 就短路
rust
let (a, b, c) = tokio::try_join!(fut1, fut2, fut3)?;- 并发 poll
- 任一 Err → 立刻返回 Err、其他 Future 被 drop(cancel safety 同 select!)
- 所有 Ok → 返回 tuple
三兄弟对比:
select!:第一个 Ready 就返回、其他 dropjoin!:等所有 Ready、没有 canceltry_join!:第一个 Err 返回、其他 drop(有 cancel)
三者覆盖并发 Future 组合的主要场景——记住这三个、大多数并发组合需求都有对应工具。
14.10 和这个系列的其他书的关联
select! 的 "声明宏 + tt-muncher"设计和 《Rust 编译器与运行时揭秘》第 14 章(声明宏与过程宏) 直接衔接——那章讲 macro_rules! 的 tt-muncher、递归展开、count! 技巧等。select! 是其中模式的大规模综合应用——读那章后再看本章,你会惊叹"原来我学的所有宏模式都在这里用了"。
start = thread_rng_n(BRANCHES) 的随机公平性设计和 《Vue 3 设计与实现》第 11 章(虚拟 DOM 与 Diff 算法) 里讲的"对 keyed children 做公平更新"是同构思想——都是"避免按固定顺序处理导致的偏心"。这种 "随机化防偏心"在很多地方出现,识别它让你的并发 / 调度设计更成熟。
陷阱 6:else 分支的误用
rust
tokio::select! {
r = rx.recv() => { /* ... */ }
else => { /* 什么时候走这里?*/ }
}else 不是"所有分支都 Pending 时"——是"所有分支 disabled 时"。一个分支 disabled 的情况:
- if guard 为 false
- 已经 Ready 过了(但 select 在 Ready 后就返回了,不会再 poll)
- 模式匹配失败
最容易触发 else:所有 if guard 都 false:
rust
tokio::select! {
r = rx1.recv(), if flag1 => { /* ... */ }
r = rx2.recv(), if flag2 => { /* ... */ }
else => { /* 当 flag1 == flag2 == false 时走这里 */ }
}没有 else 分支且所有 branch disabled → panic(宏默认生成 panic)。生产代码建议总是写 else,或者确保至少一个分支始终 enabled。
陷阱 7:select! 里的 break r
rust
// ⚠️ 微妙
let result = loop {
tokio::select! {
r = task => break r, // ← break 的值成为整个 loop 的值
_ = timer => continue,
}
};**break r**的 r 是 select! 的输出类型——不是 task 的输出类型。这里 task 是 Future<Output = T>、r: T —— 恰好。
但如果 task 的返回类型复杂,要小心 break 的类型推导。一般 loop + select 模式写成 explicit return 或命名块更清晰:
rust
let result = 'outer: loop {
tokio::select! {
r = task => break 'outer r,
_ = timer => continue,
}
};14.9½ select! 的性能特征
展开后的 select! 每次被 poll 做的事:
- 生成一个随机 start(
thread_rng_n):~5 纳秒(xor-shift + mod) - 循环 N 个分支(N = 分支数):每个分支检查 disabled bit(~1 纳秒)
- 对未 disabled 分支:调 Future::poll(成本取决于 Future,Tokio 原生 ~100 纳秒,复杂 Future 更多)
- 其他:tuple 访问、match 分发——几纳秒
N 个分支、假设平均 1 个 Ready 的情况:
- 平均开销 ~100-300 纳秒 × N(每个 Future 被 poll 一次)
- 其中 "select 宏本身" 开销:~几十纳秒 N(随机数 + 循环 + bitmap)
select! 的开销几乎全来自 "poll 所有分支"——这是必须的(你需要知道每个分支是否 Ready)。宏本身极轻量。
分支数量越多越慢
因为每次 poll 都要顺序检查所有未 disabled 的分支——N 增加,开销线性增加。
经验法则:select! 分支数控制在 <10。超过 10 考虑 FuturesUnordered(下一章讲)——它把"等 N 个 Future 任一完成"从 O(N) 降到 O(log N)。
select! 适合:
- N 少(2-5 个)
- 每个分支是明确不同类型的 Future(timer, channel, I/O)
FuturesUnordered 适合:
- N 多(几十到几百)
- 所有 Future 同类型
- 不需要"先 Ready 的一定先处理"
14.9⅔ tokio::select! 的一个反常设计:每次 poll 从 0 开始
有个精细点值得讨论:select! 的 poll_fn 里每次被 poll 都从 start 开始——而不是记住上次 Pending 的位置继续。
为什么?因为 Future 的 poll 语义:被 Waker 唤醒后必须重新 poll(哪个分支 Ready 不确定)。记住上次位置没意义——所有分支都可能变化。
代价:N 次分支检查(但大多数时候分支是 Pending、poll 开销小)。 收益:逻辑正确、没有 "漏 wake" 问题。
这是"正确性优于性能"的例子——有时候"多做一点" 换 "稳定简单"——值得。
14.10 真实项目里 select! 的五种常见形态
把 tokio-console、axum、hyper、sled、tokio 自家 examples 的代码翻一遍,能抽出五种最常见的 select! 使用范式。列出来,既能让你在读别人代码时一眼识别意图,也能在自己写时避免重新发明轮子。
形态 1:主循环 + 信号关停
rust
loop {
tokio::select! {
biased;
_ = shutdown.changed() => break,
msg = rx.recv() => handle(msg).await,
}
}关键点:biased; 保证每轮先检查 shutdown——否则在大流量下 shutdown 信号可能长时间被 rx.recv() 的公平调度挤压。axum 的 graceful_shutdown、tokio-console 的 command loop 都是这个形态。
形态 2:超时包装
rust
let result = tokio::select! {
r = work => Ok(r),
_ = tokio::time::sleep(Duration::from_secs(5)) => Err(Timeout),
};本质上等价于 tokio::time::timeout(…)——后者的实现内部就是这个 select。知道这点,你在读 timeout 源码时就不会被绕晕。
形态 3:首个到达者胜出(race)
rust
tokio::select! {
r = primary_db.query(sql) => r,
r = replica_db.query(sql) => r,
}注意 cancel safety——没抢到的那条 query 会被 drop、中途 cancel。这对幂等只读查询安全、对有副作用的写操作危险。sled 在做读 fallback 时用这个、写路径绝不用。
形态 4:多源融合
rust
loop {
tokio::select! {
Some(tcp) = tcp_rx.recv() => forward_tcp(tcp).await,
Some(udp) = udp_rx.recv() => forward_udp(udp).await,
Some(ws) = ws_rx.recv() => forward_ws(ws).await,
}
}模式匹配 Some(x) = ch.recv() 自带过滤:channel 关闭返回 None 时该分支自动 disabled——三路全关、循环 panic(有 else 时走 else、优雅退出)。这种"N 路输入合成 1 路处理"在网关、代理里几乎每天都出现。
形态 5:心跳 + 工作两条腿走路
rust
let mut ticker = tokio::time::interval(Duration::from_secs(30));
loop {
tokio::select! {
_ = ticker.tick() => send_heartbeat().await,
Some(req) = rx.recv() => handle(req).await,
}
}关键点:interval.tick() 是 cancel-safe 的——即便 30 秒内被 rx.recv() 分支"抢走"无数次、它也只是累积 missed tick、下次依然按时触发。自己用 sleep(30s) 手写心跳会被 select 每次 cancel、时间永远重置——这是初学者常踩的坑。
这五种形态覆盖了我见过的 80% 以上 select! 用例。把它们背下来,读代码时就能快速把 select! 块映射到意图、而不是每次从零解析 tt-muncher。
14.10½ 从宏展开看「零成本抽象」的真实含义
很多人第一次读完 Tokio 源码,会对 select! 生成的那几百行 poll_fn 感到不舒服——「这不就是把人写的 if-else 换成宏写的 if-else 吗?凭什么说是零成本?」。但仔细想想,这正是 Rust 零成本抽象最干净的一次示范:你手写一份能正确处理取消、公平、唤醒、Pin 的 N 路 Future 轮询,代码量不会比宏展开的更少——但你会在第 3 次手写时就弄错一个边界。宏把这份"繁琐但机械"的工作封装起来、一次写对、随处展开、编译器再把各分支的类型单态化——最终产生的机器码和一位顶级工程师手写的"恰好只做必要事情"的代码完全等价。这是宏作为代码生成器的本色:它不是为了引入新语义、而是为了让正确的模式可复制。
再进一步看,tokio::select! 之所以能做到展开后几乎没有 runtime overhead,根基在于 Rust 的三件东西:泛型单态化、Pin 的编译期保证、以及 Future 本身零堆分配的状态机。Go 的 select 必须依赖 channel 的 runtime 注册/注销,每次进入 select 都要和运行时打交道;Node.js 的 Promise.race 必须把所有 Promise 放进同一个微任务队列、每次 resolve 都穿过 V8 的事件循环调度;而 Rust 的 select! 展开后,只是一段静态已知的轮询代码跑在调用者自己的协作线程里——没有队列、没有锁、没有堆分配。这种"抽象完全消失在编译期"的特性,是系统级语言才有的奢侈。
宏、语言特性、运行时的三角边界
Tokio 团队在 2020 年前后有过一场公开的设计讨论:是不是应该把 select! 做成语言特性、或者做成一个编译器内置的 intrinsic?最终答案是否定的——macro_rules! 已经足够。select! 需要的所有能力(多路类型收敛、cancel 时正确 drop、随机顺序、模式匹配)都能用现有宏机制表达;把它做成语言内置反而会锁死某些决策(比如公平算法、偏置语法),让后来者无法演进。
这种「能用宏就不改语言」的克制,是 Tokio 代码可读性、可审计性的底层保证。你今天读到的 select!,和 2019 年 0.2 版本的 select!,结构几乎没变——只是多了 biased; 分号、多了 disabled bitmap、多了 cancel-safe 文档。这份稳定性让无数生产代码跨越好几个 Tokio 大版本却不需要重写——这才是真正的"基础设施品质"。
14.10¾ 一个容易被低估的细节:select! 如何让编译器帮你查错
当你在 select! 的某个分支里写下 r = rx.recv() => { use_r(r); },如果忘记处理 r 是 Option<T>(channel 关闭返回 None)的情况,编译器会给出一条完全可读的错误信息——不是指向宏内部某个无名临时变量,而是直接指向你的 use_r(r) 那一行、告诉你类型不匹配。这件事背后的工程量远超想象。
macro_rules! 宏的错误提示在 Rust 历史上一直是痛点:用户写错一个 token,编译器往往会把错误指向宏展开后的某段天书、让你根本看不懂。Tokio 团队为此专门设计了 @ 前缀的内部归一化入口、FAIL 规则(在遇到无法匹配的形态时显式跳转到一个专门生成高质量报错的分支)、以及 span 保留技巧(用 $(…)* 重复而不是 $(…)+、保证原始 token 的源位置不丢失)。这些技巧都不是 Rust 宏教科书上写的"主菜",而是被生产环境反复教训之后才打磨出来的细节。
作为读者你未必会去模仿这些技巧,但理解它们的存在能让你在写自己的 macro_rules! 时心里有杆秤:好用的宏 ≠ 写得巧的宏,好用的宏是把错误信息还给用户的宏。Tokio 的 select! 是这方面的黄金标准——这也是为什么即便有 proc_macro 可以做更花哨的事,社区至今仍然把 macro_rules! 版本的 select! 作为默认方案。
顺便提一句:proc_macro 版本不是没做过尝试。2020 年前后有人提过 async-select crate、用 proc_macro 重写、支持更灵活的语法。结果就是——编译速度下降一个数量级(proc_macro 需要启动 rustc 的代码生成器)、错误信息反而更差(因为 proc_macro 的 span 推断比 macro_rules! 还微妙)、而且没有解决任何 macro_rules! 版本解决不了的问题。一年后那个 crate 就停更了。Tokio 团队在设计 select! 之初,已经把"是否该用 proc_macro"这件事彻底想清楚——这种前瞻性判断,是基础设施作者和应用作者之间最明显的差距。
和 rust-compiler 书的呼应
我们在《Rust 编译器与运行时揭秘》第 14 章专门讲过 macro_rules! 的 tt-muncher 和 count! 技巧——如果你没读过,那一章的 30 行示例展示了和 select! 完全同构的递归归约模式。两本书一起读,你会发现 Tokio 不是「把宏用到极致」、而是「把宏用到恰到好处」——复杂度刚好够用、再多一分就会伤到可读性。这种克制,正是「大师级基础设施」和「聪明人炫技玩具」的分界线。
14.11 本章小结
带走三件事:
select!是一个 ~200 行的macro_rules!宏——通过 tt-muncher 归一化 + 生成 poll_fn 循环。展开后是普通 Rust 代码、没有黑魔法- 公平性靠
start = thread_rng_n(BRANCHES)——默认每次 poll 随机选起点、所有分支概率均等。biased;改为 start=0、严格顺序、用在关键分支优先场景 - 所有分支 Future 必须 cancel-safe——Tokio channel / timer / I/O 都 safe,自己写的 async fn 默认不 safe——在 select! 里等这类业务 future 时要特别小心
下一章进入 JoinHandle / JoinSet / AbortHandle——Task 集合管理的几个原语。你会看到 JoinSet 如何用 FuturesUnordered 的思路高效管理一组 Task、AbortHandle 如何无侵入地取消 Task。
延伸阅读
- Tokio 源码:
tokio/src/macros/select.rs - Tokio 源码:
tokio/src/macros/support.rs - 《Rust 编译器与运行时揭秘》第 14 章:macro_rules! 的 tt-muncher + count! 技巧
- 《Vue 3 设计与实现》第 11 章:keyed children diff 的公平性设计对比
- Tokio docs:
tokio::macros::cancel_safety—— 官方 cancel safety 清单