Skip to content
C Codeloom
Rust

Rust async/await and Futures Explained

How Rust's async/await desugars into a state machine, what a Future actually is, and the runtime model that makes it efficient on real workloads.

·4 min read · By Codeloom
Intermediate 10 min read

What you'll learn

  • What a Future is at the type level
  • How async/await compiles to a state machine
  • The poll model and why a runtime is required
  • How wakers and reactors interact
  • Common errors around lifetimes in async code

Prerequisites

  • Comfort with Rust ownership and traits

What and Why

Rust’s async system gives you cooperative concurrency without garbage collection or runtime-managed green threads. You write code that looks sequential; the compiler turns it into a state machine that suspends on I/O and resumes when work is ready. A runtime (commonly Tokio) drives those state machines on a small pool of OS threads.

The result is high throughput for I/O-heavy workloads with predictable memory usage and zero hidden allocations from the language itself.

Mental Model

A Future is a value that, when polled, either returns a result (Poll::Ready) or signals that it needs to wait (Poll::Pending). The runtime polls futures; futures register a Waker so the runtime knows when to poll them again.

runtime --poll--> Future
                  |
     Ready(T) <---+--- Pending (stores Waker)
                        ^
                        |
              I/O event arrives
              wake() called
                        |
runtime --poll-- Future (resumes state machine)
Future polling lifecycle

async fn foo() -> T is sugar for fn foo() -> impl Future<Output = T>. Inside, every .await becomes a suspension point in the generated state machine.

Hands-on Example

A small async function fetching two URLs concurrently with Tokio.

use tokio::time::{sleep, Duration};

async fn fetch(name: &str, ms: u64) -> String {
    sleep(Duration::from_millis(ms)).await;
    format!("{name} done after {ms}ms")
}

#[tokio::main]
async fn main() {
    // Sequential: ~150ms total
    let a = fetch("a", 100).await;
    let b = fetch("b", 50).await;
    println!("{a}\n{b}");

    // Concurrent: ~100ms total
    let (a, b) = tokio::join!(fetch("a", 100), fetch("b", 50));
    println!("{a}\n{b}");

    // Spawning lets futures run on background tasks
    let handle = tokio::spawn(fetch("c", 75));
    println!("{}", handle.await.unwrap());
}

tokio::join! polls multiple futures on the current task in parallel. tokio::spawn hands a future to the runtime as an independent task that can run on any worker thread.

Common Pitfalls

  • Blocking the runtime: calling std::thread::sleep or doing CPU-heavy work inside an async function blocks the worker thread and starves other tasks. Use tokio::time::sleep for waiting, and spawn_blocking for CPU-bound work.
  • Holding non-Send state across .await: if any value held across an await point is not Send, the future itself is not Send and cannot be spawned. The compiler error points to the offending value.
  • Borrowing across await: borrows must be valid across all suspension points. Refactor to clone, restructure scopes, or use owned types.
  • Forgetting to .await: a future on its own does nothing. Calling foo() without .await produces a warning and the work never starts.
  • Mixing runtimes: do not call Tokio APIs from inside an async-std runtime, or vice versa. Stick to one.

Practical Tips

For concurrency, choose between join! (run all to completion), select! (return when the first finishes), and FuturesUnordered (a stream of futures with dynamic membership). Each has a clear use case.

Cancellation in Rust is just dropping the future. Anything held by a future runs its destructor when the future is dropped, so RAII-style cleanup still works. Plan for this: do not hold a mutex guard across an await if cancellation could leave the lock in a surprising state.

Use #[tracing::instrument] from the tracing crate to follow tasks. Stack traces are less helpful in async code; structured logs and spans are the right tool.

When debugging “future is not Send” errors, check for Rc, RefCell, or raw pointers across an await. Replace with Arc, Mutex, or restructure the borrow.

For libraries, return impl Future<Output = T> rather than wrapping in Box<dyn Future> unless you really need dynamic dispatch. Static dispatch keeps zero-cost guarantees.

Wrap-up

Async Rust is a small language feature plus a big ecosystem. The feature is async/await, which compiles your code into a state machine of Future values. The ecosystem is the runtime that polls those futures and the I/O drivers that wake them when bytes arrive. Once you internalize the polling model, error messages stop being mysterious and patterns like join, select, and spawn_blocking fall into clear roles. The payoff is concurrency that scales to thousands of connections with predictable, allocation-free code.