Reading Rust

Part 4 · Concurrency & async

Futures are inert

The single biggest trap for a JavaScript developer. Calling an async function in Rust does not start anything — and two `.await`s in a row are not concurrent.

55 minThreshold conceptrefutation textPOEcontrasting casesnotional machine
What this lesson is trying to break (4)
  • Calling an async function starts the work.
  • Creating two futures and awaiting both runs them concurrently.
  • A future is a promise.
  • Async is faster than threads.
Liminality warning

This is a threshold concept: troublesome, transformative, and irreversible. You should expect to be genuinely stuck here, and being stuck is the mechanism rather than a sign that you are failing. Budget several sessions. Do not move on because you are bored — move on when you can explain it out loud without notes.

If you write JavaScript, this is the lesson where the most confident wrong model you own gets replaced. Take it slowly.

The demonstration

Predict firstcommit in writing, then look
async fn work() {
    println!("working");
}

fn main() {
    let fut = work();
    println!("main done");
}
Unlearn thiscarried over from JavaScript, and to a lesser extent C# and Python
You think

Calling an async function starts the work and hands you a handle to the eventual result. const p = fetchData() — the request is now in flight, and await p waits for something already happening.

Wrong here

A JavaScript promise is eager: creating it runs the body up to the first suspension, and the event loop drives it from then on whether or not you await. A Rust future is lazy: calling the function only builds a value. Nothing has been scheduled, nothing is in flight, and nobody is going to run it unless you hand it to something that polls.

Instead

Read async fn as a constructor for a state machine, not as “start this work”. The returned value is a description of work that has not begun. .await is the only thing that makes progress happen, and it makes progress happen only on the future you awaited.

Varies: only the language — the two code shapes are written identically

JavaScript PromiseRust Future
Starts running whenCreatedPolled
Driven byThe built-in event loopAn executor you chose and started
Ignoring itRuns anyway (and may warn about a floating promise)Never runs at all
CancellationNot possibleDrop it
Two, then await bothConcurrentSequential
CostsA heap object per promiseOne state machine, often stack-sized, no allocation

Row five is where working JavaScript code becomes silently slow Rust code. Nothing errors; it just stops being concurrent.

The trap that costs real latency

Predict firstcommit in writing, then look

Assume fetch_a and fetch_b each take one second.

async fn handler() {
    let a = fetch_a();
    let b = fetch_b();

    let x = a.await;
    let y = b.await;
}

The identical shape in JavaScript takes one second. What about here?

What a future actually is

pub trait Future {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

pub enum Poll<T> { Ready(T), Pending }

One method. The contract:

  • The executor calls poll.
  • The future does as much work as it can without blocking.
  • It returns Ready(value) if finished, or Pending if not.
  • Before returning Pending, it registers a waker from the Context, so that whatever it is waiting on can say “poll me again” when ready.

That is the entire async machinery. Everything else — tokio, async/await, streams — is built on this.

Two consequences worth holding onto:

There is no executor in std. The Future trait is standard; running one is not. This is why you must pick tokio, or smol, or async-std, and why #[tokio::main] exists — it starts a runtime and blocks on your main future. Rust is the only mainstream language that standardised the interface and left the runtime to the ecosystem, and the trade-offs of that decision are 4.4.

poll must not block. A future that calls std::thread::sleep or does blocking file I/O stalls the entire worker thread and every other task on it. This is the number one operational bug in async Rust, and the fix is tokio::task::spawn_blocking.

The state machine

async fn is a compiler transformation. This:

async fn handle(id: u32) -> String {
    let user = fetch_user(id).await;
    let posts = fetch_posts(&user).await;
    format!("{} has {} posts", user.name, posts.len())
}

becomes roughly an enum:

enum HandleFuture {
    Start { id: u32 },
    AwaitingUser  { fut: FetchUser },
    AwaitingPosts { user: User, fut: FetchPosts },   // ← user is now a field
    Done,
}

Each .await is a suspension point, and every local that is still live across one becomes a field of the struct.

That is not trivia. It explains the async error you are most likely to hit:

async fn broken(m: &Mutex<Vec<u32>>) {
    let guard = m.lock().unwrap();     // MutexGuard: not Send
    fetch().await;                     // ← guard is live across this
    println!("{:?}", *guard);
}
// error: future cannot be sent between threads safely

guard is live across the .await, so it becomes a field. MutexGuard is not Send (4.1), so the state machine struct is not Send, so tokio::spawn rejects it.

The compiler even tells you which local is responsible and where — one of the better diagnostics in the language.

The fixes: end the borrow before the await (drop the guard, or scope it), or use tokio::sync::Mutex, whose guard is Send. Note that tokio’s mutex is slower and should only be used when you genuinely need to hold a lock across an await — which is usually a design smell anyway.

Cancellation

Here is something JavaScript simply does not have.

Dropping a future cancels it. The work stops at the last suspension point, Drop runs for everything the state machine held, and that is it. No exception, no rejection, no cleanup callback.

tokio::select! {
    a = fetch_a() => handle(a),
    _ = tokio::time::sleep(TIMEOUT) => handle_timeout(),
}

When the timeout wins, fetch_a()’s future is dropped mid-flight. If it had read half a message off a socket, that half is gone.

This is what cancellation safety means, and it is a real concept with real bugs attached. A future is cancellation-safe if dropping it part-way through loses no data. tokio’s docs mark each select!-usable method as safe or not, and the classic bug is using a non-cancellation-safe read inside a select! loop and losing bytes on every timeout.

There is no JavaScript equivalent, so there is no intuition to transfer. Being aware the concept exists is most of the battle.

Pin, at reading level

You will see Pin<&mut Self> and wonder. The short version:

A generated state machine can be self-referential — if a local holds a reference to another local, both become fields, and one field points at another. Moving such a struct in memory would leave that pointer dangling.

Pin is the type-level promise that a value will not move again. It exists only to make self-referential futures sound.

For reading purposes: Pin<&mut Self> in poll means “this future is at a fixed address”. You will almost never construct a Pin by hand — Box::pin, pin!, and async blocks handle it. If a bound demands Unpin and your type is not, Box::pin is the usual answer.

Explain it to yourselfwrite it — thinking it does not count

A colleague’s handler is slower than expected:

async fn handler(ids: Vec<u32>) -> Vec<User> {
    let mut out = Vec::new();
    for id in ids {
        out.push(fetch_user(id).await);
    }
    out
}

Explain what is happening, in terms of futures being inert, and give them the fix. Then say when your fix would be a bad idea — because it can be.

What you should now be able to say

  • Calling async fn builds a state machine and runs nothing. Lazy, not eager.
  • Two futures awaited in sequence are sequential. join! or spawn for concurrency.
  • Future is one method, poll, returning Ready or Pending, plus a waker.
  • std has no executor — that is why you pick tokio.
  • Blocking in async blocks a whole worker. spawn_blocking exists for this.
  • Locals live across .await become struct fields, which is why a non-Send guard breaks spawn.
  • Dropping cancels, which is why cancellation safety is a concept.
  • Pin exists because state machines can be self-referential.

Next: 4.4 — The async ecosystem and its arguments, which is mostly about opinions you should be able to hold.

In the wildgo read the real thing now, while it is fresh
  • std::future::Future — the whole traitOne required method: poll. Read its signature and note that nothing in std ever calls it — there is no executor in the standard library. That absence is the design decision this lesson is about.
  • tokio::join! and tokio::select!These are how you get concurrency from futures. Read join! first, then select!, then the section of the select! docs headed "cancellation safety" — that concept has no equivalent in JavaScript at all.
Scheduled for recall4 items added to your review queue

These are not shown to you now on purpose. They will surface in Review tomorrow, mixed in among items from other parts of the course — not grouped by topic. Sorting out which idea applies is the part that transfers.