Tokio 源码深度解析

第14章 select! 宏展开与公平调度

作者 杨艺韬 · 12,785 字 · 发布于 · 更新于

本章要点

  • 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 做:

  1. 从 start 开始循环所有分支
  2. 对每个分支:检查 disabled → poll Future → Ready 就返回 / Pending 就标记 is_pending 后 continue
  3. 所有分支都 Pending:返回 Pending
  4. 所有分支 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 被设置的三种情况:

  1. if guard 返回 false:预先置 disabled(那条分支永不 poll)
  2. 分支 Ready:置 disabled(下次不再 poll、如果在 else 路径走)
  3. 模式匹配失败:即 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。

安全性保证:

  • futures tuple 通过 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 impl impl<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 为什么自己做一套?

几个原因:

  1. API 风格一致性:tokio::select! 和 tokio::join! 等宏风格统一
  2. 性能优化:Tokio 自己可以针对其 runtime 优化(比如 thread_rng_n 走 Tokio 的 thread-local)
  3. 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 就返回、其他 drop
  • join!:等所有 Ready、没有 cancel
  • try_join!:第一个 Err 返回、其他 drop(有 cancel)

三者覆盖并发 Future 组合的主要场景——记住这三个、大多数并发组合需求都有对应工具。


14.15 和这个系列的其他专栏的关联

select! 的 「声明宏 + tt-muncher」设计和 《Rust 编译器与运行时揭秘》第 14 章(宏系统:编译期的元编程引擎) 直接衔接——那章讲 macro_rules! 的 @ 内部规则、递归宏、count_tts! 计数技巧等。select! 是其中模式的大规模综合应用——读那章后再看本章,你会惊叹"原来我学的所有宏模式都在这里用了"。


14.16 select! 的开销构成

展开后的 select! 每次被 poll 做的事,从代码上就能一条条数出来(本机没有可复现的基准环境,这里只谈做了什么,不给纳秒数):

  1. 算一个随机起点:thread_rng_n(BRANCHES)——线程本地的 FastRand 取一次下一个数、再用乘法加移位映射到分支范围,没有原子操作(每线程首次使用时要播种一次)
  2. 循环最多 N 个分支(N = 分支数):每个分支先看一眼 disabled 的对应 bit,一条 & 指令
  3. 对未 disabled 的分支调 Future::poll——这一步的成本完全取决于分支里那个 Future,宏本身管不着
  4. 其余是 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 组合需求。

带走三件事:

  1. select! 的主体是一个 237 行的 macro_rules! 宏(外加两个 tokio-macros 里的过程宏帮忙生成输出枚举、清洗绑定模式)——通过 tt-muncher 归一化 + 生成 poll_fn 循环。展开后是普通 Rust 代码、没有黑魔法
  2. 公平性靠 start = thread_rng_n(BRANCHES)——默认每次 poll 随机选起点、所有分支都就绪时各分支概率均等。biased; 改为 start=0、严格顺序、用在关键分支优先场景
  3. 循环里反复 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。


延伸阅读