๐ŸŽ“ A1Learner
๐Ÿ”

Rust Joining & Racing Futures

Joining futures means polling several futures together in one task. Each future makes progress while the others wait.

Waits in a row run one at a time

Each wait below runs to its end before the next one starts. Two short waits add up to one long wait:

use std::thread;
use std::time::Duration;

fn wait(name: &str) {
    thread::sleep(Duration::from_millis(20));
    println!("{name} done");
}

fn main() {
    wait("first");
    wait("second");
    println!("both done");
}

join! runs futures together

join! polls its futures side by side and collects the results as one tuple. Here the two helpers return Results, so the tuple holds both of them:

let pair = futures::join!(first(), second());
println!("pair = {pair:?}");

try_join!: all-or-nothing joining

join! waits for everything. try_join! finishes as soon as the FIRST error appears. Sharp detail: the macro awaits internally, so there is no .await written after it.

let both = futures::try_join!(first(), second());
println!("both = {both:?}");

The first error wins โ€” and cancels the rest

Here broken() fails. So the whole expression is that error, immediately. The other future is dropped mid-flight. That is the drop-is-cancel rule from the cancellation-safety lesson.

let both = futures::try_join!(first(), broken());
println!("both = {both:?}");

Same error type โ€” or make it so

other() fails with a &str while first() fails with a String. map_err rewrites one future's error into the shared type so try_join! accepts both.

use futures::future::TryFutureExt; // for map_err on futures

let fixed = other().map_err(|e: &str| e.to_string());
let both = futures::try_join!(first(), fixed);
println!("both = {both:?}");

FuturesUnordered: a bag of same-shaped futures

The number of futures is not always fixed at two or three. In that case, push them all into a FuturesUnordered. Drain it with next(). Results arrive in finish order โ€” here only the sum matters, so the order cannot change the answer. Every pushed future must have the same type: return them all from one async fn, or box them.

use futures::stream::{FuturesUnordered, StreamExt}; // for push, next

let mut bag = FuturesUnordered::new();
bag.push(job(10));
bag.push(job(20));
let mut total = 0;
while let Some(n) = bag.next().await {
    total += n;
}
println!("total = {total}");

try_join! over spawned tasks

Inside an async fn that returns Result<_, JoinError>, two spawned tasks join fail-fast โ€” one ? handles both a bad result and a dead task:

let a = tokio::spawn(async { 1 });
let b = tokio::spawn(async { 2 });
let (x, y) = tokio::try_join!(a, b)?;
println!("joined = {}", x + y);

Example

// Cargo.toml: tokio = { version = "1", features = ["full"] }, futures = "0.3"
use futures::future::join_all;
use tokio::time::{sleep, Duration};

async fn job(name: &str, ms: u64) -> String {
    sleep(Duration::from_millis(ms)).await;
    format!("{} done", name)
}

#[tokio::main]
async fn main() {
    let pair = tokio::join!(job("alpha", 10), job("beta", 20));
    println!("pair = {} + {}", pair.0, pair.1);

    let jobs = (1..=3).map(|n| async move { n * n });
    let squares = join_all(jobs).await;
    println!("squares = {:?}", squares);

    tokio::select! {
        winner = job("fast", 5) => println!("race winner = {}", winner),
        _ = sleep(Duration::from_secs(5)) => println!("race timed out"),
    }
}

Key points

  • A join polls each future in turn.
  • So the futures make progress side by side.
  • They do not run one after the other.
  • The select function returns an Either.
  • It returns Left(output) when the first future wins.
  • It returns Right(output) when the second does.
  • The winner is one or the other.
  • It is named only by position.
  • All futures in one join_all list must share one type.
  • Futures of the same shape collect directly.
  • Different async blocks have different hidden types.
  • Pin each one as Pin<Box<dyn Future>>.
  • Then they can be stored together.
  • An unpinned Box<dyn Future> is rejected with error E0277.
  • join_all is its partner for a collection the program builds as it runs.
  • Racing picks the first future to finish.

In this example, job waits a few milliseconds and then reports its name. First, tokio::join! runs the alpha and beta jobs together. It waits for both. So the pair line shows both results. The waits overlap instead of adding up. Next, join_all runs a list of three small futures. A map built the list. There is one future for each number. join_all collects their squares into [1, 4, 9]. Finally, select! races a 5-millisecond job against a 5-second sleep. The job wins. Its line prints. The sleep future is dropped unfinished.

Note

the futures crate has its own select! macro too โ€” unlike tokio::select!, it requires each future to be .fuse()d and stack-pinned with pin_mut! first, because it polls by reference so survivors can be selected again in a loop

join! is for futures you can name one by one in the code.

Try it Yourself

Edit the code, then press Run.

Running is simulated ยท Rust Playground

Exercise

You await future A (about 2 seconds), then await future B (about 2 seconds), one after the other in the same async block. How long until both are done?