Skip to content

← Async From First Principles step 14 of 25

Medium Framework

Hand-rolling `join`: running two futures concurrently

“Why isn’t my async code concurrent?” is the most-asked async question on every forum, and the answer is always the same: you awaited sequentially instead of joining. Build join by hand and that answer stops being a rule you memorised.

pub struct Join<A: Future, B: Future> { /* two boxed children, two result slots */ }
pub fn race_log(a_pends: u32, b_pends: u32) -> (u32, u32, Vec<char>)

You are given a Countdown that writes its tag ('a' or 'b') to a shared log on every poll, pends a fixed number of times, then resolves. race_log joins one of each and returns both results plus the log.

race_log(3, 1) must produce (10, 20, ['a','b','a','b','a','a']). Read the log: a and b are being polled in the same call, alternating, on one thread. b finishes after the fourth entry and is not polled again.

Concurrency, not parallelism

There is one thread here. There is no thread::spawn anywhere in this file. And yet both futures are making progress interleaved, which is exactly what concurrency means: progress on more than one thing over an interval. Parallelism — literally at the same instant on two cores — is a different thing, and join gives you none of it.

Compare with the sequential version:

let x = a.await;   // b has not been polled at all yet
let y = b.await;

That is not concurrency by any definition. a runs to completion, then b starts. If both are waiting on I/O you have doubled your latency for nothing. join polls both on every wakeup, so their waits overlap.

The shape of the combinator

Four fields: the two children, and an Option slot for each result. On every poll:

  • poll child A only if its slot is still empty;
  • poll child B only if its slot is still empty;
  • if both slots are full, take the values out and return Ready((a, b));
  • otherwise return Pending.

The is_none() guard is not an optimisation. Polling a future after it has returned Ready violates the documented contract and is allowed to panic — you saw exactly that in item 16.3, where a second poll of Immediate unwraps a None.

Edition 2024’s let-chains read beautifully here:

if this.ra.is_none()
    && let Poll::Ready(value) = this.a.as_mut().poll(cx)
{
    this.ra = Some(value);
}

Why both children, every time

There is exactly one Waker for this task. When it fires, the driver polls Join — and Join has no way to know which child asked to be woken. So it polls both. With two children that is fine. With ten thousand it is an O(n) scan on every single wakeup, and that is precisely the inefficiency FuturesUnordered exists to fix: it hands each child its own waker carrying an index, so a wakeup identifies the child that woke.

The error you are meant to meet

The starter is missing one line and fails with E0596: cannot borrow data in dereference of Pin<&mut Join<A, B>> as mutable. Join holds Option<A::Output>, and nothing says A::Output is Unpin, so the struct is not automatically Unpin, so Pin<&mut Join<..>> has no DerefMut.

The fix is a safe impl:

impl<A: Future, B: Future> Unpin for Join<A, B> {}

No unsafe keyword — and it is sound here for a specific, checkable reason: both children live behind Box::pin, so nothing inside Join depends on where Join itself sits in memory. Moving a Join moves two pointers and two Options. If you ever changed a child to be stored inline, that same impl would become genuine unsoundness. State the invariant; do not just copy the incantation.

Loading visualization…