RustAsync / Await

Async / Await in Rust

Modern applications must handle thousands of concurrent operations — network connections, file reads, database queries — without creating an OS thread for each one. OS threads are powerful but heavy: each one costs around 2–8 MB of stack space, and context-switching between thousands of them is expensive.

Rust's async / await system solves this by letting a small pool of OS threads drive an enormous number of concurrent tasks. When a task is waiting for I/O, it is suspended and the thread moves on to another task. No thread sits idle.

Model

Concurrency unit

Overhead

Best for

OS thread

Thread (1:1 with kernel)

MB of stack, kernel scheduling

CPU-bound parallelism

Async task

Future (N:M onto thread pool)

Kilobytes, user-space scheduling

I/O-bound concurrency

async fn and Futures

Marking a function with async transforms it into a function that returns a Future. A Future is a value representing a computation that may not have completed yet. The trait looks like this:

RUST
// Simplified — the real trait uses Pin, but the concept is the same
pub trait Future {
    type Output;

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

pub enum Poll<T> {
    Ready(T),   // computation complete, here is the result
    Pending,    // not done yet — wake me when there is progress
}

When you write an async function, the compiler rewrites it into a state machine that implements Future. Each .await point becomes a state boundary where execution can suspend and resume.

RUST
// What you write:
async fn greet(name: &str) -> String {
    format!("Hello, {}!", name)
}

// What the compiler produces (conceptually):
fn greet(name: &str) -> impl Future<Output = String> {
    async move { format!("Hello, {}!", name) }
}
Futures Are Lazy

This is the single most important thing to understand about async Rust: a Future does nothing until it is driven. Calling an async fn merely constructs the future — no code inside it runs yet.

RUST
async fn expensive_work() -> u32 {
    println!("doing work...");
    42
}

fn main() {
    let _future = expensive_work(); // nothing printed — future is just a value
    // _future is dropped here — the work NEVER ran
}
Note
The compiler will warn you about unused futures. If you create an async value and never .await it or drive it with a runtime, the computation never runs. This is a common source of bugs for async beginners.
.await: Suspending Until Done

The .await operator does two things:

  1. Polls the future to check if it is ready.
  2. If it is not ready (Poll::Pending), suspends the current task and yields control back to the runtime so another task can run.
  3. When the future becomes ready, the runtime wakes this task and it resumes from exactly where it paused.

.await can only appear inside an async function or block.

RUST
async fn fetch_data() -> String {
    // In a real program this would hit a network endpoint
    String::from("data from server")
}

async fn process() {
    let data = fetch_data().await; // suspends here if not ready
    println!("Got: {}", data);
}
Async Runtimes

Rust's standard library defines the Future trait but ships no runtime. A runtime is responsible for:

  • Maintaining a pool of OS threads.
  • Scheduling which task runs next.
  • Waking tasks when their I/O is ready (via epoll, kqueue, IOCP, etc.).

You must choose a runtime and add it as a dependency. The most widely used options are:

Runtime

crates.io name

Notes

Tokio

tokio

Most popular, full-featured, multi-threaded by default

async-std

async-std

std-mirroring API, simpler mental model

smol

smol

Tiny, composable, good for embedded or learning

Tokio is the dominant choice in the ecosystem. Most async libraries — reqwest (HTTP), sqlx (database), axum (web framework) — target Tokio and compose with it naturally.

Getting Started with Tokio

Add Tokio to Cargo.toml:

TOML
[dependencies]
tokio = { version = "1", features = ["full"] }

The #[tokio::main] attribute macro transforms your main function into one that starts the Tokio runtime and runs your async code on it:

RUST
#[tokio::main]
async fn main() {
    println!("Running inside the Tokio runtime");
    say_hello().await;
}

async fn say_hello() {
    println!("Hello from async Rust!");
}
Running inside the Tokio runtime
Hello from async Rust!

Under the hood, #[tokio::main] expands to roughly:

RUST
fn main() {
    tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
        .unwrap()
        .block_on(async {
            // your async main body runs here
        });
}
Reading a File Asynchronously

RUST
use tokio::fs;

#[tokio::main]
async fn main() {
    // tokio::fs::read_to_string suspends the task while the OS reads the file.
    // The thread is free to run other tasks in the meantime.
    let contents = fs::read_to_string("hello.txt").await.unwrap();
    println!("File contents: {}", contents);
}
Tip
Always use tokio::fs (and other Tokio I/O types) inside async code — not std::fs. The std versions block the OS thread; the Tokio versions suspend the task and allow other work to proceed.
Making HTTP Requests with reqwest

TOML
[dependencies]
tokio   = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json"] }

RUST
#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
    let url = "https://httpbin.org/get";
    let response = reqwest::get(url).await?;  // suspends here
    let body     = response.text().await?;    // suspends here too
    println!("Response length: {} bytes", body.len());
    Ok(())
}
Response length: 307 bytes
tokio::spawn: Concurrent Tasks

.await runs one future at a time in sequence within a task. To run multiple async operations concurrently — without waiting for each to finish before starting the next — use tokio::spawn. Each spawned task is a lightweight unit of work scheduled independently by the runtime.

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

#[tokio::main]
async fn main() {
    let handle_a = tokio::spawn(async {
        sleep(Duration::from_millis(200)).await;
        println!("task A done");
        "result A"
    });

    let handle_b = tokio::spawn(async {
        sleep(Duration::from_millis(100)).await;
        println!("task B done");
        "result B"
    });

    // Both tasks run concurrently. Await the handles to get results.
    let a = handle_a.await.unwrap();
    let b = handle_b.await.unwrap();
    println!("a={}, b={}", a, b);
}
task B done
task A done
a=result A, b=result B
Note
tokio::spawn returns a JoinHandle. You must.await the handle to get the result. If you drop the handle without awaiting it, the task is detached and continues running in the background.
tokio::join!: Wait for All

tokio::join!(f1, f2, f3) runs all futures concurrently on the current task and waits until every one completes. It is more efficient than awaiting them in sequence because all futures make progress simultaneously. Total elapsed time equals the slowest single future, not the sum.

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

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

#[tokio::main]
async fn main() {
    // Sequential would take 150+100+120 = 370 ms.
    // join! takes ~150 ms — the longest single step.
    let (a, b, c) = tokio::join!(
        step("alpha", 150),
        step("beta",  100),
        step("gamma", 120),
    );

    println!("{}", a);
    println!("{}", b);
    println!("{}", c);
}
alpha finished
beta finished
gamma finished
tokio::select!: Take the First

tokio::select! races multiple futures and returns as soon as the first one completes, cancelling the rest. This is useful for timeouts, fallbacks, and choosing between competing sources of data.

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

#[tokio::main]
async fn main() {
    let slow = sleep(Duration::from_millis(500));
    let fast = sleep(Duration::from_millis(100));

    tokio::select! {
        _ = slow => println!("slow branch fired"),
        _ = fast => println!("fast branch fired — slow was cancelled"),
    }
}
fast branch fired — slow was cancelled

RUST
// Timeout pattern using tokio::time::timeout
use tokio::time::{timeout, Duration};

async fn might_be_slow() -> &'static str {
    tokio::time::sleep(Duration::from_secs(10)).await;
    "done"
}

#[tokio::main]
async fn main() {
    match timeout(Duration::from_millis(200), might_be_slow()).await {
        Ok(result) => println!("got: {}", result),
        Err(_)     => println!("timed out!"),
    }
}
timed out!
Never Block the Async Thread

The golden rule of async Rust: never call a blocking function inside an async context. If you call std::thread::sleep or a blocking file read, you block the entire OS thread — no other tasks on that thread can make progress until it returns.

Blocking (wrong in async)

Async equivalent

std::thread::sleep(dur)

tokio::time::sleep(dur).await

std::fs::read_to_string(path)

tokio::fs::read_to_string(path).await

std::net::TcpListener::accept()

tokio::net::TcpListener::accept().await

blocking database driver

sqlx (async), or tokio::task::spawn_blocking

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

// WRONG — blocks the entire OS thread for 1 second
async fn wrong_sleep() {
    std::thread::sleep(Duration::from_secs(1)); // all tasks on this thread stall!
}

// RIGHT — suspends only this task; thread serves other tasks
async fn correct_sleep() {
    sleep(Duration::from_secs(1)).await;
}
Warning
If you must call a truly blocking operation — a C library call, CPU-intensive work, or a synchronous driver — usetokio::task::spawn_blocking(|| blocking_fn()). This moves the blocking call to a dedicated thread pool so the async threads stay free.
Async Channels with tokio::sync::mpsc

The standard library's std::sync::mpsc channels are blocking. Inside async code, use tokio::sync::mpsc instead — its send and recv methods are async and do not block the thread while waiting.

RUST
use tokio::sync::mpsc;

#[tokio::main]
async fn main() {
    let (tx, mut rx) = mpsc::channel::<String>(32); // buffer of 32 messages

    // Producer task
    let producer = tokio::spawn(async move {
        for i in 0..5 {
            tx.send(format!("message {}", i)).await.unwrap();
        }
        // tx dropped here — the channel closes, rx.recv() will return None
    });

    // Consumer on the main task
    while let Some(msg) = rx.recv().await {
        println!("received: {}", msg);
    }

    producer.await.unwrap();
}
received: message 0
received: message 1
received: message 2
received: message 3
received: message 4
async in Traits

Until Rust 1.75, async fn in traits was not directly supported in stable Rust. The async-trait crate provides a procedural macro workaround. Rust 1.75+ added native support through return-position impl Trait in traits (RPITIT).

RUST
// Using the async-trait crate — works on all stable Rust versions
// Cargo.toml: async-trait = "0.1"
use async_trait::async_trait;

#[async_trait]
trait Fetcher {
    async fn fetch(&self, url: &str) -> String;
}

struct HttpFetcher;

#[async_trait]
impl Fetcher for HttpFetcher {
    async fn fetch(&self, url: &str) -> String {
        format!("fetched from {}", url)
    }
}

#[tokio::main]
async fn main() {
    let f = HttpFetcher;
    println!("{}", f.fetch("https://example.com").await);
}

RUST
// Rust 1.75+ — async fn in traits natively (no crate needed)
trait Fetcher {
    async fn fetch(&self, url: &str) -> String;
}
Note
Native async-in-traits (Rust 1.75+) has a limitation: the returned future is not object-safe by default, so dyn Fetcher requires extra work. For dynamic dispatch, the async-trait crate boxes the future and remains the practical choice when trait objects are needed.
Async vs Threads: When to Use Each

Scenario

Recommended

Web server handling thousands of connections

Async (Tokio + Axum)

HTTP client making many requests

Async (Tokio + reqwest)

Database queries

Async (sqlx)

Image processing, video encoding, heavy math

OS threads or rayon

CPU-bound work inside an async app

tokio::task::spawn_blocking

Simple script with one network call

Either — threads may be simpler

A useful mental model: async for waiting, threads for working. If your task spends most of its time waiting for external systems (network, disk, database), async is the right tool. If it spends most of its time computing, OS threads — potentially with the rayon crate for easy data parallelism — are a better fit.

The Async Ecosystem
  • tokio — the runtime: async threads, I/O, timers, sync primitives (mpsc, Mutex, RwLock).

  • reqwest — ergonomic HTTP client built on Tokio.

  • sqlx — async SQL driver for PostgreSQL, MySQL, SQLite with compile-time query checking.

  • axum — web framework from the Tokio team; routes, extractors, middleware.

  • tower — middleware and service abstractions shared across the ecosystem.

  • tracing — async-aware structured logging and spans.

These crates compose naturally. An Axum web server uses reqwest to call external APIs, sqlx to query a database, and tracing to log every request — all async, all on the same Tokio runtime, all without blocking a thread.

Complete Example: Concurrent HTTP Requests

TOML
[dependencies]
tokio   = { version = "1", features = ["full"] }
reqwest = "0.12"

RUST
use reqwest;

async fn fetch(url: &str) -> Result<usize, reqwest::Error> {
    let body = reqwest::get(url).await?.text().await?;
    Ok(body.len())
}

#[tokio::main]
async fn main() {
    let urls = [
        "https://httpbin.org/get",
        "https://httpbin.org/ip",
        "https://httpbin.org/user-agent",
    ];

    // Spawn all requests concurrently
    let handles: Vec<_> = urls.iter()
        .map(|url| {
            let url = url.to_string();
            tokio::spawn(async move {
                match fetch(&url).await {
                    Ok(len) => println!("{} => {} bytes", url, len),
                    Err(e)  => println!("{} => error: {}", url, e),
                }
            })
        })
        .collect();

    // Wait for all of them
    for h in handles {
        h.await.unwrap();
    }
}
https://httpbin.org/ip => 28 bytes
https://httpbin.org/user-agent => 45 bytes
https://httpbin.org/get => 307 bytes

All three requests are in flight simultaneously. The total time is roughly equal to the slowest single request, not the sum of all three.

Key Points to Remember
  1. async fn returns a Future — no code inside it runs until the future is polled.

  2. .await suspends the current task (not the thread) until the future is ready.

  3. You need a runtime (Tokio, async-std, or smol) — Rust's std provides none.

  4. tokio::spawn creates a concurrent task; tokio::join! waits for all; tokio::select! takes the first.

  5. Never call blocking APIs inside async code — use their async equivalents or spawn_blocking.

  6. Use async for I/O-bound work; use OS threads or rayon for CPU-bound work.

Success
Async Rust is powerful and efficient, but it has a learning curve. Start with#[tokio::main], learn .await andtokio::join!, then reach for spawn andselect! as your needs grow. The type system ensures even complex async code compiles without data races, and the zero-cost abstraction model means you pay only for the concurrency you actually use.