Skip to content

← Async From First Principles step 15 of 25

Medium Framework

Race two futures with a defined polling order

Update the supplied Select::poll so it supports two polling orders. A select returns the first ready result it observes, while a join waits for both results. Keep the supplied children, constructor, driver and race function unchanged.

pub enum Either<L, R> { Left(L), Right(R) }
pub fn race(a_pends: u32, b_pends: u32, biased: bool) -> (String, Vec<char>)

Each Countdown logs its tag on every poll, returns Pending the requested number of times, then returns its value. Child A has tag 'a' and value 10; child B has tag 'b' and value 20. Return "a:10" or "b:20" with the exact poll log.

  • If biased is true, poll A first on every call to Select::poll.
  • Otherwise, alternate the first child: A on call 1, B on call 2, A on call 3, and so on.
  • If the first child is ready, return immediately without polling the other.
  • If it is pending, poll the other child. Return its ready result, or return Pending if both children are pending.

Increment the poll counter once per call, not once per child. It starts at zero, so determine the order from the old counter value before incrementing. Both pending counts are below 100_000, within the supplied driver's budget.

For example:

race(1, 1, true)  = ("a:10", ['a', 'b', 'a'])
race(1, 1, false) = ("b:20", ['a', 'b', 'b'])
race(0, 0, false) = ("a:10", ['a'])

The second child is not polled after a winner is found. When both could be ready on the same call, the chosen order determines which result is observed. There is no wall-clock race or extra thread in this exercise.

What alternating order does and does not guarantee

Alternating gives each child the first position on successive polls of the same Select. It is not a general starvation guarantee. Each newly created Select starts with A, so repeatedly constructing one with an immediately ready A can still prevent B from being polled.

Biased ordering can be useful when branch priority is intentional. It can also hide a lower-priority branch: if A is ready, this implementation never checks B on that call. Real combinators may use other policies, including randomized order. Randomized behavior can be tested, but deterministic alternation makes this exercise's exact logs straightforward to check.

Completion and dropping are separate

Returning Ready does not itself destroy Select. The owner decides when to drop it. Here block_on owns the select and drops it while returning its result, which drops both stored child futures. The winning output has already moved out; the losing child will receive no further polls.

Dropping a future runs its normal cleanup, including destructors. It does not undo side effects or necessarily cancel work the future started elsewhere. The shared poll log survives because race retains an Rc handle to it.

A losing operation may have made partial progress before it is dropped. Whether abandoning and later retrying that operation is safe depends on its API: consumed bytes, queue items or external effects may need explicit handling. Keeping progress in a longer-lived object can help, but does not by itself prove cancellation safety.

Pinning and wakeups

Select stores its children as Pin<Box<A>> and Pin<Box<B>>. Moving the parent moves the pointers, not the child allocations. Its other fields are ordinary scalar values, so this layout is automatically Unpin even when the children are not. It has no stored output values that would affect that automatic implementation. Do not add extra Unpin bounds to the children.

Forward the current context to each child being polled. The supplied countdowns request a wake whenever they return Pending. The teaching driver instead polls repeatedly with a no-op waker, so its output tests do not prove that a general implementation handles wakeups correctly.

Like other futures, a completed Select should not be polled again. This exercise specifies behavior only up to its first ready result.