We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← Async From First Principles step 14 of 25
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…
Stuck?
Rust reference solution
Sign in to attempt this problem and reveal the reference solution.