Understanding Futures: The Lazy Computation Model That Powers Rust's Async
In Rust, calling an async function creates a lazy Future that does nothing until .await-ed. Discover how zero-cost state machines power async.
Understanding Futures: The Lazy Computation Model That Powers Rust's Async (Or: Why Nothing Happens Until You .await It)
There's a conceptual trap that catches every developer transitioning to async Rust from JavaScript or Python: they think calling an async function starts the work. In JavaScript, when you call fetch(url), the HTTP request fires immediately—the Promise you get back represents work that's already in progress. In Python, asyncio.create_task(coro) schedules the coroutine to start running.
In Rust, calling an async function does absolutely nothing. No network request. No computation. No side effects. It constructs a Future—a value that describes work to be done—and returns it. The work only happens when someone polls the future, which typically happens when you .await it. Until then, the future just sits there, inert, like a recipe card sitting on a kitchen counter. The recipe exists, the ingredients are referenced, but nobody's cooking anything.
This "lazy by default" design seems bizarre at first and brilliant once you understand it. It means futures are composable before execution starts. It means creating a future allocates no resources until those resources are needed. It means the runtime has maximum flexibility in when and how to execute futures. And it means Rust's async model compiles down to state machines that are exactly as efficient as hand-written polling code—because that's literally what the compiler generates.
At RantAI, understanding futures at this level is what separates "I can write async code" from "I can design async architectures." This article, drawn from Chapter 6, Section 6.2 of our guide "The Rust Programming Language," explains what futures actually are, how the poll model works, and why laziness is a feature, not a bug.
What a Future Actually Is
At its core, a Future is a trait with one method:
trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
enum Poll<T> {
Ready(T), // The value is available
Pending, // Not ready yet — come back later
}
When the runtime calls poll(), the future either returns Ready(value) (done!) or Pending (not done yet—I'll wake you when something changes). That's the entire interface. Everything else—.await, tokio::spawn, select!, join!—is built on top of this simple protocol.
The async/await Sugar
When you write an async fn, the compiler transforms it into a state machine that implements Future:
// What you write:
async fn fetch_data(url: &str) -> String {
let response = make_request(url).await; // Pause point 1
let body = response.text().await; // Pause point 2
body
}
// What the compiler generates (conceptually):
// A state machine with three states:
// State 0: Before first .await — call make_request, store future
// State 1: After first .await — got response, call .text(), store future
// State 2: After second .await — got body, return it
Each .await becomes a state transition. The compiler generates an enum with a variant for each state, plus the data needed to resume from that state. No heap allocation for the state machine itself (it can live on the stack). No dynamic dispatch. Just a struct with an enum discriminant that advances through states when polled.
Laziness in Action
async fn greet(name: &str) -> String {
println!("Building greeting..."); // This doesn't run yet!
format!("Hello, {}!", name)
}
#[tokio::main]
async fn main() {
let future = greet("Alice"); // Nothing printed! Future created but not polled.
println!("Future created, but greet hasn't run yet.");
let result = future.await; // NOW greet runs: "Building greeting..."
println!("{}", result); // "Hello, Alice!"
}
The println! inside greet doesn't execute when greet("Alice") is called—it executes when the future is .awaited. This is fundamentally different from JavaScript's Promises, where the executor function runs immediately.
Why Laziness Matters
Zero allocation until needed. Creating a future doesn't allocate network buffers, open file handles, or acquire resources. Resources are acquired when the future is polled. This means you can create thousands of futures and only consume resources for the ones that actually execute.
Composability before execution. You can combine futures with join! (run concurrently), select! (take the first to complete), or chain them with .then() and .map() before any work starts. The composition describes the structure of concurrent work without committing to execution.
Cancellation is free. Dropping a future cancels it. Since the work hasn't started (or has been paused at an await point), dropping the future struct cleans up any in-progress state. No need for cancellation tokens, interrupt signals, or cooperative cancellation protocols.
use tokio::time::{sleep, Duration, timeout};
#[tokio::main]
async fn main() {
// If slow_operation takes more than 1 second, it's cancelled (dropped)
match timeout(Duration::from_secs(1), slow_operation()).await {
Ok(result) => println!("Got result: {}", result),
Err(_) => println!("Operation timed out — future dropped, resources freed"),
}
}
async fn slow_operation() -> String {
sleep(Duration::from_secs(5)).await;
"finally done".to_string()
}
The Poll Model: Cooperative Scheduling
The runtime doesn't busy-loop polling futures. It uses a waker mechanism: when a future returns Pending, it registers a waker with the I/O system. When the I/O event occurs (data arrives on a socket, a timer expires), the waker notifies the runtime, which re-polls the future. This is event-driven scheduling with zero busy-waiting—the runtime only does work when there's work to do.
This is why Rust's async is so efficient: the compiled state machine + event-driven polling model produces code that's equivalent to hand-written epoll/kqueue event loops, but expressed as readable async/await syntax.
Broader Implications: Futures as Architecture
At RantAI, we think of futures as descriptions of concurrent work. When we design an API request handler, we compose futures: "fetch from cache AND query database, take whichever completes first, then transform the result, then serialize." This composition is the architecture. The runtime handles the scheduling. The compiler generates the state machine. We focus on the what, not the how.
Practical Applications & Strategic Takeaways
For newcomers: Remember: calling an async function creates a future. The work happens when you .await. If you're not seeing side effects from an async call, you probably forgot to .await it.
For JavaScript developers: Rust futures are lazy (nothing runs until polled). JavaScript Promises are eager (the executor runs immediately). This means Rust gives you a composition window before execution that JS doesn't have.
For systems programmers: Futures compile to state machines. No heap allocation for the future itself. No runtime overhead beyond what you'd write by hand. This is genuinely zero-cost abstraction—the async syntax is a compile-time convenience, not a runtime cost.
Our Commitment to Open Knowledge
RantAI is committed to open education. Futures and the poll model are covered in Chapter 6, Section 6.2 of our guide, "The Rust Programming Language," freely available online.
Explore these concepts further: https://trpl.rantai.dev
Support Our Mission & Get Your Handbook
Get the Handbook on Amazon KDP: https://www.amazon.com/dp/B0DHCMD3F2
Get the Handbook on Google Play Books: https://play.google.com/store/books/details?id=INwfEQAAQBAJ
When did the lazy future model click for you? Was it the "nothing happens until .await" realization? Or the moment you understood that dropping a future cancels it? Share below!
#RustLang #Futures #AsyncAwait #Concurrency #RantAI #LearnRust #ZeroCost #StateMachine #Performance #NonBlocking
Want to learn more?
Connect with our team to discuss how AI can transform your enterprise.