🎓 A1Learner
🔍
0 of 45 lessons completed

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?