Who Runs Your Rust Future? Hands-On Intro to Async Rust A new hands-on series teaches async Rust by building a custom executor from scratch, bridging the gap between understanding Future internals and shipping production code with Tokio. The series targets developers familiar with async in other languages and Rust basics, starting with the fundamental question of who runs a Rust Future. Chapter 1 Who Runs Your Rust Future? Hands-On Intro to Async Rust On AI assistance Discord https://discord.com/invite/cD9qEsSjUH and I'll work on it. Async Rust is usually taught from one of two directions. The async book and the runtime write-ups show you the internals: how Future , poll , and Pin work, and you can even hand-build a tiny executor. Tokio’s guides go the other way, you wire up a real service and it runs. Both are genuinely useful. What is harder to find is the bridge between them, the part that connects understanding how async works to actually shipping with it. That bridge is what this series is. You build the engine yourself, the future, the waker, the executor, until you understand every piece, and then you drive the real one, Tokio, and recognize those same pieces because you built them. This series assumes two things. First, that you’ve written async and await code in some language JS, C , Python 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 /books/the-impatient-programmers-guide-to-bevy-and-rust/ chapters . I’ll use JavaScript for the comparisons throughout, but the same idea exists in C and Python. 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: js async function getUser { / ... / }const user = await getUser ; // and it just... works You write async function , add 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. In JavaScript you never had to ask who keeps that loop running. But what about Rust? Who actually runs your async code? Or, in Rust’s own terms: who runs your future? 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. 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 then and there, and by the next line its return value is already sitting in your variable: A normal functionYou call it, it runs immediately, and hands back the finished value.fn add a: i32, b: i32 - i32 { a + b }let sum = add 2, 3 ;+Runs right now. sum is 5 before the next line even starts. An async function doesn’t do that. You call it and it hands you back a placeholder 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 placeholder; 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: there’s a write side, filled with the value once the work finishes, and a read side, the handle you hold and await. In JavaScript the Promise you await is the read side, and that read side is what Rust along with C , Java, and the rest calls a Future . Future is the common name for it; JavaScript’s Promise is the outlier. The write sideCode doing the work calls resolve to put the value in, when it is ready.const promise = new Promise resolve = { setTimeout = resolve "the data" , 1000 ;+Value goes IN here, one second later.} ;The read sideYou await the promise itself. The event loop runs it; await just hands you the value.const data = await promise;+Value comes OUT here, with no work from you. 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. In Node, the instant you call an async function, the placeholder 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 : Calling it does nothingIn Rust an async fn call builds a Future and stops. The body has not run.async fn get user - User { / ... / }let f = get user ;+f is a Future. Not one line of the body has executed yet. 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. 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 . Most polls hit a wall and say “not yet” . One poll, eventually, runs off the end and says “done” . Async is just that loop: hitting walls, waiting, trying again, until the last poll finally makes it through. 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. 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: 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 since we need to update those fields we ask for writable access through &mut in the first argument. But there’s a problem. item points back into the future itself, at the cart field sitting right beside it. A value pointing at its own insides. You have probably never written a struct like that, and that is no accident: normal Rust quietly steers you away from self-references. So you have never had to care where a value lives in memory. Move it into a function, push it into a Vec , hand it to a caller, it all just works. But if this future is moved to a new place in memory, cart goes along to the new spot while item keeps pointing at the old one. It is now pointing at empty space. A dangling pointer. Why would the future move by itself to a new place in memory? It doesn’t move itself. Your code moves it, the same way any value gets moved in Rust: Calling the async fn itself. checkout returns the future to you. That return is already a move. Passing it around. A future is a value, so handing it to any function passes it by value. That is a move. Storing it. Push it into a Vec , or keep it in a struct field. All moves. 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 for now. 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, meaning it holds the actual network connection the reply will arrive on. Your poller does not; to it a future is a black 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. The waker wakes the poller, and the poller polls 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. 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. 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. Is the Sender like our Waker? Yes and no. They look similar: both are handed off to someone else, and both signal “I am done.” But they do different jobs. The Sender carries the actual value. When the worker is done, it sends the result through it. The Waker carries no value at all. It just taps the poller on the shoulder and says “poll again.” Soon you will see how they fit together: the Sender will deliver the value, and as part of doing that, it will fire the Waker to wake the poller up. 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 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. When the Receiver future is polled and the value has not arrived yet, the future has to sleep. But something needs to wake it up the moment the value lands. So before sleeping, it leaves its Waker in Inner . Let’s start with the implementation of the Sender and the Receiver . use std::future::Future;use std::pin::{pin, Pin};use std::sync::{Arc, Mutex};use std::task::{Context, Poll, Waker};The two endsEach holds a handle to the same shared Inner.struct Sender { inner: Arc