Async Introduction
Async/Await and Futures
Section titled “Async/Await and Futures”Rust’s async/.await syntax lets you write non-blocking code that looks like ordinary sequential code. Under the hood, an async fn returns a Future — a value that represents a computation that may not have completed yet.
// @skip-verify crate: tokiouse tokio::time::{sleep, Duration};
async fn fetch_data(id: u32) -> String { sleep(Duration::from_millis(10)).await; format!("data for id={}", id)}
#[tokio::main]async fn main() { let result = fetch_data(1).await; println!("{}", result);}The .await keyword suspends the current async function until the Future it is applied to resolves. While suspended, the executor (tokio) can run other tasks.
What Is a Future?
Section titled “What Is a Future?”A Future<Output = T> is a trait that represents a value of type T that will be available at some point in the future. Calling an async fn does not execute the body immediately — it returns a Future. The body only runs when you .await it (or poll it via an executor).
// @skip-verify crate: tokio// This does nothing yet — just creates a Futurelet fut = fetch_data(42);
// This runs the body and blocks until completelet result = fut.await;This lazy evaluation is why Rust async is zero-cost: futures that are never awaited never run.
Proving It: A Tiny Executor With No Crates at All
Section titled “Proving It: A Tiny Executor With No Crates at All”tokio is an external crate, but the Future trait, Pin, and Waker are all in std — you can build the world’s simplest executor with nothing else, and watch laziness happen with your own println! calls instead of taking it on faith:
use std::future::Future;use std::pin::Pin;use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
// A waker that does nothing when woken. Good enough for a future that// finishes the first time it is polled — no real async I/O involved.fn noop_waker() -> Waker { fn no_op(_: *const ()) {} fn clone(_: *const ()) -> RawWaker { raw_waker() } fn raw_waker() -> RawWaker { static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, no_op, no_op, no_op); RawWaker::new(std::ptr::null(), &VTABLE) } unsafe { Waker::from_raw(raw_waker()) }}
// The smallest possible executor: poll the future in a loop until it is Ready.fn block_on<F: Future>(mut future: F) -> F::Output { let waker = noop_waker(); let mut cx = Context::from_waker(&waker); // SAFETY: future is a local variable that is never moved again after // this point, so pinning it on the stack is sound. let mut future = unsafe { Pin::new_unchecked(&mut future) }; loop { match future.as_mut().poll(&mut cx) { Poll::Ready(value) => return value, Poll::Pending => continue, // no real waker source, so just poll again } }}
async fn greet(name: &str) -> String { println!("greet body is running for {name}"); format!("Hello, {name}!")}
fn main() { println!("before calling greet()"); let fut = greet("Rust"); // nothing has run yet — this only builds a Future println!("after calling greet(), before polling"); let message = block_on(fut); // this is what actually runs the body println!("{message}");}Compiling…
before calling greet()after calling greet(), before pollinggreet body is running for RustHello, Rust!Notice the order: "greet body is running" prints after "before polling", not when greet("Rust") was called. Calling an async fn only builds a value describing the work — the body does not run a single line until something polls it, which is exactly what block_on (and tokio, using a real, efficient waker instead of this no-op one) does for you.
flowchart TD
A["async fn call() returns a Future — nothing has run yet"] --> B["executor calls poll()"]
B --> C{"body reaches an .await on an unfinished future?"}
C -->|"no — runs to completion"| D["Poll::Ready(value)"]
C -->|"yes"| E["Poll::Pending — state machine suspends here"]
E --> F["waker fires when the awaited future can progress"]
F --> B Each .await point is a place the compiler can suspend the generated state machine and return Poll::Pending; the executor’s job (tokio’s real one, or this lesson’s toy block_on) is to keep calling poll again whenever there is a chance the future could make progress.
Running Tasks Concurrently with tokio::join!
Section titled “Running Tasks Concurrently with tokio::join!”To run multiple async tasks at the same time, use tokio::join!. It drives all futures concurrently on the same thread (or across worker threads, depending on the runtime configuration).
// @skip-verify crate: tokiouse tokio::time::{sleep, Duration};
async fn task(name: &str, ms: u64) -> String { sleep(Duration::from_millis(ms)).await; format!("{} done", name)}
#[tokio::main]async fn main() { let (a, b, c) = tokio::join!( task("alpha", 30), task("beta", 20), task("gamma", 10), ); println!("{}", a); println!("{}", b); println!("{}", c);}All three tasks start immediately. tokio::join! waits until all three complete and returns their results in order.
Spawning Async Tasks
Section titled “Spawning Async Tasks”tokio::spawn launches a task onto the tokio thread pool. It returns a JoinHandle similar to std::thread::spawn.
// @skip-verify crate: tokiouse tokio::task;
#[tokio::main]async fn main() { let handle = task::spawn(async { // runs concurrently on the tokio thread pool 42_u32 });
let result = handle.await.unwrap(); println!("Spawned task returned: {}", result);}What the Compiler Says: A Spawned Future Must Be Send
Section titled “What the Compiler Says: A Spawned Future Must Be Send”tokio::spawn (like std::thread::spawn) may run its future on a different worker thread than the one that created it, so the future — and everything it holds across an .await point — must be Send. Holding a Rc<T> across an .await breaks that:
// @skip-verify crate: tokiouse std::rc::Rc;
async fn uses_rc() { let shared = Rc::new(42); tokio::time::sleep(std::time::Duration::from_millis(1)).await; println!("{shared}");}
#[tokio::main]async fn main() { tokio::spawn(uses_rc()).await.unwrap();}error: future cannot be sent between threads safely --> src/main.rs:11:18 | 11 | tokio::spawn(uses_rc()).await.unwrap(); | ^^^^^^^^^ future returned by `uses_rc` is not `Send` | = help: within `impl Future<Output = ()>`, the trait `Send` is not implemented for `Rc<i32>`note: future is not `Send` as this value is used across an await --> src/main.rs:5:61 | 4 | let shared = Rc::new(42); | ------ has type `Rc<i32>` which is not `Send` 5 | tokio::time::sleep(std::time::Duration::from_millis(1)).await; | ^^^^^ await occurs here, with `shared` maybe used laternote: required by a bound in `tokio::spawn` --> tokio-1.53.1/src/task/spawn.rs:176:21 |174 | pub fn spawn<F>(future: F) -> JoinHandle<F::Output> | ----- required by a bound in this function175 | where176 | F: Future + Send + 'static, | ^^^^ required by this bound in `spawn`Note the diagnostic’s own phrasing — Rc<i32> is “used across an await”, meaning it is still alive on both sides of a suspend point. Swap Rc::new for Arc::new and the future becomes Send again, exactly as in the threads lesson. This is the same Send bound from std::thread::spawn, just enforced against the state a suspended async fn’s generated struct has to carry between polls instead of a closure’s captures.
Two Async-Specific Smells
Section titled “Two Async-Specific Smells”A synchronous Mutex held across .await looks fine to the borrow checker but is a real liveness hazard: the executor can suspend your task mid-.await while still holding the guard, and if that executor happens to run another task on the same thread that also wants the lock, you get a deadlock the type system cannot see (the same class of bug as the two-lock deadlock in the threads lesson, but now with only one lock).
// @skip-verify crate: tokiouse std::sync::Mutex;
async fn bad_pattern(shared: &Mutex<i32>) { let guard = shared.lock().unwrap(); tokio::time::sleep(std::time::Duration::from_millis(1)).await; // guard is still held here println!("{}", *guard);}warning: this `MutexGuard` is held across an await point --> src/main.rs:4:9 |4 | let guard = shared.lock().unwrap(); | ^^^^^ | = help: consider using an async-aware `Mutex` type or ensuring the `MutexGuard` is dropped before calling `await` = note: `#[warn(clippy::await_holding_lock)]` on by defaultThe fix: drop the guard before the .await (compute what you need, then release the lock), or use an async-aware lock like tokio::sync::Mutex if you genuinely need to hold it across a suspend point.
std::thread::sleep inside an async fn blocks the entire OS thread the executor is using — including every other task scheduled on that thread — because it is a synchronous call with no .await, so the executor gets no chance to run anything else while it waits:
// @skip-verify crate: tokioasync fn bad_delay() { std::thread::sleep(std::time::Duration::from_millis(100)); // blocks the whole executor thread println!("done");}
async fn good_delay() { tokio::time::sleep(std::time::Duration::from_millis(100)).await; // yields control back to the executor println!("done");}tokio::time::sleep(...).await yields control back to the executor for the duration, letting other tasks run on that thread in the meantime. If the delay is actually unavoidable CPU-bound work rather than a sleep (e.g., a heavy computation), the right tool is tokio::task::spawn_blocking, which moves the blocking work onto a dedicated thread pool reserved for exactly this purpose, keeping the async executor’s threads free.
async fn in Traits
Section titled “async fn in Traits”Since Rust 1.75, you can write async fn directly inside a trait definition — no async-trait crate needed for the common case:
trait Greeter { async fn greet(&self, name: &str) -> String;}
struct English;
impl Greeter for English { async fn greet(&self, name: &str) -> String { format!("Hello, {name}!") }}This compiles and runs on plain std — it does not even need tokio for a call site that just .awaits it directly. The one real limitation: you cannot make a trait with async fn into a dyn Trait object today (each implementation’s future is a distinct, differently-sized anonymous type, and dyn needs one fixed size) — for a trait object you still need the async-trait crate’s Box<dyn Future<...>>-based desugaring, or to write the -> impl Future<Output = ...> signature by hand.
async vs Threads: When to Use Which
Section titled “async vs Threads: When to Use Which”| Criterion | OS Threads (std::thread) | Async (tokio) |
|---|---|---|
| CPU-bound work | Preferred | Use spawn_blocking |
| I/O-bound work | Wasteful (blocked thread) | Preferred |
| Number of concurrent tasks | Hundreds (OS limit) | Millions (lightweight) |
| Stack size | ~8 MB per thread | ~few KB per task |
| External crate needed | No | Yes (tokio) |
Adding tokio to Your Project
Section titled “Adding tokio to Your Project”cargo add tokio --features fullOr in Cargo.toml:
[dependencies]tokio = { version = "1", features = ["full"] }Then annotate your entry point:
// @skip-verify crate: tokio#[tokio::main]async fn main() { // your async code here}#[tokio::main] is a procedural macro that wraps your async fn main in a call to tokio::runtime::Runtime::block_on, starting the tokio event loop.
Mental Model
Section titled “Mental Model”An async fn compiles to an anonymous struct implementing Future — one field per value that needs to survive across an .await point, and a poll method that is a state machine jumping to wherever it last returned Pending. .await is not a keyword that “waits” in the thread-blocking sense; it is closer to return Poll::Pending with a bookmark, handed back up to whatever called poll. Nothing runs “in the background” on its own — an executor (tokio, or this lesson’s toy block_on) must keep calling poll for any progress to happen at all.
Trade-offs
Section titled “Trade-offs”Futures cost effectively nothing until polled — you can build and store thousands without any thread or memory overhead beyond the state machine’s own fields. But that laziness means correctness now depends on something actually polling every future you care about; an async fn call whose result you never .await and never pass to tokio::spawn simply never runs, silently, with no error. Threads guarantee progress the moment you spawn them, at real OS cost; futures guarantee nothing until an executor drives them, at near-zero cost.
Common Misconceptions
Section titled “Common Misconceptions”- “
.awaitblocks the current thread while it waits.” It suspends the current task, not the thread — the executor is free to run other tasks on that same OS thread while this one is pending.std::thread::sleepgenuinely blocks the thread;tokio::time::sleep(...).awaitdoes not. - “Calling an async fn starts its work immediately, like a regular function.” Calling
greet("Rust")only constructs aFuturevalue — as theblock_ondemo above shows directly, the body’sprintln!does not run until something polls it. - “tokio and async/await are the same thing.”
async/.awaitand theFuturetrait are part of the language andstd.tokiois one (widely used) executor among several (async-std,smol) that knows how to actually run those futures.
Reading AI-generated Code
Section titled “Reading AI-generated Code”AI-generated async Rust frequently reaches for std::sync::Mutex (the synchronous one, from the threads lesson) inside async fn bodies and holds the guard across an .await, because the code “looks the same” as the thread-based examples it was trained on — without noticing the executor-level liveness hazard that introduces.
🐛 Spot the bug
use std::sync::Mutex;use std::time::Duration;
async fn record_event(log: &Mutex<Vec<String>>, event: String) { let mut guard = log.lock().unwrap(); guard.push(event); tokio::time::sleep(Duration::from_millis(10)).await; // simulate flushing to disk println!("logged, {} entries so far", guard.len());}Show the answer
The MutexGuard from log.lock() is still alive across the tokio::time::sleep(...).await — the executor can suspend this task right there and run a different task on the same thread that also wants log’s lock, and now that task is stuck waiting for a guard that will not be released until this task is polled again. The fix: do the push and read guard.len() before the .await, dropping the guard first — or switch to an async-aware lock (tokio::sync::Mutex) if the lock genuinely must be held across the suspend point.