select! 与 join!
主线位置:任务主线的「
Harness::poll→ 用户 future」内部。这两个宏不 spawn 任何东西,所有分支都在当前任务的同一次 poll 里被轮流 poll。
flowchart TB
start[进入 poll_fn] --> budget{预算还有?}
budget -->|没有| pend0[返回 Pending]
budget -->|有| pick[选起点:随机或 0]
pick --> lp[从起点轮流看每个分支]
lp --> dis{已禁用?}
dis -->|是| lp
dis -->|否| poll[poll 这个分支]
poll -->|Pending| lp
poll -->|Ready| mark[禁用它]
mark --> pat{匹配模式?}
pat -->|否| lp
pat -->|是| win[返回 Out 的对应变体]
lp -->|看完一圈| rest{有 Pending 的吗?}
rest -->|有| pend1[返回 Pending]
rest -->|没有| els["Out::Disabled,走 else"]
win --> drop[drop 全部分支 future]
drop --> handler[运行这个分支的处理代码]
select! 的宏结构
select! 是一个声明宏,写成 tt-muncher:入口规则先在输入前面插一个 @{ ... },然后每次递归吃掉一个分支,把它改写成统一形式,追加到 @{ } 里。全部改写完,才进入真正生成代码的那条规则。
入口有三条(第 797 行起):
($(biased;)? else => $else:expr $(,)? ) => {{
$else
}};
(biased; $p:pat = $($t:tt)* ) => {
$crate::select!(@{ start=0; () } $p = $($t)*)
};
( $p:pat = $($t:tt)* ) => {
// Randomly generate a starting point. This makes `select!` a bit more
// fair and avoids always polling the first future.
$crate::select!(@{ start={ $crate::macros::support::thread_rng_n(BRANCHES) }; () } $p = $($t)*)
};
biased; 和默认模式的唯一区别是 start 这个表达式:常量 0,或者一次随机数调用。注意 start 是作为表达式传下去的,它在 poll_fn 的闭包里求值,所以默认模式每次 poll 都重新随机,不是每次 select! 只随机一次。
改写规则(第 763 行起)把每个分支补齐成 ( _ _ ) pat = fut, if cond => handler, 的形式:
- 没写
if的分支补上if true。 - 括号里的
_个数等于这个分支前面有几个分支。它有两个用途:交给count!变成分支下标(count!是手写的 65 条规则,从 0 个_到 64 个,源码里有一句「I'm not happy about it either」);以及生成let ( _, _, fut, .. ) = &mut *futures;这样的元组模式,取出第 n 个 future。 - 没有
else的,else补成panic!("all branches are disabled and there is no else branch")。
源码开头的注释说明了为什么这样写:每个分支只递归一次,用户不需要调高 recursion_limit。
问: select! 的 else 是不是 Go 里 select 的 default:所有分支都还没就绪,就走 else?
答: 不是。下一节展开后的代码里,一圈看下来只要有一个分支返回 Pending(is_pending 为真),整个 poll_fn 就返回 Pending,select! 接着等。只有所有分支都被禁用了(if 条件为假,或者完成了但模式不匹配),才返回 Out::Disabled,走 else。所以 else 的意思是「没有分支还能产生结果」,不是「现在没有结果」。想要「都没就绪就立刻去做别的」,可以用 biased;,在最后放一个永远就绪的分支 _ = std::future::ready(()) => { ... }。即使这样,预算用完时 select! 也会先返回 Pending,一个分支都不 poll。
两个过程宏帮手
有两件事声明宏做不了,交给 tokio-macros 里的过程宏:
select_priv_declare_output_enum! 根据 _ 的个数生成输出枚举和位掩码类型(declare_output_enum):
// 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(),
);
TokenStream::from(quote! {
pub(super) enum Out<#( #variants ),*> {
#( #variants(#variants), )*
// Include a `Disabled` variant signifying that all select branches
// failed to resolve.
Disabled,
}
pub(super) type Mask = #mask;
})
三个分支生成 enum Out<_0, _1, _2> { _0(_0), _1(_1), _2(_2), Disabled }。每个分支的输出类型不同,所以用泛型参数,由编译器推断。声明宏没法凭空造出 _0、_1 这样的标识符,这是要用过程宏的原因。select! 最多 64 个分支,就是因为掩码最大是 u64。
select_priv_clean_pattern! 把模式里的 ref 和 mut 去掉(clean_pattern)。原因在下一节:宏要先拿 &out 试一下模式能不能匹配。对一个引用做匹配时,绑定默认是按引用的;模式里的 mut x 会把它改回按值绑定,变成从引用里移出值,非 Copy 类型编译不过(Rust 2024 起,这个位置写 mut、ref 都直接报错)。试匹配只关心「匹配不匹配」,不需要绑定,去掉这两个修饰符不影响结果。真正绑定变量时用的仍是用户原来的模式。
展开后的主体
生成代码的规则从 第 590 行 开始。先处理前置条件:
const BRANCHES: u32 = $crate::count!( $($count)* );
let mut disabled: __tokio_select_util::Mask = Default::default();
// First, invoke all the pre-conditions. For any that return true,
// set the appropriate bit in `disabled`.
$(
if !$c {
let mask: __tokio_select_util::Mask = 1 << $crate::count!( $($skip)* );
disabled |= mask;
}
)*
if 条件只在进入 select! 时求值一次,为假的分支直接置位禁用。但紧接着的 let futures_init = ($( $fut, )+); 会把所有分支的 future 表达式都求值一遍,包括被禁用的。所以 if 只能阻止 poll,不能阻止 future 被创建:opt.unwrap().recv(), if opt.is_some() 这种写法在 opt 为 None 时照样会 panic。
所有 future 放在一个元组里,存在外层 async 函数的状态机里,之后不再移动,所以可以用 Pin::new_unchecked。然后是 poll_fn:
$crate::macros::support::poll_fn(|cx| {
// Return `Pending` when the task budget is depleted since budget-aware futures
// are going to yield anyway and other futures will not cooperate.
$crate::macros::support::ready!($crate::macros::support::poll_budget_available(cx));
// ...
let start = $start;
for i in 0..BRANCHES {
let branch;
#[allow(clippy::modulo_one)]
{
branch = (start + i) % BRANCHES;
}
match branch {
$(
#[allow(unreachable_code)]
$crate::count!( $($skip)* ) => {
// ...
let mask = 1 << branch;
if disabled & mask == mask {
// The future has been disabled.
continue;
}
// ...
let ( $($skip,)* fut, .. ) = &mut *futures;
// ...
let mut fut = unsafe { $crate::macros::support::Pin::new_unchecked(fut) };
// Try polling it
let out = match $crate::macros::support::Future::poll(fut, cx) {
$crate::macros::support::Poll::Ready(out) => out,
$crate::macros::support::Poll::Pending => {
// ...
is_pending = true;
continue;
}
};
// Disable the future from future polling.
disabled |= mask;
// ...
match &out {
$crate::select_priv_clean_pattern!($bind) => {}
_ => continue,
}
// The select is complete, return the value
return $crate::macros::support::Poll::Ready($crate::select_variant!(__tokio_select_util::Out, ($($skip)*))(out));
}
)*
_ => unreachable!("reaching this means there probably is an off by one bug"),
}
}
if is_pending {
$crate::macros::support::Poll::Pending
} else {
// All branches have been disabled.
$crate::macros::support::Poll::Ready(__tokio_select_util::Out::Disabled)
}
}).await
逐条看:
- 预算。 第一行先查协作式预算,用完了就整体返回
Pending,一个分支都不 poll。理由见协作式调度预算。 - 起点。 从
start开始绕一圈。for里的match把运行时的下标映射到编译期展开的各个分支。 - Ready 就禁用。 一个分支返回
Ready后立刻置位,不管模式匹不匹配,它都不会再被 poll。一个已经完成的 future 再 poll 是违反契约的。 - 模式不匹配就跳过。 比如
Some(v) = rx.recv()遇到None:这个分支被禁用,它的输出out在continue时被 drop,循环接着看下一个分支。 - 全部禁用。 一圈下来既没有分支匹配、也没有分支
Pending,返回Out::Disabled,执行else;没写else就是上面那句 panic。
poll_fn 外面还包着一层块:
// Create a scope to separate polling from handling the output. This
// adds borrow checker flexibility when using the macro.
let mut output = {
// ...
let mut futures = &mut futures;
$crate::macros::support::poll_fn(|cx| { /* ... */ }).await
};
match output {
$(
$crate::select_variant!(__tokio_select_util::Out, ($($skip)*) ($bind)) => $handle,
)*
__tokio_select_util::Out::Disabled => $else,
_ => unreachable!("failed to match bind"),
}
futures 元组在这个块结束时被 drop,然后才 match output 运行处理代码。所以进入任何一个分支的处理代码时,所有分支的 future(包括没完成的)都已经销毁了。这让处理代码可以重新借用 future 借过的东西(比如在 rx.recv() 分支的处理代码里再用 rx),也正是下面 cancel safety 问题的根源。
随机起点
thread_rng_n 用的是线程本地 CONTEXT 里的 FastRand,一个 xorshift 生成器,取模用 Lemire 的乘法移位代替除法,每次调用只有几条整数指令。
为什么默认随机:如果总是从第一个分支开始,而第一个分支一直就绪(比如一个消息不断的 channel),后面的分支永远轮不到。随机起点让每个一直就绪的分支有相同的机会。
实验,三个永远就绪的分支:
use std::future::ready;
#[tokio::main(flavor = "current_thread")]
async fn main() {
let mut wins = [0u32; 3];
for _ in 0..30_000 {
let i = tokio::select! {
v = ready(0) => v,
v = ready(1) => v,
v = ready(2) => v,
};
wins[i] += 1;
}
println!("random start: {wins:?}");
let mut wins = [0u32; 3];
for _ in 0..30_000 {
let i = tokio::select! {
biased;
v = ready(0) => v,
v = ready(1) => v,
v = ready(2) => v,
};
wins[i] += 1;
}
println!("biased: {wins:?}");
// a branch whose output does not match its pattern is disabled, not chosen
let r = tokio::select! {
Some(v) = ready(None::<u32>) => format!("got {v}"),
else => "all branches disabled -> else".to_string(),
};
println!("{r}");
}
random start: [9916, 10062, 10022]
biased: [30000, 0, 0]
all branches disabled -> else
biased; 适合两种情况:分支之间确实有优先级(比如关闭信号要优先于数据);或者需要确定的行为来复现问题。代价是排在前面的分支一直就绪时,后面的分支会饿死。
join! 和 try_join!
join! 的结构类似,也是一个 tt-muncher 加一个 poll_fn,但每个 future 外面包一层 MaybeDone:
pub enum MaybeDone<Fut: Future> {
/// A not-yet-completed future.
Future { #[pin] future: Fut },
/// The output of the completed future.
Done { output: Fut::Output },
/// The empty variant after the result of a [`MaybeDone`] has been
/// taken using the [`take_output`](MaybeDone::take_output) method.
Gone,
}
select! 只要一个结果,join! 要全部结果,而先完成的 future 的输出要找个地方存着,等最慢的那个。MaybeDone 完成时把自己从 Future 变成 Done { output },原 future 的内存就地复用来放输出;之后再 poll 它直接返回 Ready(())。全部完成后,用 take_output 逐个取出,组成元组返回。
每次 poll 的起点由 Rotator 决定:
pub fn num_skip(&mut self) -> u32 {
let num_skip = self.next;
self.next += 1;
if self.next == COUNT {
self.next = 0;
}
num_skip
}
不是随机,而是每次 poll 往后挪一个。poll_fn 里有一个 loop,把展开的各分支跑两遍:第一遍跳过前 skip 个,第二遍从头补上,to_run 数到 0 就停,保证每个 future 每次恰好 poll 一次。biased; 换成永远返回 0 的 BiasedRotator。
源码注释说明了轮换的目的:「to ensure every future passed to join! can make progress even if one of the futures consumes the whole budget」。和 select! 不同,join! 开头不检查预算:它要的是所有分支都完成,预算耗尽时排在前面的分支扣光预算,后面的分支(只要它们会看预算)就返回 Pending。如果起点固定,后面的分支每次都分不到预算;轮换让每个分支轮流排第一。
join! 每次都从第一个开始 poll2022 年 4 月的 issue #4612(标题「Fairness problem with semaphore acquire」)给了一个两分支的 join!:第一个分支在死循环里 acquire_owned() 一个有 10 个许可的 Semaphore,拿到就丢掉,循环里没有别的 .await;第二个分支每秒 sleep 一次,打印 Hello!。报告的现象是第二个分支从来不运行。修复它的 #4624(1.19.0)在 PR 说明里写道:join! 的 future 在同一个任务里跑,共用一份预算,一个 future 可能把预算全部用完,其他的什么也分不到。修法就是本节的轮换。2025 年 6 月的 #7307(1.46.0)又加了 biased;,需要固定顺序时可以关掉轮换。
实验:三个 future 各返回 3 次 Pending(每次都立即唤醒自己),记录 poll 的顺序:
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
// Returns Pending `n` times (waking itself each time), logging each poll.
struct Noisy { name: char, left: u32, log: std::rc::Rc<std::cell::RefCell<String>> }
impl Future for Noisy {
type Output = ();
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
self.log.borrow_mut().push(self.name);
if self.left == 0 {
return Poll::Ready(());
}
self.left -= 1;
cx.waker().wake_by_ref();
Poll::Pending
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
for biased in [false, true] {
let log = std::rc::Rc::new(std::cell::RefCell::new(String::new()));
let mk = |name| Noisy { name, left: 3, log: log.clone() };
if biased {
tokio::join!(biased; mk('A'), mk('B'), mk('C'));
} else {
tokio::join!(mk('A'), mk('B'), mk('C'));
}
let s = log.borrow();
let rounds: Vec<&str> = (0..s.len()).step_by(3).map(|i| &s[i..i + 3]).collect();
println!("{}: {}", if biased { "biased " } else { "default" }, rounds.join(" | "));
}
}
default: ABC | BCA | CAB | ABC
biased : ABC | ABC | ABC | ABC
try_join! 只多一处:某个 future 完成后,用 output_mut() 看一眼结果,是 Err 就立即返回这个错误。其余还没完成的 future 随 join 的 future 一起被 drop,也就是被取消。
join! 的所有分支在同一个任务里并发执行,不并行:同一时刻只有一个分支在跑。需要多核并行就 spawn 多个任务。
还是会返回 Pending 的 Noisy,但这次四个 future 要 Pending 的次数不一样,日志里每一项记下名字和「join! 第几次被 poll」。Rounds 包在 join! 外面,每被 poll 一次就把轮数加一:
struct Noisy { name: char, left: u32, round: Rc<Cell<u32>>, log: Rc<RefCell<Vec<String>>> }
impl Future for Noisy {
type Output = ();
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
let entry = format!("{}{}", self.name, self.round.get());
self.log.borrow_mut().push(entry);
if self.left == 0 {
return Poll::Ready(());
}
self.left -= 1;
cx.waker().wake_by_ref();
Poll::Pending
}
}
struct Rounds<F> { inner: Pin<Box<F>>, round: Rc<Cell<u32>> }
impl<F: Future> Future for Rounds<F> {
type Output = F::Output;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<F::Output> {
self.round.set(self.round.get() + 1);
self.inner.as_mut().poll(cx)
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let round = Rc::new(Cell::new(0));
let log = Rc::new(RefCell::new(Vec::new()));
let mk = |name, left| Noisy { name, left, round: round.clone(), log: log.clone() };
let (a, b, c, d) = (mk('A', 3), mk('B', 0), mk('C', 1), mk('D', 2));
Rounds { inner: Box::pin(async { tokio::join!(a, b, c, d) }), round: round.clone() }.await;
println!("{}", log.borrow().join(" "));
}
前 4 项是 A1 B1 C1 D1。提示:已经完成的 future 被 MaybeDone 挡住,不会再调到 Noisy::poll。答案在本页末尾。
cancel safety
select! 的文档(第 90 行)这样定义:一个还没完成的 future,drop 掉它再重新创建一个,如果效果等于什么都没发生,它就是 cancel safe 的。
上面已经看到,select! 一个分支胜出后,其余分支的 future 被 drop。在循环里用 select! 时,下一轮会重新创建这些 future。问题出在 future 停在第二个或之后的 .await 上时:前面的 .await 已经产生了效果,drop 掉它,这些效果就丢了。
一个具体的例子:从 channel 取一条消息,再做点异步的事(比如写审计日志)才返回:
async fn recv_and_log(rx: &mut mpsc::Receiver<u32>) -> Option<u32> {
let v = rx.recv().await?;
sleep(Duration::from_millis(3)).await; // e.g. write an audit log
Some(v)
}
rx.recv() 本身是 cancel safe 的:它返回 Ready 之前,消息还在 channel 里。但 recv_and_log 不是:消息取出来以后,future 停在 sleep 上,这时被 drop,变量 v 随状态机一起销毁,消息既不在 channel 里,也没交给调用方。
实验:生产者每 4ms 发一条,共 10 条;消费者在循环里 select! 这个函数和一个 5ms 的定时器。为了结果可复现,用了 biased; 和暂停的时钟(start_paused 需要 test-util feature):
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::time::sleep;
// Not cancel safe: the message is already taken out of the channel
// before the second .await. Dropping the future there loses it.
async fn recv_and_log(rx: &mut mpsc::Receiver<u32>) -> Option<u32> {
let v = rx.recv().await?;
sleep(Duration::from_millis(3)).await; // e.g. write an audit log
Some(v)
}
async fn run(safe: bool) {
let (tx, mut rx) = mpsc::channel(16);
tokio::spawn(async move {
for i in 0..10 {
tx.send(i).await.unwrap();
sleep(Duration::from_millis(4)).await;
}
});
let mut got = Vec::new();
let mut ticks = 0;
let mut tick = tokio::time::interval(Duration::from_millis(5));
loop {
tokio::select! {
biased;
v = async { if safe { rx.recv().await } else { recv_and_log(&mut rx).await } } => {
match v { Some(v) => got.push(v), None => break }
}
_ = tick.tick() => ticks += 1,
}
}
println!("{:<14} received {:?} ({} items, {} ticks)",
if safe { "rx.recv():" } else { "recv_and_log:" }, got, got.len(), ticks);
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
run(true).await;
run(false).await;
}
rx.recv(): received [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] (10 items, 9 ticks)
recv_and_log: received [0, 3, 4, 5, 8, 9] (6 items, 9 ticks)
1、2、6、7 四条消息丢了:每一条都是在 recv_and_log 取到消息、正在 sleep 的 3ms 里,定时器先到期,select! 选了定时器分支,drop 了 recv_and_log。去掉 biased; 后,丢哪几条会随随机起点变化,但照样会丢。
两种改法:
- 让
select!分支里只放 cancel safe 的操作,取到消息后在处理代码里做后续的异步工作(处理代码不会被取消)。 - 把不能取消的 future 提到循环外面,用
tokio::pin!固定住,分支里写&mut fut。select!drop 的只是这个引用,future 本身留着,下一轮接着 poll。它完成后要换一个新的 future,否则会 poll 一个已经完成的 future。
tokio 文档里标明的
tokio 在方法文档的 # Cancel safety 一节里逐个写明,select! 的文档也列了一份常用清单。按 1.54.1 的文档:
| cancel safe | 不是 cancel safe |
|---|---|
mpsc::Receiver::recv、recv_many(有界和无界) | AsyncReadExt::read_exact、read_to_end、read_to_string |
broadcast::Receiver::recv | AsyncBufReadExt::read_line |
watch::Receiver::changed | AsyncWriteExt::write_all |
await &mut oneshot::Receiver、&mut JoinHandle | mpsc::Sender::send:没发出去,但消息随 future 一起丢了 |
JoinSet::join_next | mpsc::Sender::reserve:失去排队位置 |
time::Interval::tick | Mutex::lock、RwLock::read / write、Semaphore::acquire、Notify::notified:失去排队位置 |
TcpListener::accept、UnixListener::accept | Barrier::wait |
AsyncReadExt::read、read_buf,AsyncWriteExt::write、write_buf | |
AsyncBufReadExt::fill_buf | |
各 socket 的 readable / writable / ready | |
signal::unix::Signal::recv |
这些区别多数来自同一个原因:cancel safe 的方法在返回 Ready 之前不改变任何外部状态,要么一次做完,要么什么都没做。read_exact 可能已经读了一部分字节到缓冲区里,write_all 可能已经写出去一部分;锁和信号量为了公平维护等待队列,drop 掉等待中的 future 就从队列里出来了,重新创建要排到队尾。
文档也提醒,取消一个不 cancel safe 的操作不一定是 bug:比如程序正在关闭,丢掉读了一半的数据通常无所谓。实际代码里最常见的两种出错写法(read_line 丢掉读了一半的行、每轮重建的 future 永远完成不了)及修法,见常见坑:select! 的取消安全。
动脑筋答案
M2 Max、rustc 1.99.0、--release,连跑 3 次,输出都一样:
A1 B1 C1 D1 C2 D2 A2 D3 A3 A4
join! 一共被 poll 了 4 次,每次的起点往后挪一个:
| 第几次 poll | 起点 | 顺序 | 实际调到 Noisy::poll 的 | 这次完成的 |
|---|---|---|---|---|
| 1 | A | A B C D | A1 B1 C1 D1 | B |
| 2 | B | B C D A | C2 D2 A2 | C |
| 3 | C | C D A B | D3 A3 | D |
| 4 | D | D A B C | A4 | A |
- B 第一次就完成了,变成
MaybeDone::Done。第 2 轮虽然从 B 开始,B 那一格直接返回Ready(()),日志里没有B2。 - 第 3 轮从 C 开始,C 已经完成,实际第一个被 poll 的是 D;第 4 轮从 D 开始,D 也完成了,只剩 A。
- 每轮每个 future 恰好轮到一次,完成的那几个只是什么都不做。把
join!换成join!(biased; a, b, c, d)再跑 3 次,结果都是A1 B1 C1 D1 A2 C2 D2 A3 D3 A4,每轮都从 A 开始。
小结
select!是声明宏,每个分支递归一次改写成统一形式;输出枚举Out<_0, _1, ...>和禁用位掩码由tokio-macros的过程宏生成,最多 64 个分支。- 展开后是当前任务里的一个
poll_fn:先查预算,再从起点绕一圈;Ready的分支立即禁用,模式不匹配就跳过;全部禁用走else,没有else就 panic。 - 默认模式每次 poll 重新随机起点,
biased;固定从第一个开始。if前置条件为假时 future 表达式仍会被求值。 - 一个分支胜出后,所有分支的 future 在处理代码运行前被 drop。停在第二个及之后
.await上的 future 会丢掉已经完成的那部分工作,这就是 cancel safety 问题。 join!用MaybeDone原地存放先完成的结果,每次 poll 把起点往后挪一个,防止前面的分支独占预算;try_join!遇到第一个Err就返回并取消其余分支。