Rust in Everything
基于 tokio 1.54.1

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。

📝NOTE
没有蠢问题

问: 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。如果起点固定,后面的分支每次都分不到预算;轮换让每个分支轮流排第一。

📝NOTE
冷知识:1.19.0 之前,join! 每次都从第一个开始 poll

2022 年 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 多个任务。

❗IMPORTANT
动脑筋:日志的 10 项依次是什么?

还是会返回 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::recvAsyncBufReadExt::read_line
watch::Receiver::changedAsyncWriteExt::write_all
await &mut oneshot::Receiver、&mut JoinHandlempsc::Sender::send:没发出去,但消息随 future 一起丢了
JoinSet::join_nextmpsc::Sender::reserve:失去排队位置
time::Interval::tickMutex::lock、RwLock::read / write、Semaphore::acquire、Notify::notified:失去排队位置
TcpListener::accept、UnixListener::acceptBarrier::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 的这次完成的
1AA B C DA1 B1 C1 D1B
2BB C D AC2 D2 A2C
3CC D A BD3 A3D
4DD A B CA4A
  • 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 就返回并取消其余分支。

💬 关联讨论

发起讨论
加载中...