Skip to content

← Async From First Principles step 18 of 25

Hard End-to-End

Spawning, `JoinHandle`, and getting values back out

Your executor can run tasks. It cannot yet tell you what any of them producedspawn takes a Future<Output = ()> and the value goes nowhere. Fixing that is what turns a loop into a runtime, because it requires one task to wake another.

pub struct Shared<T> { value: Option<T>, waker: Option<Waker> }
pub struct JoinHandle<T> { /* an Rc to a Shared<T> */ }
impl<T> Future for JoinHandle<T> { type Output = T; .. }

impl Executor {
    pub fn spawn<T: 'static>(&mut self, fut: impl Future<Output = T> + 'static) -> JoinHandle<T>;
}

fan_in is given: it spawns one worker per input (each yielding a different number of times, so they finish out of order), then spawns a collector task that awaits every handle in order and pushes the results. The output is always the inputs doubled, in input order.

The mechanism

A spawned task and its handle share one cell:

  • spawn allocates the shared cell, wraps the caller’s future in a small async move block that awaits it and writes the result into the cell, hands the wrapper to spawn_raw, and returns a JoinHandle holding the other end.
  • JoinHandle::poll looks in the cell. Value there? Ready. Nothing there? Store the current waker and return Pending.
  • Task completion writes the value, takes the stored waker, and calls it.

Notice what that last step is: task A waking task B. That is cross-task wakeup, and it is the thing your executor could not do before. Everything a runtime offers — channels, mutexes, timers, JoinHandle — is this same pattern: a shared cell, a parked waker, and a wake on completion.

The contract clause that finally bites

Item 16.6 stated three clauses, and the second one has been theory until now:

On repeated polls, only the waker from the most recent Context should be scheduled. Wakers are not cumulative.

The starter caches the first waker it ever sees and ignores later ones. That looks harmless — surely the same task keeps polling the same handle? It is not, and fan_in proves it: before the collector exists, every handle is polled once with a throwaway Waker::noop(). A handle that remembers only its first waker remembers that one forever. When the worker finishes it wakes a no-op, the collector is never re-queued, the ready queue drains, run returns, and the output is empty.

The peek is contrived-looking and the situation it stands for is not. A future polled once by one executor and then moved to another, a future polled inside a select! arm and then awaited directly, a task migrated between worker threads by a work-stealing scheduler — all of them hand the same future a different Waker than last time. Overwriting unconditionally is the only correct policy. (Waker::will_wake exists as a cheap “is this the same waker?” check to skip a clone, but it is explicitly best-effort and may answer false for two wakers that would in fact wake the same task, so it can only ever be an optimisation.)

Order of operations at completion

Write the value first, then take the waker, then release the borrow, and only then wake:

let waker = {
    let mut shared = cell.borrow_mut();
    shared.value = Some(value);
    shared.waker.take()
};
if let Some(waker) = waker { waker.wake(); }

Waking while still holding the borrow invites re-entrancy: the woken task may run, or may try to touch the same cell, and now you own a RefCell that is already mutably borrowed (panic) or a Mutex you already hold (deadlock). This shape — compute under the lock, act outside it — is worth making a habit.

A nice thing to notice

The collector awaits its handles sequentially, and the workers still interleave. Spawning is eager-ish: the moment you spawn, the task is on the ready queue and will be polled whether or not anybody is awaiting its handle. A plain future is the opposite — completely inert until polled. Both models exist, they behave differently, and dropping a JoinHandle in a real runtime does not cancel the task for exactly this reason.