4 min read
On this page

Async & Await

Async programming in Rust lets you write concurrent code that handles thousands of I/O-bound tasks without spawning thousands of threads. The async/.await syntax looks sequential, but under the hood a state machine multiplexes tasks onto a small thread pool. The result is high concurrency with low overhead — but the model has sharp edges that are worth understanding upfront.

async fn & .await

An async fn returns a Future instead of executing immediately. The future does nothing until polled — typically by an async runtime:

async fn fetch_data(url: &str) -> String {
    // Simulating an HTTP request
    println!("Fetching {}", url);
    tokio::time::sleep(std::time::Duration::from_millis(100)).await;
    format!("Data from {}", url)
}

#[tokio::main]
async fn main() {
    let result = fetch_data("https://api.example.com").await;
    println!("{}", result);
}
Fetching https://api.example.com
Data from https://api.example.com

.await suspends the current task and yields control to the runtime, which can poll other futures while waiting. This is cooperative multitasking — tasks must yield voluntarily.

Futures Are Lazy

This is the most important thing to internalize: calling an async function does not execute it.

async fn greet() {
    println!("Hello!");
}

#[tokio::main]
async fn main() {
    let future = greet(); // Nothing happens yet
    println!("Future created");
    future.await;          // NOW it executes
}
Future created
Hello!

If you forget .await, the code silently does nothing. The compiler warns about unused futures, but it is easy to miss.

The Difference Between Threads & Async

Threads give you preemptive parallelism. The OS scheduler switches between threads, and each thread has its own stack (typically 2-8 MB). Spawning 10,000 threads is expensive.

Async gives you cooperative concurrency. Tasks share a small thread pool, each task uses minimal memory (a few hundred bytes for the state machine), and the runtime switches between tasks at .await points. Spawning 10,000 async tasks is cheap.

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

#[tokio::main]
async fn main() {
    let mut handles = vec![];

    // Spawn 10,000 concurrent tasks — trivial with async
    for i in 0..10_000 {
        handles.push(tokio::spawn(async move {
            sleep(Duration::from_millis(100)).await;
            i
        }));
    }

    let mut sum = 0u64;
    for handle in handles {
        sum += handle.await.unwrap() as u64;
    }
    println!("Sum: {}", sum);
}
Sum: 49995000

When to use threads: CPU-bound work, blocking I/O, work that cannot be made async.

When to use async: Network I/O, file I/O (with async libraries), handling many concurrent connections, anything where tasks spend most of their time waiting.

You Need a Runtime

Rust's standard library provides the Future trait but no runtime. You must choose one:

  • tokio — the dominant async runtime. Full-featured: timers, I/O, channels, synchronization primitives. Used by most production Rust projects.
  • async-std — mirrors the standard library API with async equivalents. Smaller community but simpler learning curve.
  • smol — minimal runtime, good for understanding how runtimes work.
// With tokio
#[tokio::main]
async fn main() {
    println!("Running on tokio");
}

// Equivalent manual setup
fn main() {
    let rt = tokio::runtime::Runtime::new().unwrap();
    rt.block_on(async {
        println!("Running on tokio");
    });
}

The runtime provides the executor (polls futures), the reactor (watches for I/O events), and utilities like timers and channels. Without a runtime, futures have nothing to drive them.

Concurrent Execution

.await is sequential. To run futures concurrently, use join! or spawn:

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

async fn fetch_user() -> String {
    sleep(Duration::from_millis(200)).await;
    "Alice".into()
}

async fn fetch_orders() -> Vec<String> {
    sleep(Duration::from_millis(300)).await;
    vec!["order-1".into(), "order-2".into()]
}

#[tokio::main]
async fn main() {
    // Sequential: 200ms + 300ms = 500ms
    let user = fetch_user().await;
    let orders = fetch_orders().await;
    println!("{}: {:?}", user, orders);

    // Concurrent: max(200ms, 300ms) = 300ms
    let (user, orders) = tokio::join!(fetch_user(), fetch_orders());
    println!("{}: {:?}", user, orders);
}
Alice: ["order-1", "order-2"]
Alice: ["order-1", "order-2"]

tokio::join! runs both futures on the same task. tokio::spawn creates separate tasks that can run on different threads.

Pinning

Futures in Rust are state machines that may contain self-referential data. Pin ensures a future is not moved in memory after it has been polled, which would invalidate internal pointers:

use std::pin::Pin;
use std::future::Future;

fn make_future() -> Pin<Box<dyn Future<Output = String>>> {
    Box::pin(async {
        "hello from pinned future".to_string()
    })
}

#[tokio::main]
async fn main() {
    let result = make_future().await;
    println!("{}", result);
}
hello from pinned future

You encounter Pin most often when:

  • Storing futures in structs
  • Implementing Future manually
  • Using Box::pin() to put a future on the heap

In day-to-day async code, you rarely deal with Pin directly. The async/.await syntax handles it for you. When the compiler complains about pinning, Box::pin() is usually the answer.

Async Is Not Always Faster

Async has overhead: the state machine, the runtime, the cooperative scheduling. For CPU-bound work, async adds complexity with no benefit:

// BAD: CPU-bound work in async — blocks the runtime
async fn compute_hash(data: &[u8]) -> Vec<u8> {
    // This blocks the executor thread
    expensive_hash(data)
}

// GOOD: Offload CPU-bound work to a blocking thread
async fn compute_hash_proper(data: Vec<u8>) -> Vec<u8> {
    tokio::task::spawn_blocking(move || expensive_hash(&data))
        .await
        .unwrap()
}

Async shines when tasks spend most of their time waiting on I/O. If your tasks are compute-heavy, use threads or Rayon.

A Practical Example: Fetching Multiple URLs

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

async fn fetch(url: &str) -> Result<String, String> {
    // Simulate network latency
    sleep(Duration::from_millis(150)).await;

    if url.contains("error") {
        Err(format!("Failed to fetch {}", url))
    } else {
        Ok(format!("Response from {}", url))
    }
}

#[tokio::main]
async fn main() {
    let urls = vec![
        "https://api.example.com/users",
        "https://api.example.com/orders",
        "https://api.example.com/products",
    ];

    let mut handles = vec![];
    for url in urls {
        handles.push(tokio::spawn(async move {
            (url, fetch(url).await)
        }));
    }

    for handle in handles {
        let (url, result) = handle.await.unwrap();
        match result {
            Ok(body) => println!("{}: {}", url, body),
            Err(e) => eprintln!("{}: {}", url, e),
        }
    }
}
https://api.example.com/users: Response from https://api.example.com/users
https://api.example.com/orders: Response from https://api.example.com/orders
https://api.example.com/products: Response from https://api.example.com/products

Common Pitfalls

  • Forgetting .await — the most common async bug. The future is created but never executed. The compiler warns, but in complex code the warning can be missed.
  • Blocking the runtime — calling std::thread::sleep, synchronous file I/O, or CPU-heavy computation inside an async context starves other tasks. Use spawn_blocking for blocking work.
  • Async in traits — prior to Rust 1.75, async fn in traits required the async-trait crate, which boxes the future. As of Rust 1.75, native async trait methods work but with some limitations on dyn Trait.
  • Send bound issuestokio::spawn requires futures to be Send. Holding a non-Send type (like Rc or MutexGuard from std) across an .await point causes a compile error.
  • Colored function problem — async functions can only be called from async contexts. This "colors" your entire call stack. Design your libraries with this in mind.
  • Assuming async is faster — for compute-bound work, async adds overhead without benefit. Use threads or Rayon instead.

Key Takeaways

  • async/.await enables cooperative concurrency: many tasks share a small thread pool.
  • Futures are lazy — they do nothing until awaited or polled by a runtime.
  • You must choose a runtime (tokio is the standard). Rust's stdlib provides the trait, not the executor.
  • Use join! or spawn for concurrent execution. Sequential .await chains run one at a time.
  • Async is for I/O-bound work. For CPU-bound work, use threads or spawn_blocking.
  • Never block the async runtime with synchronous operations.