The future that never runs
You write an async function to fetch a URL. You call it from main. You run the program. The terminal blinks, and the process exits instantly. No error. No output. Just silence.
You check the network. Nothing happened. You check the logs. Empty.
This is the first rite of passage in Rust async. The compiler didn't stop you because, technically, you didn't do anything wrong. You just created a value and dropped it. The future never ran.
The compiler trusts you to check the ticket. If you don't, the work never happens.
Futures are lazy values
In Rust, async fn doesn't run code. It builds a machine.
Think of a future like a ticket at a busy deli. When you order, the clerk doesn't make your sandwich immediately. They hand you a ticket and go back to the line. The ticket represents the work. The sandwich only appears when someone looks at the ticket and sees it's ready.
In Rust, the "ticket" is the Future. The "someone" is the executor, like Tokio. If you hold the ticket but never check it, the sandwich never gets made. If you throw the ticket away, the kitchen forgets you ordered.
Calling an async fn gives you the ticket. Adding .await is checking the ticket and waiting for the sandwich.
async fn fetch_status() -> &'static str {
"ok"
}
fn main() {
// This creates a Future but never runs it.
// The program exits immediately.
fetch_status();
}
The function fetch_status returns a type that implements the Future trait. That type is just a struct holding the state of the computation. It does nothing until polled.
To run it, you need an executor. The standard way is to mark main as async and use a runtime attribute.
use tokio;
#[tokio::main]
async fn main() {
// .await polls the future until it completes.
// The executor drives the work.
let status = fetch_status().await;
println!("{}", status);
}
How the executor drives the work
When you write .await, the compiler transforms your function into a state machine. It breaks the function into chunks at every .await point. Each chunk runs until it hits I/O or a yield point, then it pauses and tells the executor "I'm waiting for this." The executor moves on to other tasks. When the I/O completes, the executor wakes up your state machine and resumes from where it left off.
This is why you can't just call async code from a regular function. Regular functions don't have a state machine. They run to completion. There's no place to pause and resume.
The executor maintains a list of futures. It loops through them, calling a poll method on each one. The future returns Poll::Ready(value) when it's done, or Poll::Pending when it needs to wait. When it returns Pending, it registers a "waker" so the executor knows which future to resume when the underlying event happens.
You rarely see poll or Waker in application code. The .await syntax hides the complexity. But understanding this loop explains why async Rust is fast. The executor multiplexes thousands of tasks onto a few threads. No task blocks a thread; it just yields control back to the loop.
Chaining and error handling
Real async code chains operations. You fetch data, parse it, transform it, and send it. Each step might fail. The ? operator works in async functions just like sync ones, propagating errors up the stack.
use reqwest;
/// Fetches the page title from a URL.
/// Returns an error if the request fails or parsing fails.
async fn get_page_title(url: &str) -> Result<String, reqwest::Error> {
// .await on the request. ? propagates errors.
let response = reqwest::get(url).await?;
// .await on reading the body. ? propagates errors.
let text = response.text().await?;
// Simple parsing logic for demonstration.
// In real code, use a proper HTML parser.
let title = text
.lines()
.find(|line| line.contains("<title>"))
.unwrap_or("Untitled");
Ok(title.to_string())
}
The ? operator requires the function to return a type that implements From for the error being propagated. In async functions, the return type is usually Result<T, E> or Option<T>. The ? works seamlessly across .await points.
Convention aside: when you call Rc::clone(&data) in async code, prefer the explicit form over data.clone(). The explicit form signals to readers that you are cloning the reference count, not the underlying data. This distinction matters more in async where data often lives on the heap and is shared across tasks.
Pitfall: Blocking the runtime
The biggest danger in async Rust is blocking the runtime. If a task blocks the thread, the executor can't run other tasks. The whole application stalls.
Blocking happens when you hold a lock across an .await point.
use tokio::sync::Mutex;
async fn bad_lock_usage(mutex: &Mutex<Vec<String>>) {
// Lock is acquired.
let mut guard = mutex.lock().await;
// BAD: We hold the lock while waiting for async work.
// Other tasks trying to lock this mutex will hang.
// If those tasks are needed to complete the async work,
// you have a deadlock.
let data = fetch_external_data().await;
guard.push(data);
}
The lock guard keeps the mutex locked until it goes out of scope. If you .await while holding the guard, you're holding the lock while the task is suspended. Other tasks blocked on the lock can't run. If the async work you're waiting for requires one of those blocked tasks, the system deadlocks.
Drop the lock before you wait. The runtime shares the thread; don't hog it.
use tokio::sync::Mutex;
async fn good_lock_usage(mutex: &Mutex<Vec<String>>) {
// Fetch data first. No lock held.
let data = fetch_external_data().await;
// Acquire lock only when modifying shared state.
// Guard drops immediately after the block.
{
let mut guard = mutex.lock().await;
guard.push(data);
}
}
Another common block is using std::thread::sleep inside async code. This puts the entire thread to sleep. The executor can't run other tasks on that thread. Use tokio::time::sleep instead, which yields control back to the executor.
Pitfall: Send bounds and threads
Tokio's multi-threaded runtime moves tasks between threads. For a future to move between threads, it must implement the Send trait. If your future captures a non-Send type, the compiler rejects it when you try to spawn the task.
use std::rc::Rc;
use tokio;
#[tokio::main]
async fn main() {
let data = Rc::new("hello");
// ERROR: Rc is not Send.
// The compiler rejects this with E0277.
tokio::spawn(async move {
println!("{}", data);
});
}
Rc uses reference counting that isn't thread-safe. It can't be sent to another thread. The compiler catches this with E0277 (trait bound not satisfied). The error message tells you that Rc cannot be sent between threads safely.
Use Arc instead of Rc when sharing data across tasks. Arc is atomic reference counting and implements Send.
use std::sync::Arc;
use tokio;
#[tokio::main]
async fn main() {
let data = Arc::new("hello");
// Clone the Arc to share ownership with the task.
let task_data = Arc::clone(&data);
// Arc is Send, so this compiles.
tokio::spawn(async move {
println!("{}", task_data);
});
}
Convention aside: keep unsafe blocks as small as possible. The community calls this the "minimum unsafe surface" rule. If you need to interact with a C library that requires raw pointers, wrap the unsafe logic in a tiny helper function. Document the invariants in a // SAFETY: comment. Treat the comment as a proof. If you can't write it, you don't have one.
Pitfall: Nested runtimes
You can't run a runtime inside a runtime. If you call tokio::runtime::Runtime::new().block_on(...) from inside an async function, you'll get a panic. The executor is already running. You can't start another one on the same thread.
Use tokio::task::block_on only when you absolutely must call async code from a synchronous context, like a plugin hook or a test helper. Never call it from within an async function.
If you need to run async code from a sync function, the sync function should be the entry point. Mark it with #[tokio::main] or create a runtime there.
Decision: Async patterns
Use #[tokio::main] when writing a standalone binary that needs an async runtime. It sets up the executor and runs your async main function.
Use tokio::spawn when you want to run a task concurrently and don't need the result immediately. The spawn returns a JoinHandle you can await later if you need the result.
Use .await when you need the result of a future before proceeding. Await chains the logic and pauses the current task until the future completes.
Use tokio::task::block_on when you must call async code from a synchronous context, like a callback in a sync library. Only use this at the boundary between sync and async code.
Use Arc instead of Rc when sharing data across tasks that might run on different threads. Arc is Send and Sync, making it safe for multi-threaded runtimes.
Use tokio::sync::Mutex instead of std::sync::Mutex when you need to hold a lock across an .await point. The tokio mutex is async-aware and won't block the runtime thread.
Counter-intuitive but true: the more you use unsafe, the harder the rest of your code becomes to reason about. Async Rust is already complex with lifetimes and state machines. Keep the unsafe surface tiny and isolated.