Skip to content

← Async From First Principles step 15 of 25

Medium Framework

Hand-rolling `select`: racing futures and dropping the loser

join waits for both. select takes whichever finishes first and throws the other one away. That second half is where async Rust stops being “sequential code with extra keywords” and starts having semantics you have not met before.

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

race builds the same tagged Countdown pair as the previous item, races them, and returns the winner formatted as "a:10" or "b:20" plus the poll log.

The polling order is part of the specification, because with equal pends the winner is decided entirely by who is polled first:

  • biased == true — branch A is polled first on every poll.
  • biased == false — the branches take turns: A first on the first poll, B first on the second, A first on the third, and so on.

So race(1, 1, true) is ("a:10", ['a','b','a']) and race(1, 1, false) is ("b:20", ['a','b','b']). Same futures, same number of pends, different winner. A select without a documented order is not a specification, it is a coin flip.

Fairness

Always polling the first branch first is the simplest thing that works, and it starves the second branch whenever the first is usually ready. That is a real production failure: a select! between “receive a message” and “shut down” where messages always arrive means the shutdown branch is checked but never wins.

Real select! macros randomise branch order by default for exactly this reason, and tokio’s offers a biased; mode to opt out when you want determinism and know what you are doing. A hand-rolled version cannot randomise and stay testable, so this one alternates — deterministic, and fair in the sense that neither branch is permanently at the back of the queue.

The loser

When one branch wins, Select returns Ready and is then dropped by whoever owned it. Dropping it drops both children, including the one that was halfway through its work. There is no notification, no cancel() call, no chance to finish. Whatever partial state that future had accumulated — half-parsed bytes, a partially built response, an item taken off a queue — goes away with it.

That is the origin of the cancel-safety problem, and it is the single sharpest edge in async Rust. If a future keeps its partial progress inside itself, losing a select race loses that progress silently. If it keeps partial progress in a longer-lived object it merely borrows, losing the race costs nothing. That is why tokio documents cancel safety per method rather than as a blanket rule. Item 16.22 goes into it properly; for now, notice that the mechanism is nothing more exotic than Drop.

Shape notes

Select needs no manual Unpin impl — unlike Join it has no Option<A::Output> field, so every field is already Unpin and the struct gets it for free. Worth noticing rather than cargo-culting the impl from the previous item.

clippy::type_complexity is a default-on lint and generic combinator types get long fast; a type alias is the usual answer if you trip it.