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.
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.
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
async fn work() {
println!("working");
}
fn main() {
let fut = work();
println!("main done");
}main doneworking never prints. The body of work never executes at all.
(The compiler warns you: “unused implementer of Future that must be used —
futures do nothing unless you .await or poll them”. That warning text is the
whole lesson.)
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.
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.
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 Promise | Rust Future | |
|---|---|---|
| Starts running when | Created | Polled |
| Driven by | The built-in event loop | An executor you chose and started |
| Ignoring it | Runs anyway (and may warn about a floating promise) | Never runs at all |
| Cancellation | Not possible | Drop it |
| Two, then await both | Concurrent | Sequential |
| Costs | A heap object per promise | One 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
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?
Two seconds. Sequential.
In JavaScript, fetch_a() starts the request immediately, so by the time you
await, both are already in flight and the total is one second.
In Rust, fetch_a() builds an inert state machine. a.await polls only a,
driving it to completion over one second. b has not started. Then b.await
runs it for another second.
This compiles with no warning and no error. It is simply slower than the author intended, and it is one of the most common performance bugs in Rust services written by people arriving from JavaScript.
Concurrency has to be requested explicitly:
let (x, y) = tokio::join!(fetch_a(), fetch_b()); // 1s — both polledor, for genuinely independent work that should also run on other threads:
let ha = tokio::spawn(fetch_a());
let hb = tokio::spawn(fetch_b());
let (x, y) = (ha.await?, hb.await?); // 1s, and paralleljoin! polls both futures on the same task, interleaving them at suspension
points. spawn hands each to the runtime as a separate task, which may run them
on different threads — and requires Send + 'static, which join! does not.
Being able to explain that distinction is a genuinely useful thing for a tech lead to have, because “just spawn everything” is a real and costly habit.
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, orPendingif not. - Before returning
Pending, it registers a waker from theContext, 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.
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 fnbuilds a state machine and runs nothing. Lazy, not eager. - Two futures awaited in sequence are sequential.
join!orspawnfor concurrency. Futureis one method,poll, returningReadyorPending, plus a waker.stdhas no executor — that is why you pick tokio.- Blocking in async blocks a whole worker.
spawn_blockingexists for this. - Locals live across
.awaitbecome struct fields, which is why a non-Sendguard breaksspawn. - Dropping cancels, which is why cancellation safety is a concept.
Pinexists 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.