Skip to content
advanced Phase 13 · Async Rust with Tokio

join! and select! for Concurrent Futures

Run futures together and race them with branch-based control flow.

45m
0 problems
Topic Progress 0%

Two Requests, One Task: Measuring join! Against Sequential .await

You need a user profile and an order history before rendering a page. Written the obvious way:

async fn load(id: u64) -> (User, Vec<Order>) {
    let user   = fetch_user(id).await;     // 200 ms
    let orders = fetch_orders(id).await;   // 200 ms
    (user, orders)                          // ~400 ms on the wire
}

Each .await suspends until its future is Ready, so the costs add. tokio::join! runs them on the same task, interleaved, and costs the slower of the two:

async fn load(id: u64) -> (User, Vec<Order>) {
    tokio::join!(fetch_user(id), fetch_orders(id))   // ~200 ms
}

The macro evaluates both expressions (creating two inert futures — see async fn and await), then polls them alternately inside one poll: if user returns Pending, it polls orders; when a timer or socket fires, the task wakes and both keep advancing. Output arrives as a tuple in the order you wrote the branches: (User, Vec<Order>).

What join! gives you and what it does not:

Property a().await; b().await tokio::join!(a(), b()) spawn(a()) + join! handles
Wall time t_a + t_b max(t_a, t_b) max(t_a, t_b)
Threads one one two or more workers
Futures need Send + 'static no no yes
One panics task panics task panics (both dropped) other task survives; JoinError
Runs on this task this task runtime scheduler

That middle column is the answer to "concurrency or parallelism?" — join! is concurrency without parallelism. It is the same single thread interleaving polls; if one branch blocks (a std::thread::sleep, a synchronous decode), the other cannot advance either. Reach for spawn when a branch is CPU-heavy or must run on another worker, then join! the handles to wait for both:

let a = tokio::spawn(fetch_user(id));
let b = tokio::spawn(fetch_orders(id));
let (user, orders) = tokio::try_join!(a, b)?;   // both JoinHandles resolve to Result

try_join! is the fallible sibling: every branch must evaluate to Result, the macro returns on the first Err, and — this is the part people miss — the still-pending branches are dropped. On the failure path your in-flight requests are cancelled, which is fine for idempotent GETs and dangerous if a branch was charging a card. If you need both-or-nothing semantics that tolerate a slow branch, collect results explicitly instead.

Three details that decide whether join! fits the code in front of you:

  • No Send/'static tax. Everything runs inline on this task, so the branches may borrow local data — tokio::join!(&self.a, fetch(&self.name)) compiles where the same futures passed to spawn would fail both bounds. That makes join! the default for borrowing, short-lived concurrency.
  • Expressions are evaluated, then polled. join!(fetch_user(id), fetch_orders(id)) calls both constructors immediately (cheap: they only capture), then polls them together. Nothing starts early, nothing starts late.
  • One panic ends both. A panic inside any branch unwinds the enclosing task; both futures are dropped and the panic surfaces at the spawned task or block_on boundary. Isolation between branches is precisely what spawn adds — at the price of the bounds above.

Measuring is a two-line habit:

let t = std::time::Instant::now();
tokio::join!(slow("a", 200), slow("b", 200));
println!("{:?}", t.elapsed());        // ~200ms — run it, do not assume it

join! never cancels anything on success: it waits for all branches. Cancellation is the other macro's job — select! pairs it in the next chapter, and the dropping behavior there is where cancellation safety starts to matter. Everything above assumes the workers from The Tokio Runtime are free to poll your task; if a branch silently blocks a worker, both branches stall.

Racing Branches with select!: What Happens to the Loser

join! waits for everyone. select! is the opposite contract: poll several branches on this task and return as soon as one completes — the rest are dropped on the spot.

tokio::select! {
    maybe = rx.recv() => {
        match maybe {
            Some(msg) => println!("got {msg}"),
            None      => println!("channel closed"),
        }
    }
    _ = tokio::time::sleep(Duration::from_secs(1)) => {
        println!("timed out with nothing to read");
    }
}

The lifecycle, straight from the macro's documented behavior:

  1. Evaluate every if precondition; a false precondition disables that branch for this call (the async expression is still evaluated, but its future is never polled).
  2. Evaluate all async expressions into futures, then poll them concurrently on the current task.
  3. The first future returning Ready with a value matching its pattern runs its handler; select! returns.
  4. All other futures are dropped right there. Their locals are destroyed, their Drop runs, and any work they started is abandoned.
  5. If every branch is disabled and there is no else, select! panics.

Fairness is built in: by default the macro picks the branch to poll first randomly, so a loop with always-ready branches cannot starve one side. Write biased; at the top to force top-down polling order — useful when one branch must win (shutdown, error) — but then fairness becomes your job: a high-traffic stream listed first will swallow a shutdown branch placed after it unless you reorder or guard it.

Now the part interviewers actually probe. Because losers are dropped mid-flight, every branch of a select! must be cancel-safe: dropping it before completion must be equivalent to never having run it — the official test is "it must be a no-op to drop the future and recreate it."

Note also what select! explicitly is not: the tokio docs are blunt that branches "run concurrently but not in parallel" — one thread polls them, so a branch that blocks the thread starves the others, and if you need true parallelism you tokio::spawn each branch and select! on the resulting handles. The macro must also be inside async context; used in a plain fn it fails the same way any .await does (error[E0728]), because there is no task to suspend.

Cancel-safe (tokio docs) Not cancel-safe
mpsc / broadcast recv, watch::changed read_exact, read_to_end, read_to_string
TcpListener::accept, signal::recv write_all (bytes already sent)
AsyncReadExt::read, read_buf, write, write_buf Mutex::lock, RwLock::read/write
StreamExt::next on any stream Semaphore::acquire, Notify::notified

The right-hand column loses real state. read_exact has already pulled bytes out of the socket into your buffer before it returns Pending — drop it and the count of bytes consumed dies with the future; recreate it and reading restarts from index zero. Lock/semaphore acquisition loses your place in the queue, so a task that keeps losing the race may never get in. recv is safe because the message only leaves the channel in the same poll that returns Ready — a dropped recv future has taken nothing.

A pattern that is unsafe no matter which primitives you use:

let msg = rx.recv().await;      // message already removed from the channel
tokio::select! {                // if the other branch wins, `msg` is dropped
    _ = slow_side_work() => {}
    _ = &mut deadline => {}
}                               // ...and the message is gone forever

Receive-then-race is the anti-pattern: the side effect happened before the select, so cancellation cannot undo it. Keep the receiving call inside the branch.

Also remember where these branches run: they all share one task on one worker from The Tokio Runtime, so a branch that blocks the thread freezes the whole race — and a branch that is dropped mid-await has been cancelled, which is exactly the mechanism Timeouts, Cancellation, and Async I/O uses on purpose.

Looping select!: else Arms, Guards, and Reusing Futures Across Iterations

A single select! fires once. Every real server needs to service many events forever, which is why the canonical shape is a loop around it:

loop {
    tokio::select! {
        msg = rx.recv() => match msg {
            Some(m) => process(m),
            None    => break,             // producer gone: shut down
        },
        _ = ticker.tick() => flush_metrics(),
        _ = shutdown.cancelled() => break,
    }
}
println!("drained");

Three features make this loop correct rather than accidental.

else handles "nothing left to wait for." If every branch is disabled — precondition false, or the value didn't match the pattern — select! runs the else arm, and panics if you did not provide one. So draining streams falls out naturally:

loop {
    tokio::select! {
        Some(v) = stream_a.next() => merge(&mut out, v),
        Some(v) = stream_b.next() => merge(&mut out, v),
        else => break,        // both streams returned None: all disabled
    }
}

Note the pattern match itself acts as a one-shot switch: once a branch yields None, its Some(v) pattern stops matching, the branch disables, and the loop converges on else.

One hazard hides in that trick: it assumes a finished stream keeps returning None forever — the streams in the tokio docs examples are explicitly "fused" for this reason. If a stream instead panicked, or returned Some again after None, a select!-driven loop would keep polling a stream that should be done: a busy loop that disables nothing and burns a core. When you write your own event source, make exhaustion permanent (Option-style state, or an explicit done flag that forces the branch's pattern to fail), and the drain logic above stays honest.

if guards are re-evaluated every iteration — disabled state resets when the loop re-enters select!. That is what lets a branch retire itself:

let mut sleep = tokio::time::sleep(Duration::from_millis(50));
tokio::pin!(sleep);

loop {
    tokio::select! {
        _ = &mut sleep => { println!("deadline"); break; }
        _ = some_async_work() => { println!("done early"); }
    }
}

The docs warn about the tempting shortcut — writing if !sleep.is_elapsed() as a guard — because is_elapsed() can flip true between the check and the select!, so the timeout branch is disabled exactly when it should fire and the loop runs past the deadline. Keep polling the timer instead of inspecting it.

Reusing a future across iterations requires Unpin. &mut sleep above works because each loop pass re-polls the same future through a mutable reference — and &mut F: Future only holds when F: Unpin. Tokio's own documentation states it: passing a future by reference to more than one select! requires the future to be Unpin, made so with Box::pin or stack pinning (tokio::pin!, std::pin::pin!). Futures produced by async fn that carry state across awaits are exactly the ones that usually are not — which is the entire subject of Pin and Unpin Explained.

Why loops and cancellation safety belong in one chapter: every trip around the loop re-creates the losing futures, so a branch that lost last iteration is dropped and rebuilt each time. If dropping loses progress, the loop silently discards work forever — a bug that only appears under load. That is the bridge to Timeouts, Cancellation, and Async I/O, where dropping a future is a feature you invoke on purpose. For the scheduling substrate these tasks run on, see The Tokio Runtime.

Interview drill: "Wait for the first of two events, without losing data." Answer: select! with cancel-safe branches only (recv, read, next), receive inside the branch, else for exhaustion, and biased; if one branch must take priority.

Quiz

1. Two futures each take 200 ms of wall time. How long does `tokio::join!(a, b)` take compared with awaiting them in sequence?

Question 1 options

2. In a select!, the timeout branch becomes Ready while a message-reading branch is suspended. What happens to the reader?

Question 2 options

3. Which future is safe to use as a select! branch?

Question 3 options

4. A select! inside a loop has all of its branches disabled and no else arm. What does the program do?

Question 4 options

Flashcards

Question

What does tokio::join! guarantee, and what does it cost?

Answer

It polls all branch futures concurrently on the current task and returns only when every one is Ready, producing a tuple in the order written. Cost: wall time is max(branch times) instead of the sum, but still a single thread — a blocking branch stalls the others, and nothing is cancelled on success.

Question

What does select! do to the branches that lose the race?

Answer

It drops them: their futures are destroyed at the moment the winner returns, running destructors and abandoning any in-flight work. That is why every branch must be cancel-safe — dropping and recreating the future must be a no-op — and why receive-then-race code loses data.

Question

State tokio's cancel-safety rule and give one safe and one unsafe example.

Answer

Rule: it must be a no-op to drop an incomplete future and recreate it. Safe: mpsc Receiver::recv, TcpListener::accept, AsyncReadExt::read, StreamExt::next. Unsafe: read_exact / read_to_end / read_to_string (bytes already consumed), write_all (bytes already sent), Mutex::lock / Semaphore::acquire (queue position lost).

Question

When does select! poll branches randomly, and what does biased; change?

Answer

By default the macro randomizes which branch is polled first so loops over always-ready branches cannot starve one side. `biased;` makes polling strictly top-to-bottom — cheaper (no RNG) and order-deterministic — which shifts fairness responsibility to you: put shutdown/error branches early or a hot stream can starve them.

Question

What happens when all select! branches are disabled, and how do if guards behave across loop iterations?

Answer

Without an else arm the macro panics; with one, the else handler runs — `else => break` is the standard stream-drain exit. Guards are re-evaluated on each loop entry, so a branch disabled this pass can be re-enabled next pass; the disabled state lasts only for the current select! call.

Revision Notes

Key Takeaways

  • 1. join! polls futures together on one task: wall time becomes the max of branch times, but there is no parallelism — blocking one branch stalls the rest
  • 2. try_join! returns on the first Err and drops the still-pending branches, cancelling whatever they had started
  • 3. select! returns on the first completion and drops every loser, so branch choice is a cancellation decision, not just control flow
  • 4. Cancel safety means dropping-and-recreating must be a no-op: recv/read/accept yes, read_exact/write_all/lock no
  • 5. loop + select! with else and if guards is the standard event-service shape; reusing a future across iterations needs Unpin

Interview Tips

  • • "Wait for the first of two futures" — say select!, then immediately add: losers are dropped, so branches must be cancel-safe; that follow-up is usually the real question
  • • "join! or spawn?" — join! keeps everything on one task (no Send/'static, no parallelism, one panic kills both); spawn buys threads and isolation at the cost of bounds and JoinHandle bookkeeping
  • • "What is cancellation safety?" — quote the test (drop-and-recreate must be a no-op), name one safe and one unsafe tokio API, and show the receive-then-race anti-pattern
  • • "How do you avoid starving a select! branch?" — random polling by default, biased; for explicit order, and the rule that a shutdown branch must be polled early when biased is used

Cheat Sheet

join! and select! — Cheat Sheet

join! — wait for all:

let (a, b) = tokio::join!(fa(), fb());          // tuple, order as written, max(t_a, t_b)
let (a, b) = tokio::try_join!(fa(), fb())?;     // Result branches; first Err drops the rest

Concurrency, not parallelism: one task, one thread — a blocking branch stalls both.

select! — first one wins, losers are dropped:

tokio::select! {
    biased;                                    // optional: poll top-down instead of random
    maybe = rx.recv() => handle(maybe),        // cancel-safe branch
    _ = &mut ticker.tick() => maintain(),
    _ = shutdown.cancelled() => break,
    else => (),                                // all disabled — required to avoid a panic
}

Cancel safety test (tokio docs): it must be a no-op to drop the future and recreate it.

Safe in a branch Unsafe in a branch
recv (mpsc/broadcast), watch::changed read_exact, read_to_end, read_to_string
accept, signal::recv, StreamExt::next write_all
read, read_buf, write, write_buf Mutex::lock, RwLock::*, Semaphore::acquire, Notify::notified

Loop discipline: guards re-arm each iteration; pattern mismatch or a false if disables a branch; no branch ready + no else = panic; reuse across iterations needs Unpin (tokio::pin! or Box::pin).

Continue Learning