Skip to content

← Async From First Principles step 2 of 25

Easy Primitives

Futures are lazy: nothing happens until you poll

Calling an async fn does not run it. This problem makes you prove that to yourself.

pub fn laziness_log(tags: Vec<String>, drive: Vec<bool>) -> Vec<String>

You are given an async fn record(tag, log) whose entire body is one line: it appends ran:<tag> to a shared log. You are also given a block_on that drives a future to completion. Produce the log for this exact script:

  1. Pass 1 — for each tag in order, append constructed:<tag> to the log and then construct record(tag, log). Keep the future; do not drive it.
  2. Pass 2 — walk the tags in order again. For each one whose drive flag is false, append dropped:<tag> and then drop that future.
  3. Pass 3 — walk the tags in order a third time and block_on every future whose flag is true.
  4. Return the log.

So tags = ["a", "b"] with drive = [true, false] must produce

["constructed:a", "constructed:b", "dropped:b", "ran:a"]

Read that output again. record("b", ..) was called. Its future existed for the whole of pass 1. And ran:b is not in the log, because the body never ran — the future was dropped before anybody polled it.

Why this is the first thing in the track

If you are coming from JavaScript, C#, Python or Go, your instinct is that calling an async function starts it. In JS, fetch(url) sends the request before you ever await the promise; dropping the promise on the floor does not un-send it. Rust is the opposite, and it is opposite on purpose:

  • async fn f() compiles to an ordinary function that constructs and returns a value. That value is an inert state machine parked at “before the first line”.
  • The body runs only inside poll, and poll is only ever called by whoever is driving the future — here, block_on.
  • Therefore dropping a future is a complete, instantaneous cancellation. There is no thread to signal, no task to unregister, no CancellationToken. The work had not started.

Everything later in this track depends on that one fact: cancellation by drop, the zero-cost state machine, select! discarding the loser. They are all consequences of laziness.

Two things that will bite you

.await does not work here. laziness_log is a plain fn, and the compiler will tell you so with E0728: “await is only allowed inside async functions and blocks”. That is not a bug in your code; it is the colouring rule. Inside a synchronous function the only way to run a future is to hand it to a driver, which is exactly what block_on is for.

let _ = fut; and let _fut = fut; are not the same. The first drops the future immediately_ is not a binding, it is a discard. The second binds it to a variable named _fut that lives to the end of the scope. That difference has bitten enough people that clippy ships a lint for it, clippy::let_underscore_future; it is in the restriction group, so it is off by default and will not fire on the gate here. Knowing it exists is the point.

Storing futures you have not driven yet

Every call to record returns the same opaque type, so a Vec of them type-checks even though you cannot name that type. You need to drop some and drive others, which means each slot has to be emptiable — a Vec<Option<_>> with take() is the natural shape. Assigning None into a slot drops whatever was there, right at that moment.

Loading visualization…