The recipe that never cooks
You write an async fn to fetch a user profile from an API. You call it in main. The program prints nothing and exits immediately. You stare at the code. The function is right there. It's async. Why did it vanish?
You forgot to poll it. In Rust, async doesn't mean "runs in the background." It means "I'm building a recipe for work that hasn't started yet." Calling an async function returns a Future. That Future is a value. It sits in a variable. It does absolutely nothing until something else asks it to make progress.
If you hold a Future and never poll it, the computation never happens. The memory for the future gets allocated, the state machine gets initialized, and then the scope ends. The future is dropped. The work is lost. This is the first rule of async Rust: Futures are inert until polled.
What a Future actually is
A Future is a placeholder for a value that will be available later. It represents a computation that might need to pause and resume multiple times. The trait looks like this:
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
You rarely implement this trait manually. The compiler generates the implementation for you when you write async fn or async { }. But understanding the signature explains how async works.
The poll method asks the future: "Are you done yet?" The future checks its internal state. If the work is finished, it returns Poll::Ready(value). If the work is waiting on something else, like a network packet or a timer, it returns Poll::Pending.
When a future returns Pending, it tells the executor to come back later. The executor stores the future and moves on to other work. When the external event happens, the executor calls poll again. This cycle repeats until the future returns Ready.
Think of a Future like a ticket at a deli counter. Handing in the ticket doesn't start the sandwich. The ticket just sits on the counter. The worker only looks at it when they have time and ingredients. You have to check back to see if it's ready. If you just hold the ticket and walk away, the sandwich never gets made.
use std::future::Future;
/// Returns a future that resolves to a string.
/// Calling this function does not execute the body.
async fn get_greeting() -> String {
"Hello".to_string()
}
fn main() {
// This creates a Future. The body of get_greeting has not run.
let future: impl Future<Output = String> = get_greeting();
// The future is just a value now. It's a state machine waiting to be polled.
// If main ends here, the future is dropped and nothing happens.
}
Don't treat async as a keyword that spawns a thread. It's a keyword that builds a state machine. The state machine does nothing until you run it.
The state machine under the hood
When you write an async block, the compiler transforms it into a struct that implements Future. This struct contains an enum representing the current state, plus any local variables the block captures.
Consider this async block:
async {
let data = fetch_data().await;
process(data).await
}
The compiler generates something roughly equivalent to this:
enum AsyncState {
Start,
WaitingForFetch,
WaitingForProcess,
Done,
}
struct GeneratedFuture {
state: AsyncState,
data: Option<String>, // Holds the value between awaits
}
When you poll the future, the poll method matches on the state. In Start, it calls fetch_data(). If fetch_data isn't ready, the future stores the inner future, switches state to WaitingForFetch, and returns Pending.
Next time the executor polls, the future is in WaitingForFetch. It polls the inner fetch_data future. If that returns Ready, the future extracts the data, stores it in the data field, switches to WaitingForProcess, and calls process().
This state machine approach allows async code to look sequential while executing cooperatively. The code pauses at .await points, saves its local variables, and yields control. When it resumes, it picks up exactly where it left off.
The compiler handles all this bookkeeping. You write linear code; the compiler generates the state machine. This is why async Rust has zero overhead compared to manual callback-based code, but with much better readability.
Polling and the Waker
Polling alone isn't enough. If the executor polls a future and gets Pending, it needs to know when to poll again. It can't just spin-loop, checking every microsecond. That would burn CPU and kill performance.
This is where the Waker comes in. The Context passed to poll contains a Waker. The Waker is a handle to the executor's scheduling mechanism. When a future returns Pending, it can clone the Waker and register it with the external resource.
For example, a network future registers the Waker with the operating system's I/O completion port or epoll. When data arrives, the OS notifies the executor. The executor calls wake on the Waker. The Waker puts the future back on the run queue. The executor polls the future again.
You never touch Waker directly in normal async code. The .await syntax handles the registration automatically. The compiler inserts code to clone the waker, register it, and check the result.
This mechanism ensures that futures only run when they have work to do. The executor can manage thousands of futures on a few threads, switching between them only when I/O events occur. This is the secret to high-concurrency async systems.
Trust the waker. Your job is to yield at .await points. The executor handles the rest.
Realistic example: Fetching data
Real async code usually involves an executor like Tokio. The executor provides the runtime, the thread pool, and the I/O driver. It also provides the #[tokio::main] attribute, which sets up the runtime and allows main to be async.
use std::time::Duration;
/// Simulates fetching data with a delay.
/// In production, this would use reqwest or similar.
async fn fetch_user(id: u32) -> Result<String, Box<dyn std::error::Error>> {
// tokio::time::sleep yields to the executor.
// It returns Pending until the duration elapses.
tokio::time::sleep(Duration::from_millis(100)).await;
Ok(format!("User {}", id))
}
/// Entry point for the async application.
/// The attribute macro sets up the Tokio runtime.
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// This creates a future. The runtime polls it immediately.
let user = fetch_user(1).await?;
println!("Got: {}", user);
Ok(())
}
The #[tokio::main] attribute does three things. It creates a Runtime. It calls block_on to run the async main function. It handles the Result propagation. Without the attribute, you'd have to write this manually:
fn main() -> Result<(), Box<dyn std::error::Error>> {
let runtime = tokio::runtime::Runtime::new()?;
runtime.block_on(async {
let user = fetch_user(1).await?;
println!("Got: {}", user);
Ok(())
})
}
block_on runs the future to completion on the current thread. It's the bridge between synchronous and asynchronous code. You use it at the boundaries of your program, like in main or in tests.
Convention aside: cargo fmt formats async code consistently. Don't argue about indentation inside async { }. The formatter decides. Argue about logic, not style.
Pitfalls: Blocking, Send, and the main trap
Async Rust has specific traps that trip up developers coming from other languages.
Blocking the executor. If you call std::thread::sleep inside an async function, you block the entire thread. The executor can't run other futures on that thread while it's sleeping. If you're using a multi-threaded runtime, you might only block one worker thread, but you still waste a thread slot. If you're using a current-thread runtime, you freeze the whole app.
Always use async sleep functions like tokio::time::sleep. Never use std::thread::sleep in async code. The compiler won't stop you. You have to be disciplined.
Send and thread safety. Futures are not Send by default if they capture non-Send data. If you try to spawn a task that holds an Rc, the compiler rejects you with E0277 (trait bound not satisfied). Rc uses reference counting that isn't thread-safe. You need Arc for shared ownership across tasks.
use std::rc::Rc;
async fn bad_task() {
let data = Rc::new("shared");
// This compiles fine.
let _ = data.clone();
}
// This fails to compile.
// error[E0277]: `Rc<&str>` cannot be sent between threads safely
// tokio::spawn(bad_task());
Use Arc when you need to share data across tasks. Use Rc only when you're sure the future stays on one thread. The error message is usually clear about which type isn't Send.
The main trap. You can't write async fn main without an attribute macro. The standard main function must be synchronous. If you try to make main async, you get E0752.
// error[E0752]: `main` function is not allowed to be `async`
async fn main() {
println!("Hello");
}
Use #[tokio::main] or #[async_std::main] to fix this. Or wrap the async code in block_on. The entry point must be sync. The async world starts inside.
Dropping futures. If you create a future and don't .await it or spawn it, it gets dropped. The work is lost. This is silent. The compiler doesn't warn you. You just get no result.
async fn do_work() {
println!("Working");
}
fn main() {
let _future = do_work();
// Program exits. "Working" is never printed.
}
If you intentionally drop a future, use let _ = do_work(); to signal that you considered the value and chose to discard it. This helps readers understand your intent.
Async code is a scheduling strategy, not a magic wand. Yield or die.
Decision matrix
Use async fn when you're defining a function that performs I/O or waits for an event and should yield control while waiting. Use #[tokio::main] when you need to run async code from a synchronous entry point like main. Use .await when you have a Future and you want to pause the current task until that future completes. Use tokio::spawn when you want to run a future concurrently on the executor's thread pool. Use std::future::ready when you need to return a Future that is already completed, often for mocking or trait implementations. Use block_on when you are writing a synchronous library function that needs to call an async helper, but only if you are sure no other executor is running.
Reach for Arc when sharing data across tasks; Rc will break Send bounds and kill your concurrency. Reach for tokio::time::sleep when delaying; std::thread::sleep will freeze the executor. Reach for ? in async functions to propagate errors; the syntax works exactly like sync code.