Tokio 源码深度解析
第14章 select! 宏展开与公平调度
本章要点
tokio::select!的主体是一个macro_rules!声明宏(tokio/src/macros/select.rs第 425-661 行,宏体 237 行),但它还调用了两个tokio-macros里的过程宏做声明宏做不了的事:select_priv_declare_output_enum!和select_priv_clean_pattern!- 展开工作流:解析输入 → 归一化每个分支(把所有形态统一成"
( $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
- 在循环里反复 select 时,分支 Future 必须 cancel-safe——select 只会取其中一个 Ready 结果、其他 Future 被 drop。如果分支 Future 状态一半、drop 时丢了下一轮还要用的数据就是 bug(官方文档也说明:取消非 cancel-safe 的操作本身不一定是错)
biased;改变公平性:严格按代码顺序 poll、第一个 Ready 就返回。适合"希望某分支优先"(比如 shutdown signal 优先)
14.1 一个 select! 的真实生产案例
在进入展开分析前,看一个典型的生产 select! 用法——让你对它的复杂性有具体感受:
// 一个 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;
}
// 注意:read_frame 必须 cancel-safe(见 14.9),否则其他分支胜出时读了一半的帧会丢
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(())
}
这段代码用到了本章的多个概念:
- 4 个分支(biased 优先级)
- 不同类型 Future(broadcast recv / async fn / mpsc recv / timer)
- pattern matching(
Some(out) = ...) - 各种错误处理路径
- 循环中的 select(每轮循环都重新创建各分支的 Future)
- shutdown 优先 + timeout 兜底
这就是实际生产代码里 select! 的用法——本章带你理解它为什么这样写、每一行背后的机制。
14.2 为什么要学宏展开
select! 是 Tokio 里最常用也最容易误用的宏之一——理解它的宏展开能帮你避开典型陷阱。表面上 select! 语法和 Go 的 select 很像——但底层机制完全不同:Go 的 select 是运行时原语(Go runtime 识别它做特殊处理);Tokio 的 select! 是宏展开成普通 Future 代码(没有任何 runtime 特殊处理)。这个根本差异决定了两者在 cancellation、公平性、可组合性上的多处微妙差异——读懂宏展开你就理解了这些差异的来源。
tokio::select! 是你用过最多的 Tokio 宏之一:
tokio::select! {
msg = rx.recv() => { handle(msg); }
_ = shutdown.recv() => { break; }
}
但它究竟怎么工作的?
- 两个 Future 同时 poll?还是顺序?
- 如果 msg 和 shutdown 同时 Ready,选哪个?
- 为什么官方文档要专门用一段讲分支的 cancel safety?
biased;到底改变了什么?
这些问题的答案全在宏展开代码里。学 select! 的展开不是为了"写一个 select! 替代品"——是为了理解它的行为特征,从而用对、避坑。
本章只干一件事:把 select! 展开的代码当普通 Rust 代码读——你会发现它没有魔法、只是一段设计精巧的逻辑。
14.3 如果你自己要实现 select:问题的复杂度
设想没有 select!,你自己要实现"等 N 个 Future 任一完成"。最朴素的做法:
// 朴素版本
async fn select_2<A, B>(a: A, b: B) -> Either<A::Output, B::Output>
where A: Future, B: Future {
let mut a = pin!(a);
let mut b = pin!(b);
poll_fn(|cx| {
if let Poll::Ready(x) = a.as_mut().poll(cx) { return Poll::Ready(Either::Left(x)); }
if let Poll::Ready(x) = b.as_mut().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.4 select! 的宏签名
select! 的宏签名设计是 Tokio 宏艺术的巅峰作品——它需要处理任意数量的分支、任意类型的 Future、任意模式匹配、任意 precondition、else 分支、biased 修饰符……这些功能绝大部分都由声明式宏(macro_rules!)实现,只有两处借助过程宏(见下文)。读 select! 的宏代码是理解 Rust 宏系统能力边界的最好练习之一——声明式宏能做到的事比大多数人想象的要多。
打开 tokio/src/macros/select.rs。select! 的主体是一个 declarative macro(macro_rules!)——但不是纯声明宏。源码开头第一段注释就把这件事说清楚了:
// 来源:tokio/src/macros/select.rs:426(`macro_rules! select` 本体从 tokio/src/macros/select.rs:425 开始)
// Uses a declarative macro to do **most** of the work. While it is possible
// to implement fully with a declarative macro, a procedural macro is used
// to enable improved error messages.
//
// The macro is structured as a tt-muncher. All branches are processed and
// normalized. Once the input is normalized, it is passed to the top-most
// rule. When entering the macro, `@{ }` is inserted at the front. This is
// used to collect the normalized input.
//
// The macro only recurses once per branch. This allows using `select!`
// without requiring the user to increase the recursion limit.
也就是说:大部分工作由声明宏完成,两处交给 tokio-macros 里的过程宏——
select_priv_declare_output_enum!( ( $($count)* ) ):按分支数量声明一个Out枚举(Out::_0(..)、Out::_1(..)……)。声明宏要做到"按数量生成 N 个变体"只能再手工穷举(像下文的count!那样),这里交给过程宏按数量生成select_priv_clean_pattern!($bind):把用户写的绑定模式"洗干净"再用于匹配检查。"洗"掉的不是绑定名、而是模式里的ref/mut——tokio-macros/src/select.rs:59那个clean_pattern函数头上的注释原话是 "Removes any occurrences of ref or mut in the provided pattern"(tokio-macros/src/select.rs:58),实现就是把每个Pat::Ident的by_ref和mutability置成None
这两个过程宏在 tokio-macros/src/lib.rs:575 与 tokio-macros/src/lib.rs:583 上都标着 #[doc(hidden)](tokio-macros/src/lib.rs:574、tokio-macros/src/lib.rs:582),文档里也写明不属于公开 API。
为什么主体仍然坚持用声明宏:声明宏的展开对人类可读、编译期开销小,而且注释里特别提到 "The macro only recurses once per branch"(tokio/src/macros/select.rs:435)——每个分支只递归一次,用户不用去调 #![recursion_limit]。
入口规则
宏的入口规则分几类(tokio/src/macros/select.rs:644 起):
// 示意,非逐字:tokio/src/macros/select.rs:644-tokio/src/macros/select.rs:660
// 单独 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(tokio/src/macros/select.rs:649,第一个分支优先) - 没有 →
start = thread_rng_n(BRANCHES)(随机起点,公平;thread_rng_n在tokio/src/macros/support.rs:6,实现在tokio/src/runtime/context.rs:129)
其他参数格式全部转发给 "@" 前缀的规则——后者做归一化。
@ 前缀约定
展开规则用 @ 前缀——例如 (@ { start=$start:expr; $($t:tt)* } $p:pat = ... )。
这是 macro_rules! 社区约定:"带 @ 的规则是内部递归、用户不应该直接调用"。编译器不检查这个约定——只是社区惯例。
为什么需要这个约定:macro_rules! 没有"私有规则"的语法——所有规则都暴露给用户。用 @ 明确标记"这是内部递归"让用户和其他宏代码区分。
这种"社区约定替代语言缺失特性"的做法在 macro_rules! 生态里很常见。
宏的错误信息挑战
声明宏的一个痛点:错误信息可能指向宏展开的位置、不是用户代码。
Tokio 的 select! 用了几个技巧让错误更好:
compile_error!("select! requires at least one branch.")捕捉空输入(tokio/src/macros/select.rs:659)- 命名规则让错误提示相对明确
- 但对"分支里用了错误类型" 的错误,仍然可能指向宏内部
这是声明宏 vs 过程宏的主要 trade-off——过程宏能更精细地报错、但实现复杂。Tokio 以声明宏为主,只在两处借过程宏改善报错(见开头那段源码注释),其余部分接受错误信息的不完美。
如果你用 select! 遇到难懂的编译错误,很多时候问题是:分支里的 Future 不满足 Unpin / Send / 类型不匹配 —— 从这几个角度先排查。
14.5 归一化:让所有分支长一个样
"归一化"是宏编程里的一个常见技巧——把用户写的千变万化的语法形式转换成统一的标准形式、后续代码只需要处理标准形式。select! 的归一化做了几件事:把 if pre_cond 条件归一到 true(没 condition 时)、把 => { body } 归一到 => body、把 else 分支插入到展开逻辑末尾。这个阶段看似 trivial、但它让后面的 codegen 代码量大幅减少——如果不先归一、后面的 codegen 要处理 N × M 种形态组合。这是好宏作者的核心功力之一。
用户可以以多种方式写 select! 分支:
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 条(8 条覆盖「带不带 if guard」×「block body 还是 expression body」×「带不带尾逗号」的各种组合,另外 2 条处理「没有 else」和「有 else」的收尾),长这样:
// 补全"无 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.6 生成阶段:展开后的真实代码
这一节是整章的"看真相时刻"——把一个简单的 select! 展开成实际的 Rust 代码。你会看到 select! 展开后居然是一个相当复杂的 poll_fn 调用——包含了随机起点、分支顺序扫描、disabled bitmap 管理、Future 的 Pin 处理。理解了这段展开、你就理解了 select! 的所有运行时行为。读源码时建议不要跳过展开的代码——那些看起来繁琐的逻辑每一行都在做真实的工作。
归一化后的宏进入 @ 前缀的最终规则(tokio/src/macros/select.rs:439 起)——生成大段实际代码。简化版(去掉错误处理等):
// 示意,非逐字:简化自 tokio/src/macros/select.rs:439
(@ {
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! 展开成什么:
# 安装
cargo install cargo-expand
# 在你的项目里跑
cargo expand --bin my_binary
# 或特定模块
cargo expand module::path
看 select! 展开的好处:
- 遇到 select! 编译错误时,看展开代码定位问题
- 学习时对照本章讲解 + 真实展开代码、加深理解
- 检查宏展开的代码大小、性能敏感场景评估开销
展开结果比你想的长——上面那段模板里的 $( ... )* 会按分支数复制一遍(if guard 检查一遍、match branch 的每个 arm 一遍、末尾的 handler 分发一遍),所以分支越多、展开代码越长。但结构始终是同一套:一个 enum、一段 guard 检查、一个 tuple、一个 poll_fn 循环、一个 match。
这种"用工具看清宏真面目"的习惯建议养成——所有复杂宏都值得 cargo expand 一次。
展开后是一段普通 Rust 代码
没有黑魔法。就是:
- 一个 enum
- 几个 if 检查
- 一个 tuple 存 Future
- 一个 poll_fn 循环
- 一个 match 分发
你可以手写这段代码 —— 宏的作用是让你不用写。
select_priv_declare_output_enum!
展开代码最开始这一块:
mod __tokio_select_util {
$crate::select_priv_declare_output_enum!( ( $($count)* ) );
}
这个辅助宏生成一个 enum,每个分支对应一个 variant:
// 示意,非逐字:如果有 3 个分支,tokio-macros/src/select.rs:33 那段 quote! 展开为:
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 {} 把它包进一个内部模块。源码注释(tokio/src/macros/select.rs:460)给出的首要理由是:要进入一个能在 stable 上使用函数式过程宏的上下文——在模块里(item 位置)才能这样调用;顺带的好处是不污染用户的命名空间——用户如果自己有一个 Out 类型不会冲突。
这种"宏内部 mod 包裹辅助类型"的做法很实用。自己写复杂宏时可以学这招——用 mod {} 包裹内部辅助类型。
count! 宏的技巧
展开代码里多次出现 count!( $($skip)* )——这个辅助宏(tokio/src/macros/select.rs:668)把"一串下划线"数成整数:
// 示意,非逐字:tokio/src/macros/select.rs:668
macro_rules! count {
() => { 0 };
(_) => { 1 };
(_ _) => { 2 };
(_ _ _) => { 3 };
// ... 到 64
}
为什么要这么做:因为 macro_rules! 里没有"计数器变量"——只有模式匹配。要"数有多少个 token tree"只能穷举匹配规则。
Tokio 的 count!(tokio/src/macros/select.rs:668)手工列到 64、count_field!(tokio/src/macros/select.rs:868)列到 $var.64、select_variant!(tokio/src/macros/select.rs:1068)列到 _63——select! 最多 64 个分支。源码里那句自嘲的注释(tokio/src/macros/select.rs:663)写得很直白:
// And here... we manually list out matches for up to 64 branches... I'm not
// happy about it either, but this is how we manage to use a declarative macro!
而超过 64 分支的硬约束落在过程宏那一侧——declare_output_enum(tokio-macros/src/select.rs:6)里直接 panic!("up to 64 branches supported")(tokio-macros/src/select.rs:28)。
这是 declarative macro 的限制——procedural macro 能任意计算、declarative 不能。对应的宏设计要在规则数和灵活性之间权衡。64 的上限对实际用途(select 几个 future)够用。
14.7 公平性的实现:start = thread_rng_n(BRANCHES)
这行代码藏着 Tokio 对公平性的承诺——select! 每次从随机起点开始扫描、返回扫到的第一个就绪分支,避免"总是第一个分支胜出"的 starvation 问题。这个设计比想象中微妙——简单的顺序扫描(每次从 branch 0 开始检查)会让后面的分支在高负载下永远饿死。随机起点 + 顺序扫描让各分支长期看都有机会被选中(所有分支都就绪时机会严格均等)。这个"随机化防饥饿"是并发算法里常见的工程技巧——work-stealing scheduler、lock-free queue、load balancer 都用类似思路。
看到 start = $start 那一行——这是 select 公平性的核心。
默认情况:$start = thread_rng_n(BRANCHES) —— 每次 poll_fn 被 poll 时 生成一个 0 到 BRANCHES-1 的随机数。loop 从 start 开始、按 (start + i) % BRANCHES 顺序检查分支。
意义:如果多个分支同时 Ready,哪个被选中取决于随机起点。所有分支都就绪时,每个分支被选中的概率均等;只有部分分支就绪时并不严格均等——紧跟在未就绪分支之后的那个就绪分支会多分到一份起点(3 个分支、第 0 个不就绪时,实测第 1、2 个分支约为 2:1)。
thread_rng_n 的实现
// 来源:tokio/src/macros/support.rs:6
pub fn thread_rng_n(n: u32) -> u32 {
crate::runtime::context::thread_rng_n(n)
}
内部是一个线程本地的 FastRand(tokio/src/runtime/context.rs:129,第 5 章讲过的 fastrand_n)—— 每次调用生成下一个伪随机数,再用乘法加移位映射到 [0, n)(tokio/src/util/rand.rs:77-81,源码注释说效果类似 fastrand() % n、但更快)。
注意"线程本地" —— 每个 worker 线程有自己的 rng 状态。不需要原子操作、不需要同步,开销很低——但不是零:官方文档把"随机数生成有非零 CPU 开销"列为使用 biased; 的理由之一(tokio/src/macros/select.rs:72)。
biased; 的效果
tokio::select! {
biased;
_ = shutdown.recv() => { /* ... */ }
msg = rx.recv() => { /* ... */ }
}
有 biased; 时宏展开为 start = 0——从第一个分支开始。如果第一个 Ready 就立刻返回第一个、永远优先。
用例:shutdown signal 必须优先,不能被其他繁忙的分支饿死。下面这个模式是生产代码里的标配:
loop {
tokio::select! {
biased;
_ = shutdown.recv() => break,
msg = work_queue.recv() => process(msg).await,
}
}
如果不带 biased:起点随机——两个分支都就绪时,每轮只有一半概率先检查 shutdown,另一半概率先处理一条 work_queue 消息,关停会被推迟不确定的若干轮。
biased; 适合关键分支需要确定优先的场景——关键分支(shutdown、高优先级消息等)放在最前面并加 biased。
公平性的统计实验
为了让 "随机 start 的公平性"具象化,做一个简单实验:
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]
预期分布:三个计数应该各占三分之一左右,波动来自伪随机序列本身。这不是"实测的某个数字",而是 start = thread_rng_n(3) 这个式子的直接推论——起点均匀,第一个被 poll 到的分支就均匀。
对比 biased 模式:
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.8 disabled bitmap:记录已处理分支
disabled bitmap 是 select! 的隐形状态——它记录哪些分支已经返回 Ready、不应该再被 poll。这是一个非常巧妙的设计。如果没有这个 bitmap、一个已经完成的分支可能被再次 poll、而 Future 的合约规定"Ready 后不应再 poll"——这是一个违反 Future trait 合约的 bug。disabled 通过一次性位运算就防住了这个陷阱。
展开代码里这行:
let mut disabled: __tokio_select_util::Mask = Default::default();
Mask 的具体类型由过程宏 declare_output_enum 按分支数生成(tokio-macros/src/select.rs:18-tokio-macros/src/select.rs:28:≤8 个分支是 u8,往上依次是 u16 / u32 / u64,超过 64 直接 panic)——每个分支占一个 bit。
disabled 被设置的三种情况:
- if guard 返回 false:预先置 disabled(那条分支永不 poll)
- 分支 Ready:置 disabled(下次不再 poll、如果在 else 路径走)
- 模式匹配失败:即 Future 返回 Ready 但
$bind:pat不匹配(比如Some(x) = rx.recv()返回 None)——分支也被 disabled
每次 poll 前 check:
if disabled & mask == mask { continue; }
disabled 的意义:如果所有分支都 disabled,select 返回 else 分支——没写 else 时那一支被宏填成 panic!("all branches are disabled and there is no else branch")(tokio/src/macros/select.rs:612),文档里也把这条写明了(tokio/src/macros/select.rs:85)。
整个机制让 select! 行为确定:
- Ready 分支只被处理一次
- 模式不匹配的 Ready 被忽略
- if guard false 的分支不 poll
- 全 disabled 走 else
disabled bitmap vs HashMap<Index, Bool>
如果分支数很多,你可能想:为什么不用 HashMap<usize, bool>?
因为 bitmap 在这里是降维打击:
- bit 检查是一条
&指令;HashMap 查一次要算 hash、访存、可能还有冲突链 - bitmap 就是栈上一个
u8~u64整数,零堆分配;HashMap 起步就要一次堆分配 - 分支数编译期已知且不超过 64——bitmap 的容量上限天然够用
HashMap 只在"键空间不确定、规模会增长"时才值得——select! 的分支是编译期常量,bitmap 完美。
这是"按已知约束选最优数据结构"的例子——编译期常量 N 用 bitmap、动态规模用 HashMap。识别约束 = 做出正确选择。
14.9 分支 Future 的 cancel safety
这是 select! 使用里第一大陷阱——"cancel-safe"(取消安全)是所有分支 Future 的隐含契约、但它不写在类型系统里(没有 CancelSafe trait)、完全靠开发者自觉遵守。因此你必须记住:Tokio 所有 channel 的 recv 是 cancel-safe 的;sleep 是;timeout 包装的东西可能不是;自己写的带内部状态的 async fn 可能不是。本节把这个契约讲透——让你以后看到 select! { ... recv() => ... } 时不再犹豫它是否安全。
select! 最重要的使用要求:在循环里反复 select 时,分支的 Future 必须 cancel-safe。
Tokio 的 channel 接收侧、timer、以及"读一次就返回"那类 I/O(read / read_buf / write)都标了 cancel-safe(第 13 章讲过)。但 "读满为止"那类组合方法恰恰相反——read_exact、read_to_end、read_to_string、read_u32 这些的文档里写的是 "This method is not cancellation safe...then some data may already have been read into buf"(tokio/src/io/util/async_read_ext.rs:282):被取消时已经读进去的字节就丢了。所以"Tokio 的东西都能随便放进 select!"是不成立的,用之前去看那个方法的 # Cancel safety 段。
为什么必须 cancel-safe:select 返回 Ready 时,其他分支的 Future 被 drop。如果那些 Future 在 drop 时丢数据(比如 recv 拿了一半消息但没完成 push 到 buffer),下次就丢了。官方文档也补了一句:取消一个非 cancel-safe 的操作不一定是错——比如程序正在关停,丢掉读了一半的数据往往无所谓(见 select! 文档 Cancellation safety 段的末段);真正要紧的是在循环里反复 select、还指望下一轮接着上一轮进度的场景。
而你自己写的 async fn 默认不 cancel-safe——想想这段:
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 要落到 "函数在哪个 await 点被 cancel" 上。同一个 async fn,在某些 await 点 cancel 安全、在另一些不安全;只有每个 await 点都安全,整个函数才算 cancel safe。
看这个例子:
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 的修改原子化(两步修改之间不夹
.await,一次做完) - 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 外。
// 推荐
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 等)。
这段类型选择就写在 tokio-macros/src/select.rs 里(逻辑原样,排版压缩成单行):
// Use a bitfield to track which futures completed
let mask = Ident::new(
if branches <= 8 { "u8" }
else if branches <= 16 { "u16" }
else if branches <= 32 { "u32" }
else if branches <= 64 { "u64" }
else { panic!("up to 64 branches supported"); },
Span::call_site(),
);
顺便注意最后那个 else 分支——这就是 64 分支上限的执行者。
14.10 Pin<&mut fut>:为什么 unsafe
select! 内部使用了 unsafe 代码——这对很多 Rust 开发者来说很意外(他们一直以为 select! 是 100% safe 的)。原因是:select! 需要在栈上创建 Future、然后把它 Pin 起来 poll——这需要 Pin::new_unchecked 这个 unsafe API(因为编译器没法证明栈上 Future 不会被 move)。select! 的宏作者手动证明了所有 invariant 成立(Future 不可能被 move、因为宏生成的代码明确控制了它的位置)、因此这个 unsafe 是受控的 unsafe。这种"内部 unsafe + 对外 safe API"的模式在 Rust 系统库里无处不在。
展开代码里这行:
let mut fut = unsafe { Pin::new_unchecked(fut) };
为什么要 unsafe?因为 Pin::new 只接受 Unpin 的类型,Box::pin 要堆分配,而安全的 pin! 宏只能 pin 整个值——源码注释(tokio/src/macros/select.rs:502-504)说明:标准库没有办法对 tuple 的字段做 pin 投影,所以不能用 pin!。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 的作用
展开代码里:
let mut futures = ($( IntoFuture::into_future(...), )+);
为什么需要 IntoFuture::into_future?
因为 select! 的分支不一定是 Future——可能是 "能转成 Future 的东西":
async { ... }→ 展开成 Future(自动)- 实现 Future 的类型(比如 JoinHandle)→ 直接 Future
- 只实现了 IntoFuture 而不是 Future 的类型 → 需要
.into_future()
IntoFuture::into_future(x) 的好处:
- 如果 x 已经是 Future,
into_future是 noop——std 有一条 blanket implimpl<F: Future> IntoFuture for F(library/core/src/future/into_future.rs:138),其中type IntoFuture = F(library/core/src/future/into_future.rs:140)、fn into_future(self) -> Self::IntoFuture直接返回self(library/core/src/future/into_future.rs:142) - 如果 x 是 IntoFuture 但不是 Future,转成 Future
这让 select! 的分支更通用——任何 "能跑起来的异步值" 都行,不只是严格的 Future。
这种"通过 blanket impl 扩展泛型"是 Rust 生态的常见扩展点——IntoIterator、IntoFuture、TryInto 等——让 API 对更多类型友好。
14.11 select! 的常见陷阱
select! 用错会导致非常难调试的 bug——大多数 bug 都是"单机测试没问题、上生产偶发"的类型。本节把真实项目里遇到过的陷阱集中在一起:shutdown 优先级、非 cancel-safe Future 被 cancel、所有权转移、pin 的责任、每次循环重新调用 async fn 丢状态、else 分支的误用、break 的取值。多数配一个"这样会错、这样改对"的对照。
陷阱 1:不带 biased 的 shutdown
// ⚠️ shutdown 响应时机不确定
loop {
tokio::select! {
msg = rx.recv() => process(msg).await,
_ = shutdown.recv() => break,
}
}
修正:加 biased,并把 shutdown 放在第一个分支——不加时起点随机,rx 一直有消息时 shutdown 可能被推迟若干轮才被处理。
陷阱 2:分支 Future 非 cancel-safe
// ❌ 错误:复杂 async fn 在 select 里
tokio::select! {
r = my_complex_async_fn() => { /* r */ }
_ = timeout => { /* ... */ }
}
修正:把 my_complex_async_fn 换成简单 Future(channel.recv / timer),或保证它 cancel-safe。
陷阱 3:变量所有权转移
// ❌ 错误: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(宏会替你 pin);但要跨循环复用同一个 Future、在分支里写 &mut fut 时,&mut F 只有在 F: Unpin 时才是 Future,所以不 Unpin 的 Future 需要预先 tokio::pin!:
let fut = my_async_fn();
tokio::pin!(fut);
loop {
tokio::select! {
r = &mut fut => { return r; } // &mut Pin 可以 poll
_ = sleep(Duration::from_secs(1)) => { /* ... */ }
}
}
&mut fut 这种写法的 pin 由用户代码负责——select 宏只 pin 它自己持有的那份(这里是 &mut Pin<&mut F>),不会替你 pin 外面的 fut。
陷阱 5:select 循环里的 fut 被反复创建
// ❌ 每次循环创建新 fut,每次都从头开始
loop {
tokio::select! {
r = some_task() => break r, // some_task() 每次循环新建
_ = sleep(Duration::from_secs(1)) => continue,
}
}
如果 some_task 内部有累积进度——每次循环丢失。修正:在 loop 外 pin、select 里用 &mut:
let task = some_task();
tokio::pin!(task);
loop {
tokio::select! {
r = &mut task => break r, // 每次循环 poll 同一个 task
_ = sleep(Duration::from_secs(1)) => continue,
}
}
陷阱 6:else 分支的误用
tokio::select! {
r = rx.recv() => { /* ... */ }
else => { /* 什么时候走这里?*/ }
}
else 不是"所有分支都 Pending 时"——是"所有分支 disabled 时"。一个分支 disabled 的情况:
- if guard 为 false
- 已经 Ready 过了(但 select 在 Ready 后就返回了,不会再 poll)
- 模式匹配失败
最容易触发 else:所有 if guard 都 false:
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
// ⚠️ 微妙(task 已按陷阱 5 的方式 pin 在循环外)
let result = loop {
tokio::select! {
r = &mut task => break r, // ← break 的值成为整个 loop 的值
_ = sleep(Duration::from_secs(1)) => continue,
}
};
break r 的 r 是分支模式绑定出来的值,也就是 task 的输出:task 是 Future<Output = T>,r: T。break 跳出的是外层 loop——handler 在展开代码里 poll_fn 之外的 match 中执行,select! 自己不引入循环,所以 r 成为整个 loop 的值。
但如果 task 的返回类型复杂,要小心 break 的类型推导。一般 loop + select 模式写成 explicit return 或命名块更清晰:
let result = 'outer: loop {
tokio::select! {
r = &mut task => break 'outer r,
_ = sleep(Duration::from_secs(1)) => continue,
}
};
这些陷阱覆盖了 select! 使用中最常见的一批 bug——知道它们存在 = 避免大多数 select 相关问题。
14.12 对比:Go select 和 Tokio select! 的本质差异
Go 的 select 是语言关键字——Go runtime 对它有特殊处理(编译成专门的 runtime 调用);Tokio 的 select! 是普通宏——展开成普通 Rust 代码、runtime 对它没有任何特殊处理。这个根本差异带来一些有意思的不对称:Go select 可以真正"阻塞当前 goroutine 等多个 channel"(runtime 原生支持);Tokio select! 是"poll 多个 Future"(没有阻塞概念、只是 pending 直到某个 ready)。两种模型各有优势——但理解差异能让你写跨语言的 async 代码时不会踩坑。
Go 的 select { case <-ch1: ... } 和 Tokio 的 tokio::select! { ... } 表面极像、本质有微妙差别:
| 维度 | Go select | Tokio select! |
|---|---|---|
| 实现层级 | 语言关键字,runtime 内置 | macro_rules! 库宏 |
| 公平性 | runtime 在所有就绪的 case 里随机挑一个 | 随机起点 + 顺序扫描,返回扫到的第一个就绪分支 |
| 默认偏好 | 无 | 无 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.13 一个深入问题:为什么 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.14 select! 的 "姐妹宏":tokio::join! 和 tokio::try_join!
Tokio 还有两个形状相似、语义不同的宏:
tokio::join!:等所有 Future 都 Ready
let (a, b, c) = tokio::join!(fut1, fut2, fut3);
- 所有分支并发 poll(单 task 内的 interleaving)
- 等到所有都 Ready 才返回
- 返回值是 tuple (a.Output, b.Output, c.Output)
tokio::try_join!:同上,但任一返回 Err 就短路
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.15 和这个系列的其他专栏的关联
select! 的 「声明宏 + tt-muncher」设计和 《Rust 编译器与运行时揭秘》第 14 章(宏系统:编译期的元编程引擎) 直接衔接——那章讲 macro_rules! 的 @ 内部规则、递归宏、count_tts! 计数技巧等。select! 是其中模式的大规模综合应用——读那章后再看本章,你会惊叹"原来我学的所有宏模式都在这里用了"。
14.16 select! 的开销构成
展开后的 select! 每次被 poll 做的事,从代码上就能一条条数出来(本机没有可复现的基准环境,这里只谈做了什么,不给纳秒数):
- 算一个随机起点:
thread_rng_n(BRANCHES)——线程本地的 FastRand 取一次下一个数、再用乘法加移位映射到分支范围,没有原子操作(每线程首次使用时要播种一次) - 循环最多 N 个分支(N = 分支数):每个分支先看一眼
disabled的对应 bit,一条&指令 - 对未 disabled 的分支调
Future::poll——这一步的成本完全取决于分支里那个 Future,宏本身管不着 - 其余是 tuple 字段访问和
match分发,编译期就定死了跳转目标
结论:select! 宏自身的开销是「一次取随机数 + N 次位检查」,几乎全部实际成本来自「把每个未完成的分支 poll 一遍」——而这是语义上必须做的事:你要知道哪个分支就绪,就得问它。
分支数量越多越慢
因为每次 poll 都要顺序检查所有未 disabled 的分支——N 增加、单次 poll 的工作量线性增加。
经验法则:select! 的分支数控制在个位数。要同时等几十上百个同类型的 Future,换 FuturesUnordered(第 15 章 §15.10 有它和 JoinSet 的对比)——它的关键差别不是「查找复杂度更低」,而是只 poll 被唤醒过的那些 Future(内部维护一个「已被 wake」的侵入式链表),而不是每次把所有 Future 扫一遍。
select! 适合:
- N 少(2-5 个)
- 每个分支是不同类型的 Future(timer、channel、I/O)——这正是
select!要用 enum 收敛输出类型的原因
FuturesUnordered 适合:
- N 多(几十到几百)
- 所有 Future 同类型
- 不需要「哪个先被检查」的偏好
14.17 tokio::select! 的一个反常设计:每次 poll 都重新扫一遍
有个精细点值得讨论:select! 的 poll_fn 里每次被 poll 都重新取一个 start、从它开始扫一遍所有未 disabled 的分支——而不是记住上次 Pending 的位置继续。
为什么?因为 Future 的 poll 语义:被 Waker 唤醒后必须重新 poll(哪个分支 Ready 不确定)。记住上次位置没意义——所有分支都可能变化。
代价:N 次分支检查(但大多数时候分支是 Pending、poll 开销小)。 收益:逻辑正确、没有 "漏 wake" 问题。
这是"正确性优于性能"的例子——有时候"多做一点" 换 "稳定简单"——值得。
14.18 真实项目里 select! 的五种常见形态
前面讲的都是单个 select! 用法——这一节把视角提升到"整个应用怎么组织 select!"。真实项目里 select! 不是孤立使用的、通常以五种固定模式出现:(1)主循环 + 信号关停、(2)超时包装(select timeout + future)、(3)竞速(select 多个请求取最快的)、(4)多源融合(loop select 多个 channel)、(5)心跳 + 工作(定时器与消息交替处理)。这五种模式覆盖了绝大多数 select! 使用场景——模板化后写 Tokio 服务的速度会大幅提升。
把生产项目里的 select! 归类,基本跑不出下面五种范式。列出来,既能让你在读别人代码时一眼识别意图,也能在自己写时避免重新发明轮子。
形态 1:主循环 + 信号关停
loop {
tokio::select! {
biased;
_ = shutdown.changed() => break,
msg = rx.recv() => handle(msg).await,
}
}
关键点:biased; 保证每轮先检查 shutdown——否则在大流量下 shutdown 信号的处理会被 rx.recv() 的随机起点推迟(rx 一直有数据时,每轮都有 1/2 概率先轮到它,关停要多等不确定的若干轮)。凡是「一边干活一边要能优雅停下来」的循环,都是这个形态。
形态 2:超时包装
let result = tokio::select! {
r = work => Ok(r),
_ = tokio::time::sleep(Duration::from_secs(5)) => Err(Timeout),
};
效果上接近 tokio::time::timeout(…),但 timeout 并不是用 select! 实现的:它是一个独立的 Timeout Future(tokio/src/time/timeout.rs:190-228),每次 poll 固定先 poll 内部 future、再 poll 定时器,还专门处理了协作式预算耗尽的情况——不存在随机起点。
形态 3:首个到达者胜出(race)
tokio::select! {
r = primary_db.query(sql) => r,
r = replica_db.query(sql) => r,
}
注意 cancel safety——没抢到的那条 query 会被 drop、中途 cancel。这对幂等只读查询安全、对有副作用的写操作危险:被 drop 的那条写请求可能已经发出去了,你只是不再等它的响应。所以竞速只适合读路径。
形态 4:多源融合
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,在 else 里 break 即可优雅退出)。这种"N 路输入合成 1 路处理"在网关、代理里几乎每天都出现。
形态 5:心跳 + 工作两条腿走路
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 的(tokio/src/time/interval.rs:410-413:被取消时不会消耗 tick)——下一次到点的时刻记在 Interval 自身,即便 30 秒内被 rx.recv() 分支"抢走"无数次、被 drop 的只是这一次 tick() 的 Future,下次依然按原节奏触发。自己用 sleep(30s) 手写心跳会被 select 每次 cancel、时间永远重置——这是初学者常踩的坑。
这五种形态覆盖了绝大多数 select! 用例。把它们记住,读代码时就能快速把 select! 块映射到意图、而不是每次从零解析 tt-muncher。
14.19 从宏展开看「零成本抽象」的真实含义
很多人第一次读完 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! 展开后,只是一段静态已知的轮询代码跑在调用者自己的协作线程里——没有队列、没有锁、没有堆分配。这种"抽象完全消失在编译期"的特性,是系统级语言才有的奢侈。
宏、语言特性、运行时的三角边界
select! 停留在库里而没有变成语言特性,本身就是一个值得琢磨的决定。它需要的所有能力——多路输出类型收敛、cancel 时正确 drop、随机起点、模式匹配过滤——都能用现有的宏机制表达;而做成语言内置则会锁死某些决策(公平算法怎么选、偏置语法长什么样),让后来者无法演进。
对比一下就更清楚:biased; 这个修饰符是后加的(tokio/CHANGELOG.md 1.4.0 条目:"macros: introduce biased argument for select! (#3603)")。如果 select 当年是个关键字,加这么一个修饰符就得走语言 RFC 流程;作为宏,它只是在入口规则里多匹配一个 biased;。能用库解决就不动语言——这份克制换来的是演进空间。
14.20 一个容易被低估的细节:select! 如何让编译器帮你查错
当你在 select! 的某个分支里写下 r = rx.recv() => { use_r(r); },如果忘记处理 r 是 Option<T>(channel 关闭返回 None)的情况,编译器会给出一条完全可读的错误信息——不是指向宏内部某个无名临时变量,而是直接指向你的 use_r(r) 那一行、告诉你类型不匹配。这件事背后的工程量远超想象。
macro_rules! 宏的错误提示在 Rust 历史上一直是痛点:用户写错一个 token,编译器往往会把错误指向宏展开后的某段天书、让你根本看不懂。select! 用了几个具体手段来对抗这一点,都能在源码里直接看到:
- 把「用户能写的形态」全部穷举成归一化规则(那 10 条
@规则)——用户的$h:block/$h:expr原样传递下去,handler 里的类型错误因此仍然指向用户自己那一行 - 一条
compile_error!兜底空输入:"select! requires at least one branch." - 把一部分工作交给过程宏——源码注释写得很直接:
a procedural macro is used to enable improved error messages。select_priv_clean_pattern!用syn解析用户的绑定模式、去掉ref/mut;解析不了时它把输入原样交回,错误由编译器在模式所在位置报出(tokio-macros/src/select.rs:46-49) - 最后那个
_ => unreachable!("failed to match bind")是为了让match穷尽:用户模式可能是可反驳的(如Some(x)),而进到这里的值在 poll 阶段已经检查过能匹配,运行时走不到这一支
作为读者你未必会去模仿这些技巧,但理解它们的存在能让你在写自己的 macro_rules! 时心里有杆秤:好用的宏 ≠ 写得巧的宏,好用的宏是把错误信息还给用户的宏。
这也解释了 select! 为什么是「声明宏为主 + 过程宏点缀」而不是全过程宏:声明宏保住了展开的可读性和编译期开销,过程宏只在两处出手(按数量生成枚举、清洗绑定模式)。哪一半都不多做——这个边界划得很干净。
和 rust-compiler 专栏的呼应
我们在《Rust 编译器与运行时揭秘》第 14 章(宏系统:编译期的元编程引擎)专门讲过 macro_rules! 的递归宏和计数技巧——那一章的示例展示了和 select! 类似的递归归约模式。两个专栏一起读,你会发现 Tokio 不是「把宏用到极致」、而是「把宏用到恰到好处」——复杂度刚好够用、再多一分就会伤到可读性。这种克制,正是「大师级基础设施」和「聪明人炫技玩具」的分界线。
14.21 本章小结
select! 是 Tokio 里最强大也最危险的宏——用对了它让异步代码优雅简洁、用错了它会在生产环境引发难以调试的 bug。关键 take-home:
1. 循环里反复 select 的分支 Future 必须 cancel-safe——这是 select! 使用的第一铁律。如果不确定某个 Future 是不是 cancel-safe、先假设它不是:把它 pin 在循环外、在 select! 里用 &mut 反复 poll 同一个实例(见陷阱 5),或者把它移出 select!。
2. 理解宏展开能帮你避开大多数陷阱——select! 展开后就是普通的 poll_fn + 状态机、没有任何魔法。一旦你在脑子里能"手动展开" select!、使用它就会变得直觉化。
3. 五种常见形态覆盖绝大多数场景——优雅关停 / 超时包装 / 竞速 / 多源融合 / 心跳加工作。熟练这五个模板、你写 select! 的速度和正确性会上一个台阶。
4. biased 修饰符在"优先级明确"场景下很有用——但默认的随机公平更符合通用需求。不要为了"确定性"滥用 biased——随机公平是 Tokio 深思熟虑的默认。
join! / try_join! 这两个「全部完成」版的姐妹宏已在 §14.14 介绍,下一章不再展开。异步编程里组合 Future 是核心能力,本章加下一章基本覆盖了日常的 Future 组合需求。
带走三件事:
select!的主体是一个 237 行的macro_rules!宏(外加两个tokio-macros里的过程宏帮忙生成输出枚举、清洗绑定模式)——通过 tt-muncher 归一化 + 生成poll_fn循环。展开后是普通 Rust 代码、没有黑魔法- 公平性靠
start = thread_rng_n(BRANCHES)——默认每次 poll 随机选起点、所有分支都就绪时各分支概率均等。biased;改为 start=0、严格顺序、用在关键分支优先场景 - 循环里反复 select 的分支 Future 必须 cancel-safe——Tokio 的 channel 接收侧、timer、单次
read/write都标了 safe,但read_exact/read_to_end这类"读满为止"的组合方法官方明写 not cancellation safe,自己写的 async fn 更是默认不 safe——在 select! 里等这类业务 future 时要特别小心
下一章进入 JoinHandle / JoinSet / AbortHandle——Task 集合管理的几个原语。你会看到 JoinSet 内部的 IdleNotifiedSet 如何用「已唤醒 / 空闲」两条链表高效管理一组 Task、AbortHandle 如何无侵入地取消 Task。
延伸阅读
- Tokio 源码:
tokio/src/macros/select.rs - Tokio 源码:
tokio/src/macros/support.rs - 《Rust 编译器与运行时揭秘》第 14 章(宏系统:编译期的元编程引擎):macro_rules! 的 tt-muncher 与计数技巧
- Tokio 文档:
tokio::select!宏文档 —— 含官方对 cancel safety 与公平性的说明