Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

join!

Goal of This Episode

Learn to wait on multiple Futures at once within a single Task using join!, and understand why it’s a macro.

Main Text

Concurrency Within One Task

In Episode 9 we hand-wrote JoinAll, advancing several Futures together. Tokio provides a ready-made join! that does the same thing:

extern crate tokio;

use tokio::time::{sleep, Duration};

async fn fetch_a() -> i32 {
    sleep(Duration::from_secs(1)).await;
    1
}

async fn fetch_b() -> &'static str {
    sleep(Duration::from_secs(1)).await;
    "hello"
}

#[tokio::main]
async fn main() {
    // both Futures wait simultaneously — about one second total — returning a tuple
    let (a, b) = tokio::join!(fetch_a(), fetch_b());
    println!("a = {}, b = {}", a, b);
}

join! waits for all branches to complete before moving on, handing back each branch’s result packed into a tuple. The two fetches above each wait one second, but because they’re concurrent, the total is about one second, not two.

The Difference Between spawn and join!

Both spawn and join! give you concurrency, but by different means:

  • tokio::spawn turns each job into an independent Task handed to the runtime, possibly run on different Threads — hence Send + 'static.
  • join! polls its branches in turn within the same Task; they do not become independent Tasks.

Because the branches stay inside the current Task and join! waits for all of them to complete, they never become independent Tasks that can outlive the current scope. That makes join! a good fit for a fixed number of concurrent I/O operations that should all complete within the current scope — calling three APIs at once, reading two files at once.

join!’s Concurrency Is Not CPU Parallelism

An important limitation to clear up. join!’s branches are polled in turn on the same Task, which means its concurrency is the “interleaved switching” kind — it cannot be CPU parallelism.

The consequence is practical: if one branch goes a long time without .awaiting (doing lengthy computation, or calling a synchronous blocking function), it hogs the Thread — and since everyone takes turns on the same Task, even the other branches within the same join! go unpolled. The illusion of concurrency shatters on the spot.

This is exactly last episode’s “don’t block the Thread” iron rule playing out in join!. If some branch really has heavy lifting to do, use spawn_blocking — don’t let it wedge inside the join!.

Why join! Is a Macro

You’ve probably noticed join! is also a macro, not a function. Why must it be, this time?

Because it has to swallow any number of Futures of mutually different types, then return a tuple shaped to match. join!(a, b) and join!(a, b, c, d) both work, each branch’s Future type entirely its own; the return type changes accordingly to (A::Output, B::Output) or (A::Output, B::Output, C::Output, D::Output).

An ordinary Rust function has a fixed number of parameters. We could write separate generic functions for two, three, or four Futures, each returning a tuple whose element types match those Futures’ outputs, but no single function can cover every possible arity. A macro can instead generate, at compile time, code with exactly the right tuple shape for each invocation.

The contrast with Episode 9’s JoinAll sharpens the picture: JoinAll handles “same output type, dynamic count” — the count settled at runtime, while which concrete Future each one is got erased behind dyn Future<Output = ()>, the only requirement being that they all output (). join! is the reverse: “mixed output types, fixed count” — the count and each Future’s output type locked in as you write the code, so a macro can unroll them at compile time into a tuple that matches exactly.

Recap

  • join! waits on multiple Futures at once within one Task, returning the results as a tuple once all complete.
  • Unlike spawn: join!’s branches don’t become independent Tasks — suited to a fixed number of concurrent I/O operations that should all complete within the current scope.
  • join!’s concurrency isn’t CPU parallelism: branches take turns being polled on one Task, and one stuck branch starves the rest.
  • join! is a macro because each invocation can take a different number of differently typed Futures and produce a correspondingly shaped output tuple; an ordinary function would need a separate version for each arity.
  • Against our own JoinAll (same output type, dynamic count), join! is mixed output types, fixed count.