Code of the Day
AdvancedConcurrency

Async and await

Write non-blocking IO with async/await syntax, understand the Future trait conceptually, and run async tasks with the Tokio runtime.

RustAdvanced14 min read
Recommended first
By the end of this lesson you will be able to:
  • Explain what a Future is and why async/await exists
  • Write async functions and await their results
  • Spawn concurrent tasks with tokio::spawn
  • Know when to use async/await vs OS threads

OS threads are excellent for CPU-bound parallelism. But a web server that handles 10,000 concurrent connections can't spawn 10,000 threads — each thread uses 2–8 MB of stack space. solves this: it lets a small number of OS threads serve a huge number of concurrent IO-bound tasks, suspending each task while it waits for IO and resuming it when the data is ready.

Rust's async model is zero-cost: async functions compile to state machines with no heap allocation per suspension point — unlike Go goroutines, which have a small heap allocation per goroutine.

The Future trait

At the core of Rust's async model is the trait:

pub trait Future {
    type Output;
    fn poll(&mut self, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

poll() asks "are you done yet?" If the work is ready, it returns Poll::Ready(value). If not, it returns Poll::Pending and registers a waker that will notify the runtime when it should poll again.

You almost never implement Future directly. The async/await syntax compiles your async functions into state machines that implement Future automatically.

async functions and await

Mark a function async to make it return a Future:

async fn fetch_data() -> String {
    // In real code, this would be an IO operation
    String::from("some data")
}

Use .await to suspend the current task until a Future resolves:

async fn process() -> String {
    let data = fetch_data().await;
    format!("Processed: {}", data)
}

.await is only valid inside an async function or block. Calling an async fn does not run it immediately — it returns a Future. You must .await it (or pass it to a runtime) to make it run.

This is the key distinction from Python or JavaScript async: in Rust, calling fetch_data() does nothing — it returns an unstarted Future. Only .await drives it to completion. Forgetting .await is a common mistake; the compiler often warns about unused futures.

A runtime is required

The Rust standard library provides the Future trait and async/await syntax, but no built-in runtime. A runtime is a library that polls futures to completion, using an event loop and thread pool. The most widely-used runtime is :

# Cargo.toml
[dependencies]
tokio = { version = "1", features = ["full"] }

Mark main as async with #[tokio::main]:

#[tokio::main]
async fn main() {
    let result = process().await;
    println!("{}", result);
}

#[tokio::main] is a macro that creates a Tokio runtime and runs your async main inside it.

Running tasks concurrently with tokio::spawn

tokio::spawn starts an async task that runs concurrently with the current task — similar to thread::spawn but much lighter:

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

#[tokio::main]
async fn main() {
    let task1 = tokio::spawn(async {
        sleep(Duration::from_millis(100)).await;
        println!("task 1 done");
    });

    let task2 = tokio::spawn(async {
        sleep(Duration::from_millis(50)).await;
        println!("task 2 done");
    });

    task1.await.unwrap();
    task2.await.unwrap();
    println!("both done");
}

Both tasks run concurrently. task2 finishes first because it sleeps for less time. Total elapsed time is ~100ms, not 150ms — the sleeps overlap.

tokio::spawn requires the future to be Send (safe to move between threads), because the Tokio runtime may run tasks on any thread in its pool. If you need to spawn a future that captures non-Send data, use tokio::task::spawn_local with a LocalSet.

async blocks

You can use async { ... } as an expression to create a local future without defining a whole function:

let future = async {
    let a = fetch_data().await;
    let b = fetch_data().await;
    format!("{} + {}", a, b)
};

let result = future.await;

When to use async vs threads

SituationPrefer
CPU-intensive computationstd::thread
Many concurrent IO operations (HTTP, DB, files)async/await (Tokio)
Simple parallelism, small number of tasksstd::thread
Thousands of concurrent lightweight tasksasync/await
Interfacing with sync librariesstd::thread

The two models can coexist: tokio::task::spawn_blocking runs a blocking (sync) operation on a dedicated thread pool without blocking the async runtime.

Check your understanding

Knowledge check

  1. 1.
    What does calling an async function in Rust return immediately?
  2. 2.
    Which workload is best served by OS threads rather than async tasks?
  3. 3.
    Rust's standard library includes a built-in async runtime, similar to Go's goroutine scheduler.

Do it yourself

Add Tokio to a new project and use tokio::join! to run two async operations concurrently and wait for both:

[dependencies]
tokio = { version = "1", features = ["full"] }
#[tokio::main]
async fn main() {
    let (a, b) = tokio::join!(
        async { "result A" },
        async { "result B" }
    );
    println!("{} {}", a, b);
}

tokio::join! waits for all futures concurrently — unlike awaiting them sequentially, they overlap.

When the agent's away

# Add tokio to an existing project
cargo add tokio --features full

# Run async tests (tokio has its own test macro)
#[tokio::test]
async fn test_something() { ... }

cargo test

Where to go next

You've covered all four concurrency topics. Next module: ecosystem and tooling — deep-diving into Cargo workspaces, testing, and macros, the infrastructure that makes real Rust projects maintainable.

Finished reading? Mark it complete to track your progress.

On this page