On AI assistance
Yes, AI assistance was involved in writing this chapter. I worked on the structure, the technical decisions, the approach, how to structure the code, and put together a list of anticipated questions learners would have. AI helped expand on the structure and explanations, and I edited throughout. In total, I spent around 20–25 hours on each chapter, between coding and writing. If anything feels off in any section, let me know on Reddit or Discord and I'll work on it.

Most async Rust tutorials send you down one of two roads, and both dead-end in the same spot. Read the async book and you will learn Future, poll, and Pin, and even hand-build a toy executor, then still not know how to ship anything real. Just reach for Tokio and your code works, but Tokio is a black box, so the first strange error leaves you guessing at what is actually running it.

This series takes the road between them. You build the engine yourself, the future, the waker, the executor, until none of it is magic, and then you drive the real one, Tokio, and recognize every part because you already built it. It starts with the smallest, most fundamental question in async Rust: who actually runs your async code? Or, in Rust’s own terms: who runs your future?

This series assumes two things. First, that you’ve written async and await code in JavaScript before. Second, that you’re comfortable with Rust’s basics: structs, enums, associated functions, and closures (all covered in the free chapters of The Impatient Programmer’s Guide to Bevy and Rust).

A quick note on the series: I plan to keep at least 5 to 8 chapters free to read here, with the rest collected in a paid ebook. The free chapters may not follow the numbering in order, so some will land out of sequence.

Future

If you’ve shipped any modern JavaScript, you’ve done this a hundred times:

Pseudocode, don't use.
async function getUser() { /* ... */ }const user = await getUser();   // and it just... works

You write async function, sprinkle in some await, and it runs. You never had to think about what runs it, because something always did. The node process ships a built-in event loop: a hidden engine that picks up your Promises and pushes each one forward until it’s done. The loop is always there, behind the curtain, doing the work for you.

Node's event loop is a cycle that never stops. On every spin it grabs a Promise that can move forward, runs it until it has to wait, then sets it aside and picks up the next one. Node keeps the loop turning for you. Node's event loop is a cycle that never stops. On every spin it grabs a Promise that can move forward, runs it until it has to wait, then sets it aside and picks up the next one. Node keeps the loop turning for you.

Rust does the opposite, on purpose. It ships no event loop at all. Nothing in the language is sitting around waiting to run your async code. If you want one, you bring it in. Or, the way we’re going to learn it, you build one yourself.

A second job A second job
A second job

That sounds like a missing feature. By the end of this series it’ll feel like the opposite. But before we ask who runs it, let’s be precise about what async is.

A normal function is the kind you write every day. You call it, it runs top to bottom right then and there, and by the next line its return value is already sitting in your variable:

fn add(a: i32, b: i32) -> i32 { a + b }let sum = add(2, 3);

An async function doesn’t do that. You call it and it hands you back a box instead of a result. Not the value, but a thing that represents a value that isn’t ready yet, stamped “I’ll be done later.” Every async language has this box; they just use different names for it:

  • JavaScript calls it a Promise.
  • Rust calls it a Future.

Different words, identical idea: a value that represents work that isn’t finished yet.

Hang on, doesn’t JavaScript have a Future too?

Not as a type. You’ll still hear both words, and they’re not interchangeable: a promise is the write side, filled with the value once the work finishes; a future is the read side, the handle you hold and await.

resolve is the write side; the Promise you await is the read side.
const promise = new Promise((resolve) => {  setTimeout(() => resolve("the data"), 1000);});const data = await promise;

Rust names the handle you hold a Future, because in Rust you are the one who reads it, by polling, as you’ll see in a moment. So whenever you read Future in this series, picture a Promise that you have to read yourself.

Calling an async function returns a Promise in Node and a Future in Rust. In Node a hidden built-in event loop drives the Promise to completion on its own. In Rust the Future is lazy and does nothing on its own: no code runs until you poll it, because no event loop ships with the language. Calling an async function returns a Promise in Node and a Future in Rust. In Node a hidden built-in event loop drives the Promise to completion on its own. In Rust the Future is lazy and does nothing on its own: no code runs until you poll it, because no event loop ships with the language.

In Node, the instant you call an async function, the box is live. The hidden event loop grabs it and drives it to completion whether you’re watching or not. await is just you asking for the value once it’s ready. The work was always going to happen.

In Rust, calling an async fn does nothing:

async fn get_user() -> User { /* ... */ }let f = get_user();

get_user() handed you a Future and then stopped. The body hasn’t executed. It’s lazy: it just sits there, fast asleep, parked in a variable. It will do absolutely nothing, forever, until something polls it, the technical word for tapping it on the shoulder and asking “can you make any progress?” And since Rust ships no event loop, the thing doing that tapping has to be a runtime like Tokio that you pull in, or, the way we’ll learn it, code you write yourself.

My Future My Future
My Future

You are holding this future. What can you actually do with it to get the work done and pull the value out?

Polling

A Future gives you exactly one method. You can poll it. Polling is you asking the future a single question, “can you make any progress right now?” The future runs as far as it can, then answers one of two ways: Ready(value) if it finished, or Pending if it had to stop and wait for something.

That phrase, “runs as far as it can,” is the whole idea, so let’s make it concrete with a future that has a real job: checking out a shopping cart. The async fn loads the cart from the database, then calls a payment provider’s API to charge the card, and finally returns a confirmed order. Neither the database nor the payment API answers instantly, so the future has to stop and wait at each of those two calls. So let’s poll it, and watch how each poll drives the future forward:

  • Poll #1. The future starts and fires off the database query to load the cart. The database hasn’t replied yet, so it can go no further. It sleeps right there and answers Pending.
  • Poll #2, once the database replies. It resumes from exactly where it paused, takes the cart, and runs on until it calls the payment API. The charge is still in flight, so it sleeps again and answers Pending.
  • Poll #3, once the payment goes through. It resumes one last time, runs clean to the end of the function, builds the confirmed order, and answers Ready(order).

So “runs as far as it can” means this: pick up from wherever you last paused, and go forward until you either finish or hit the next thing you have to wait on. The early polls each end at a wait, so they hand back Pending. The last poll has nothing left to wait for, so it runs off the end of the function and hands back Ready. That difference, a poll that sleeps versus the poll that finishes, is the whole rhythm of async, and the future stays asleep mid-function between every one of those polls, holding what it has gathered so far.

And here is the part that trips people up coming from Node. poll is not just checking on the future, it is what runs it. Calling the async fn ran none of that body. The first poll is what starts it, and each poll after carries it one stretch further. So the rule is blunt: no poll, no progress. A future that is never polled never runs a single line. Nothing is running it in the background. The only thing that moves it forward is you calling poll again.

“Cool, so I just have to call poll. That sounds easy”. Well, let’s have a look at the function signature.

The full poll signature: fn poll(self: Pin<&mut Self>, cx: &mut Context) returns Poll<Self::Output>. One short line with three unfamiliar pieces, so we take it apart one at a time. The full poll signature: fn poll(self: Pin<&mut Self>, cx: &mut Context) returns Poll<Self::Output>. One short line with three unfamiliar pieces, so we take it apart one at a time.

Let’s understand these one by one.

The Future Itself

self: Pin<&mut Self>

Back at the start I asked you to picture a future like a Promise, a representation of work that is not finished yet.

So what is that representation, concretely? It is a piece of data. When you write an async fn, the compiler turns your code into a value that holds everything it needs to remember to carry on later. The future is that value: your async code, in data form. And as the future runs, poll by poll, that data is what moves forward.

So a future is just a small struct, and every field it holds is its state, the stuff it has to remember between polls.

The diagram below is a way to picture the future. Treat it as a mental model to build your intuition, not the exact, byte-level layout of a real future in memory:

A mental model of the checkout future as a struct holding its state: a step (paused at charge card), a loaded cart, and an item that points back into that cart, a sibling field in the same struct. A picture to build intuition, not the exact memory layout. A mental model of the checkout future as a struct holding its state: a step (paused at charge card), a loaded cart, and an item that points back into that cart, a sibling field in the same struct. A picture to build intuition, not the exact memory layout.

Our checkout future, paused mid-job, is holding things like where it stopped (at “charge card”), the cart it already loaded from the database, and an item it is still pointing at inside that cart. Each poll picks this state up and runs it forward, and the &mut is what lets a poll update those fields.

But there’s a problem. item points back into the future itself, at the cart field sitting right beside it. And in Rust, values move around all the time: returned from a function, pushed into a Vec, passed by value. Each move copies the whole future to a fresh address, and item would be left holding the old one, now pointing at a spot the future just left: a dangling pointer. That is what Pin solves. It promises the future never moves in memory, so that pointer stays valid.

I know this is getting tricky. These concepts are genuinely hard to wrap your head around, and they deserve a fuller explanation than I’ll give here. I’ll come back to all of it in a later chapter: why a future ends up pointing into itself at all, how the compiler lays it out, and why values move around in memory in the first place.

For now, just hold onto this. That Pin in self: Pin<&mut Self> is a guarantee that the future won’t move in memory while it’s being polled. That’s all you need yet.

No returns No returns
No returns

The Waker

cx: &mut Context

So imagine our checkout future, waiting on the payment provider. The initial poll you made starts the work and you get Pending. So, when do you poll again?

Right away is pointless: the provider has not answered, so the future would only hand you another Pending. You really have two options.

  • First, keep polling in a tight loop until it finally says Ready. It works, but it burns a whole CPU core asking “done yet? done yet?” thousands of times a second while the provider is still processing.
  • Second, go to sleep, and let something wake you the moment the answer lands. This is what a real runtime does, and that brings us to the question: who tells you the moment has come?

That is the whole job of the Waker. The future knows what it is waiting on: it made the database call, it owns the socket. Your poller does not; to it a future is an opaque box with a single poll button, so it has no way to know when polling is worth it again.

So poll hands the future a Waker, tucked inside the Context. The Waker is a callback that means “wake me when I can make progress.” The future registers it with whatever it is blocked on, the timer or the network connection the reply will arrive on, and returns Pending. The moment that thing is ready, it fires the waker, and your poller polls the future again.

Inside cx is a Waker, drawn as a bell. The poller hands the bell to the future, the future leaves it with whatever it is waiting on (a payment provider) and returns Pending, and the poller sleeps. When the provider is ready it rings the bell, which wakes the poller to poll the future again. Inside cx is a Waker, drawn as a bell. The poller hands the bell to the future, the future leaves it with whatever it is waiting on (a payment provider) and returns Pending, and the poller sleeps. When the provider is ready it rings the bell, which wakes the poller to poll the future again.

The Output

Every poll ends in one of exactly two answers: Ready(value), where the value is the future’s Output, whatever it finally produces (an Order, a u32, a String), or Pending, meaning it is not done yet. Ready ends the loop; Pending sends you back to sleep until the next wake.

poll returns a Poll of the future's Output: either Ready carrying the Output value, or Pending meaning not done yet. Ready ends the loop; Pending goes back to sleep until the next wake. poll returns a Poll of the future's Output: either Ready carrying the Output value, or Pending meaning not done yet. Ready ends the loop; Pending goes back to sleep until the next wake.

Build a Oneshot Channel

We have met async’s three moving parts: the Future, the poll that drives it, and the Waker that signals when to poll again. Now let’s put them to work on a real problem.

Imagine a web server backed by a single database connection. A connection like that cannot be used from two places at once, so instead of letting every request grab it, you hand it to one background worker: a task whose whole job is to hold the connection and run queries on it.

Now requests pour in, each needing a lookup. Say Handler A is serving GET /products/:id and needs that product, while Handler B is serving GET /users/:id and needs that user. Neither can touch the connection directly, and neither can just call the worker like a normal function, because the worker is shared: every handler drops its query into one queue, and the worker pulls them off and runs them against the connection one at a time. Getting queries in is easy.

The hard part is getting each answer back out, to the exact handler that asked and not some other one. There is no single caller to return to anymore.

Many request handlers send work to one shared worker through a single queue (an mpsc channel). The worker, which owns one database connection, handles the requests one at a time. When it finishes a job it produces an answer, but there is no single caller to return to: the return path is a red dashed arrow with a question mark, asking which of the waiting handlers the answer should go back to. Many request handlers send work to one shared worker through a single queue (an mpsc channel). The worker, which owns one database connection, handles the requests one at a time. When it finishes a job it produces an answer, but there is no single caller to return to: the return path is a red dashed arrow with a question mark, asking which of the waiting handlers the answer should go back to.

The solution is to give each request a way to send its answer back. When a handler builds its request, it creates a one-time link with two ends: a sending end and a receiving end. It keeps the receiving end and tucks the sending end into the message. While the worker pulls the request off the queue and runs the query, the handler waits on its receiving end. When the result is ready, the worker pushes it through the sending end, and it arrives at that handler’s receiving end and nowhere else; waiting on it returns the result. This one-time, one-value, one-destination link is called a oneshot channel.

Let’s build the Oneshot channel by hand, with the standard library and the three pieces you just met: the Future, its poll, and the Waker.

Create a new rust project.

cargo new oneshotcd oneshot

Sender and Receiver

Let’s start by writing the two components of the Oneshot channel, the Sender and the Receiver. For a value dropped in at the Sender to come back out at the Receiver, both ends have to reach the same piece of memory: one writes the value there, the other reads it from there. Let’s call this shared piece of memory Inner.

Why Inner and why not SharedState?

SharedState would be a fair name for it, but think about where it lives: the Sender and Receiver are the parts your program holds and passes around, and this struct hides inside them, behind those two handles, the bit nobody touches directly. It is the channel’s inner workings, so we will follow the usual convention and call it Inner.

What should Inner hold? Two things, and you have already met both:

  • The value, in a slot that sits empty until something is sent.
  • A waker. The same move you saw when polling: a future that is not ready leaves its Waker so it can be woken once there is progress. Here, if the Receiver is awaited before the value is sent, it leaves its Waker in Inner, and send wakes it.

Wait, isn’t the Receiver always waiting before the value is sent?

It feels that way, but no, it is a race. The worker runs on its own, so the first poll of the Receiver either finds the value already in Inner and returns Ready straight away (the worker was quick), or finds nothing yet and returns Pending (the worker is still busy). Only in that Pending case does the Receiver leave its Waker for send to ring later.

Let’s start with the implementation of the Sender and the Receiver.

src/main.rs
Replace src/main.rs with the following code.
use std::future::Future;use std::pin::{pin, Pin};use std::sync::{Arc, Mutex};use std::task::{Context, Poll, Waker};struct Sender   { inner: Arc<Mutex<Inner>> }struct Receiver { inner: Arc<Mutex<Inner>> }struct Inner {    value: Option<String>,    waker: Option<Waker>,}

So what is that Arc<Mutex<…>> wrapped around Inner for?

Remember, Inner has to be shared between the Sender and the Receiver, and they usually sit on separate threads. Arc (a reference-counted pointer) is what lets them share it: both ends hold a handle to the same Inner, across threads. But Arc alone only gives a read-only view, and both ends need to write into Inner too (the sender drops in the value, the receiver leaves its waker). That is what Mutex adds: a lock, so one end at a time can open Inner and change it safely.

Decoding the type Arc<Mutex<Inner>> as nested boxes. Inner, the innermost box, holds two slots: value (None) and waker (None). It is wrapped in a Mutex, a lock that lets one writer at a time. That is wrapped in an Arc, a shared pointer with two handles. The Sender on Thread A and the Receiver on Thread B each hold an Arc handle pointing at the same shared Inner; the Sender writes the value and fires the waker, the Receiver reads the value and leaves a waker. Decoding the type Arc<Mutex<Inner>> as nested boxes. Inner, the innermost box, holds two slots: value (None) and waker (None). It is wrapped in a Mutex, a lock that lets one writer at a time. That is wrapped in an Arc, a shared pointer with two handles. The Sender on Thread A and the Receiver on Thread B each hold an Arc handle pointing at the same shared Inner; the Sender writes the value and fires the waker, the Receiver reads the value and leaves a waker.

Let’s write the constructor, oneshot, that sets up the channel. The trick for sharing memory between the Sender and the Receiver is to clone the inner handle: cloning an Arc does not duplicate the Inner, it just hands back another pointer to the same one, exactly as the diagram above shows.

Append this to src/main.rs
fn oneshot() -> (Sender, Receiver) {    let inner = Arc::new(Mutex::new(Inner { value: None, waker: None }));    (Sender { inner: inner.clone() }, Receiver { inner })  }

Now let’s write send. What does it need to do? Get the value into the shared Inner so the receiver can find it, and, if the receiver is already parked waiting, wake it. Let’s build that up.

Append this to src/main.rs
impl Sender {    fn send(self, value: String) {        let mut inner = self.inner.lock().unwrap();        inner.value = Some(value);        if let Some(waker) = inner.waker.take() {            waker.wake();        }    }}

We have send take self by value, not &self, and that is on purpose: a oneshot fires exactly once, and taking self lets the compiler enforce it for us, after one call the Sender is consumed, so a second send will not even compile.

Then we lock the Inner, put the value into the value slot, and check the waker slot. If the receiver already polled and left a waker, we fire it (“there is progress, poll me again”). If it has not polled yet, there is nothing to wake, so the value just waits in the slot for the next poll to pick it up.

Why lock?

Because the sender and the receiver sit on different threads and both reach into the same Inner, so the lock makes sure they take turns instead of clobbering each other mid-write.

Poll

Now let’s implement the poll function, the exact mirror of what send just did. It needs to look for the value, and react to whether it is there. So it locks the same Inner and checks the value slot.

If the value has arrived, we are done: hand it back as Poll::Ready(value). If it has not, the receiver cannot finish yet, so it clones the waker out of cx, leave it in the waker slot for send to fire later, and return Poll::Pending.

Append this to src/main.rs
impl Future for Receiver {    type Output = String;    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<String> {        let mut inner = self.inner.lock().unwrap();        if let Some(value) = inner.value.take() {            Poll::Ready(value)        } else {            inner.waker = Some(cx.waker().clone());            Poll::Pending        }    }}

Our Receiver does nothing until something polls it, and so far nothing does. That something is a runner: a loop that drives a future to completion. Let’s build the smallest one that works and call it block_on. You hand it a future, it polls until Ready, and hands you the value. (A runner that juggles many futures at once has a name, the executor, and we build that one a few chapters from now. block_on is its one-future ancestor.)

Why block_on and not loop?

The loop is just the how on the inside. The name says what it does to you, the caller: it blocks the current thread (your main) on a future until that future resolves, then hands back the value. It is the doorway from ordinary synchronous code into async, and it is what real runtimes like Tokio call this same function, so the name will already be familiar when you meet it out in the wild.

But isn’t async supposed to be non blocking?

It is, on the inside. The non-blocking part is in the polling: when a future comes back Pending, the executor does not sit there idle, it turns around and polls other futures instead, so one thread can drive many of them at once. block_on is the deliberate exception at the very edge. Synchronous code like main has to wait somewhere for the whole async job to finish, and this is where it does, blocking just the one calling thread.

In a web server, whose job is to keep running and answer requests, but main is ordinary synchronous code: left to itself it runs to the bottom and the program exits. block_on is what keeps main alive, you hand it the server as one big future and it parks right there, driving the server for as long as it runs. That is the single block, at the top, and every request the server handles inside it stays non-blocking. That is the non-blocking you were promised.

A synchronous Web Server blocks exactly once, by calling block_on, at the edge of the async world. Inside that async world, which is non-blocking, a single Executor thread polls many in-flight requests in turn (GET /products, GET /users, GET /orders); one is Ready while two are Pending. A Pending request is just skipped and polled again later rather than waited on, so the one thread keeps all of them moving. A synchronous Web Server blocks exactly once, by calling block_on, at the edge of the async world. Inside that async world, which is non-blocking, a single Executor thread polls many in-flight requests in turn (GET /products, GET /users, GET /orders); one is Ready while two are Pending. A Pending request is just skipped and polled again later rather than waited on, so the one thread keeps all of them moving.

So how should block_on work? The obvious version is a tight loop: poll, and if it comes back Pending, poll again.

But imagine what that does while the worker is still off on its database query: block_on, running on the calling thread, would spin the CPU at millions of polls a second, every one of them coming back Pending because the worker has not sent yet.

We can do better. The channel already has the waker mechanism wired in, so on Pending we can put the thread to sleep, as long as we hand it a waker that actually wakes the thread.

Waker

For that we need a Waker, one whose wake() actually wakes this thread back up. Building one by hand is low-level work, and we will do exactly that, from scratch, in a later chapter. For now the standard library gives us a shortcut: the Wake trait. You write a single wake method on a type of your own, and Waker::from turns it into a real Waker.

So what should wake do? Wake our sleeping thread, and Rust threads already have a built-in pair for exactly that. thread::park() puts the current thread to sleep, and calling .unpark() on that thread’s handle wakes it back up.

src/main.rs
Add this to the imports of main.rs
use std::task::Wake;use std::thread::Thread;

Now let’s implement the wake method.

src/main.rs
Append this to src/main.rs
struct ThreadWaker(Thread);impl Wake for ThreadWaker {    fn wake(self: Arc<Self>) {        self.0.unpark();                                                       }}

Block On

Now let’s write block_on itself. Its job is to drive the future to completion, and to sleep rather than spin in between. So first we set it up: pin the future so we can poll it, and wrap our Waker, the one tied to this thread, in a Context to hand to poll.

Then we loop and poll. If the future comes back Ready, we are done: hand back the value. If it comes back Pending, there is nothing to do until something makes progress, so we park the thread and sleep. Our waker is what unparks it later, and when it does, we loop back and poll again.

Append this to src/main.rs
fn block_on<F: Future>(future: F) -> F::Output {    let mut future = pin!(future);    let waker = Waker::from(Arc::new(ThreadWaker(thread::current())));    let mut cx = Context::from_waker(&waker);    loop {        match future.as_mut().poll(&mut cx) {            Poll::Ready(value) => return value,            Poll::Pending      => thread::park(),        }    }}

Putting it Together

Now we have every piece: the channel, the receiver that implements a future, the waker, and the runner. Let’s put them together in main and run it.

oneshot() hands us back a pair, (tx, rx), the two ends of the channel: tx is the Sender (tx is the usual name for transmit) and rx is the Receiver (for receive), which is the future we drive. We give tx to a worker thread that stands in for a slow database query, a half-second sleep plays the part of the real query here, and once it is done it sends the row back through tx. On the main thread, block_on(rx) drives the receiver and parks until that row arrives.

All the pieces together. Append this to src/main.rs, then run it.
use std::thread;use std::time::Duration;fn main() {    let (tx, rx) = oneshot();    thread::spawn(move || {        thread::sleep(Duration::from_millis(500));        tx.send("a fresh database row".to_string());    });    let row = block_on(rx);    println!("{row}");}

The one new thing here is move. The worker runs on a separate thread that can keep going after main moves on, so its closure cannot simply borrow tx from the outside, the borrow might end while the thread is still using it. move tells the closure to take ownership of tx: it carries the sender onto the thread and does its work there, calling tx.send(...) once the row is ready.

cargo run

After about half a second it prints:

a fresh database row

There it is: a future you wrote, run to completion by block_on, a runner you wrote, out of nothing but std. That is the same poll, park, wake loop a real runtime runs on, just smaller. The complete code for this chapter is in the series repo on GitHub.

That work is the rest of the series. In the chapters ahead you build a Waker by hand, see what an async fn compiles into and why Pin is needed, then grow block_on into an executor that schedules many futures on one thread. From there: futures that wake on real network sockets and timers, a channel that carries many values, an async mutex, and cancellation. Each one you build first, then use its Tokio version by name.

Who runs it now Who runs it now
Who runs it now