Guide contents

Lesson 9 of 44

select!: Racing Futures

tokio::select! waits on several futures at once, runs the handler of the first one that completes, and drops the rest. This lesson covers its syntax, how to use it in a loop, and the rule that decides whether dropping a future loses data: cancel safety. Examples in this lesson are not machine-checked (they are marked rust,ignore); their output is derived from the timelines in the text.

Idea Where
Syntax, return value, losers are dropped Example 1
Patterns, else, biased, preconditions Example 2
Loops, &mut futures, pin! Example 3
A cancel-safety bug and its fix Example 4
Cancel safety of common APIs table
Timeouts, select! versus join! Example 5

Examples use tokio::time::pause(), which makes the clock jump forward whenever every task is idle, so sleeps finish at once and in a fixed order (explained in Time). They also use mpsc::channel, a queue that sends values between tasks, whose recv waits for the next value and returns None after all senders are dropped (explained in mpsc Channels).

Example 1: the smallest race

Each branch has the form pattern = future => handler. select! polls all the futures on the current task. When one completes, its handler runs and select! evaluates to the handler's value. The other futures are dropped, which cancels them (Cancellation).

Rust
use std::time::Duration;
use tokio::time::sleep;

struct Noisy(&'static str);

impl Drop for Noisy {
    fn drop(&mut self) {
        println!("dropped {}", self.0);
    }
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
    tokio::time::pause();
    let winner = tokio::select! {
        _ = async {
            let _guard = Noisy("slow");
            sleep(Duration::from_secs(5)).await;
        } => "slow",
        _ = async {
            let _guard = Noisy("fast");
            sleep(Duration::from_secs(1)).await;
        } => "fast",
    };
    println!("winner: {winner}");
}
Output
dropped fast
dropped slow
winner: fast

The fast block finishes and drops its own guard. select! then drops the slow block, which drops its guard while it is suspended at the .await. All handlers must produce the same type, because select! is one expression.

All branches run inside one task, one at a time as each is polled. select! gives concurrency, not parallelism. Work that needs its own thread of execution goes into tokio::spawn.

Example 2: patterns, else, and biased

The pattern on the left can refuse a value. If the future's output does not match the pattern, that branch is disabled for the rest of this select! call and the other branches keep waiting. When every branch is disabled, select! runs the else branch. Without an else branch, that situation panics.

By default, when several branches are ready at the same moment, select! picks one at random, so no branch starves the others. Adding biased; as the first line makes it check the branches from top to bottom.

Rust
use tokio::sync::mpsc;

#[tokio::main(flavor = "current_thread")]
async fn main() {
    let (tx_a, mut rx_a) = mpsc::channel::<u32>(4);
    let (tx_b, mut rx_b) = mpsc::channel::<&str>(4);
    tx_a.send(1).await.unwrap();
    tx_a.send(2).await.unwrap();
    tx_b.send("x").await.unwrap();
    drop(tx_a);
    drop(tx_b);

    loop {
        tokio::select! {
            biased;
            Some(n) = rx_a.recv() => println!("a: {n}"),
            Some(s) = rx_b.recv() => println!("b: {s}"),
            else => {
                println!("both closed");
                break;
            }
        }
    }
}
Output
a: 1
a: 2
b: x
both closed

With biased;, channel a is drained before b. Once rx_a.recv() returns None, the pattern Some(n) fails and that branch is disabled for the call. The same happens to b, and then else runs.

A precondition adds , if condition after the future: Some(n) = rx.recv(), if open => .... The condition is evaluated before polling. When it is false, the branch is disabled for that call and its future is not polled. Preconditions switch branches off after their work is done, as with a flag that records "this channel closed".

Example 3: loops, &mut futures, and pin!

A select! in a loop creates its branch futures again on every iteration. A sleep(...) written inside the macro starts a new countdown each time, so the deadline never arrives while other branches keep firing. To keep one deadline across iterations, create the future once, pin it, and pass &mut to the branch.

tokio::pin! pins a value to the stack, a requirement for polling a future by reference (Pin is explained in Polling, Wakers, and a Tiny Executor).

Rust
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::time::sleep;

#[tokio::main(flavor = "current_thread")]
async fn main() {
    tokio::time::pause();
    let (tx, mut rx) = mpsc::channel::<u32>(8);
    tokio::spawn(async move {
        for n in 1..=3 {
            tx.send(n).await.unwrap();
            sleep(Duration::from_millis(100)).await;
        }
    });

    let deadline = sleep(Duration::from_millis(250));
    tokio::pin!(deadline);

    loop {
        tokio::select! {
            Some(n) = rx.recv() => println!("got {n}"),
            _ = &mut deadline => {
                println!("deadline reached");
                break;
            }
        }
    }
}
Output
got 1
got 2
got 3
deadline reached

The sender sends at 0, 100, and 200 ms. The deadline fires at 250 ms, measured from the single sleep call before the loop. When the deadline branch runs, the handler leaves the loop, so the finished sleep is never polled again.

A JoinHandle needs no pin. It can be raced with &mut handle and awaited later if another branch wins.

Example 4: a cancel-safety bug and its fix

A future is cancel safe when dropping it before completion loses no data and leaves no half-finished state. The state that lives inside a dropped future is gone. In a loop or a retry, that matters for any future that has already consumed input.

read_pair below takes two values from a channel. When the timeout wins after the first value arrived, the first value is dropped with the future.

Rust
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::time::sleep;

async fn read_pair(rx: &mut mpsc::Receiver<u32>) -> Option<(u32, u32)> {
    let a = rx.recv().await?;
    let b = rx.recv().await?;
    Some((a, b))
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
    tokio::time::pause();
    let (tx, mut rx) = mpsc::channel(8);
    tokio::spawn(async move {
        tx.send(1).await.unwrap();
        sleep(Duration::from_millis(100)).await;
        tx.send(2).await.unwrap();
    });

    tokio::select! {
        pair = read_pair(&mut rx) => println!("pair: {pair:?}"),
        _ = sleep(Duration::from_millis(60)) => println!("timeout"),
    }
    println!("next: {:?}", rx.recv().await);
}
Output
timeout
next: Some(2)

The value 1 was received, held in the local a, and dropped. Nothing tells the program it is gone. The fix moves the partial state to the caller, which owns it across attempts:

Rust
async fn read_pair(
    rx: &mut mpsc::Receiver<u32>,
    first: &mut Option<u32>,
) -> Option<(u32, u32)> {
    if first.is_none() {
        *first = Some(rx.recv().await?);
    }
    let b = rx.recv().await?;
    Some((first.take()?, b))
}

// in main:
let mut first = None;
loop {
    tokio::select! {
        pair = read_pair(&mut rx, &mut first) => {
            println!("pair: {pair:?}");
            break;
        }
        _ = sleep(Duration::from_millis(60)) => println!("timeout"),
    }
}
Output
timeout
pair: Some((1, 2))

The first attempt stores 1 in first before it is dropped. The second attempt starts from the stored value, and 2 arrives at 100 ms, before the second 60 ms timeout ends at 120 ms. Cancel-safe futures keep no such state. mpsc::Receiver::recv is one of them, so recv alone can go in any select! branch.

Cancel safety of common APIs

The statements below come from the "Cancel safety" sections of the Tokio 1.x documentation (tokio 1.53 source) and of tokio-stream 0.1. "Safe" means the docs say that if another branch wins, no data was consumed or lost. A method marked "queue place" is safe for data, but its docs say that cancelling loses the caller's position in the fair queue.

API Cancel safe Notes from the docs
mpsc::Receiver::recv, recv_many safe no message was received
mpsc::UnboundedReceiver::recv safe same guarantee
mpsc::Sender::send safe, but the value is lost the message was not sent and is dropped; use reserve then Permit::send to keep it
mpsc::Sender::reserve queue place cancelling loses the place in the queue
mpsc::Sender::closed safe stays closed once closed
oneshot::Receiver (as &mut rx) safe no message was received
broadcast::Receiver::recv safe no message was received
watch::Receiver::changed, wait_for safe no value marked seen
Mutex::lock, RwLock::read/write queue place fair queue; cancelling loses the place
Semaphore::acquire, acquire_owned queue place fair queue; cancelling loses the place
Notify::notified queue place fair queue; cancelling loses the place
JoinHandle (as &mut handle) safe task output is not lost
JoinSet::join_next safe no task was removed
interval.tick() safe documented as cancel safe
TcpListener::accept, UnixListener::accept safe no connection was accepted
UdpSocket::recv_from, recv, send_to, send safe no message received or sent
AsyncReadExt::read, read_buf safe no data was read
AsyncReadExt::read_exact not safe some data may already be in the buffer
AsyncReadExt::read_u8, read_i8 safe no data was read
AsyncReadExt::read_u16, read_u32, read_u64 and the other multi-byte readers not safe some data may be lost
AsyncWriteExt::write, write_vectored, write_buf safe no data was written
AsyncWriteExt::write_all not safe part of the buffer may be written; a retry starts over
AsyncWriteExt::write_all_buf partial the buffer is advanced by the bytes already written
AsyncWriteExt::flush safe documented as cancel safe
AsyncBufReadExt::read_line not safe data may be partly read
AsyncBufReadExt::read_until resumable partial bytes are appended to buf; call again to continue
AsyncBufReadExt::fill_buf safe no data was read
Lines::next_line safe documented as cancel safe
tokio_stream::StreamExt::next safe the future holds only a reference to the stream

The table has gaps on purpose. A method that is missing has no statement in the docs, so treat it as unverified until the docs or the source say otherwise. A sleep or timeout holds no data. Dropping one only cancels the timer.

Two rules follow from the table. Methods that read a fixed number of bytes into a buffer usually keep no state between polls, so a partial read is lost. Methods that only wait for a value or a slot in a queue are safe. For a not-safe method, run it outside select! (in its own task, with the result sent over a channel), or move its state into the caller as in Example 4.

Example 5: timeouts and join!

A timeout is a race between the work and a sleep. The branch order does not matter here, because only one can finish first.

Rust
use std::time::Duration;
use tokio::time::sleep;

async fn fetch() -> &'static str {
    sleep(Duration::from_secs(2)).await;
    "data"
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
    tokio::time::pause();
    let result = tokio::select! {
        value = fetch() => Ok(value),
        _ = sleep(Duration::from_secs(1)) => Err("timed out"),
    };
    println!("{result:?}");

    let (a, b) = tokio::join!(fetch(), fetch());
    println!("{a} {b}");
}
Output
Err("timed out")
data data

tokio::time::timeout(duration, future) expresses the same race and returns a Result with an Elapsed error (Time). Write the select! form when the timeout is one of several events, as in Example 3.

select! join! (join! and try_join!)
Waits for the first branch to finish all branches
Other futures dropped run to completion
Result the winning handler's value a tuple of all outputs
Typical use timeouts, shutdown signals, multiple event sources independent work whose results are all needed

Interview points

  • select! polls all branches on one task and cancels the losers by dropping them.
  • A dropped future loses its local state. A future is cancel safe when that loss is harmless.
  • recv on mpsc, broadcast, and oneshot, accept, and tick are cancel safe. read_exact, write_all, read_line, and multi-byte read_u32 are not.
  • mpsc::Sender::send is cancel safe in the sense that nothing is sent, but the value is dropped. reserve avoids that.
  • With no biased;, ready branches are picked at random. biased; gives a fixed top-to-bottom order, which can starve later branches.
  • In a loop, create long-lived futures (a deadline, a JoinHandle) once and pass &mut (pinned with tokio::pin! if the type is not Unpin).
  • A false precondition or a failed pattern disables a branch for that call. If all branches are disabled and there is no else, select! panics.

Summary

  • select! returns the value of the first handler to run and drops every other branch.
  • Branches are pattern = future, if precondition => handler. else handles the case where every branch is disabled.
  • biased; switches from random choice to top-to-bottom order.
  • Reuse a future across loop iterations with &mut future after tokio::pin!.
  • Cancel safety is a property of each future. The table lists the documented status of common Tokio APIs.
  • Keep state that must survive cancellation outside the future.
  • select! races futures and cancels the rest. join! waits for all of them.