Skip to main content
Rust advanced Lesson 21 of 30

Async/Await in Rust

Learn async/await, the Future trait, Tokio runtime, async streams, and the select! macro.

The Basics: async and await

Async programming lets a single thread handle thousands of concurrent operations by suspending tasks that are waiting for I/O instead of blocking the thread. In Rust, async fn marks a function as asynchronous — calling it does not run the body immediately, it returns a Future. The body only runs when something drives that future to completion, typically a runtime like Tokio. This model gives you concurrency with near-zero overhead compared to spawning threads.

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

async fn fetch_data(id: u32) -> String {
    // .await suspends this task without blocking the thread
    // Other tasks can run while this sleep is in progress
    sleep(Duration::from_millis(100)).await;
    format!("data for id={}", id)
}

#[tokio::main]  // sets up the Tokio runtime and runs main as an async task
async fn main() {
    let result = fetch_data(42).await;
    println!("{}", result);
}

Add to Cargo.toml:

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

The Future Trait

Understanding Future demystifies what async/await actually generates. The compiler transforms an async fn body into a state machine that implements Future. Each .await point becomes a state — the state machine records where it was and yields control back to the runtime. The runtime polls the future repeatedly, advancing it state by state until it completes.

// Simplified definition from std::future
pub trait Future {
    type Output;
    // poll() is called by the runtime — Ready means done, Pending means "call me again later"
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

pub enum Poll<T> {
    Ready(T),    // computation is complete, here is the result
    Pending,     // not done yet, runtime will call poll() again when ready
}

You rarely implement Future manually — async fn generates the implementation for you. The Pin wrapper ensures the state machine’s internal self-references remain valid if the runtime moves it in memory.

Running Multiple Futures Concurrently

tokio::join! — Concurrent, Wait for All

Running futures sequentially with .await wastes time — each operation waits for the previous to finish. tokio::join! runs all the given futures concurrently within a single task, polling each one in turn. Total elapsed time is the duration of the longest operation, not the sum.

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

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

#[tokio::main]
async fn main() {
    // All three tasks run concurrently — total time ~300ms, not 600ms
    let (a, b, c) = tokio::join!(
        task("A", 300),
        task("B", 200),
        task("C", 100),
    );
    println!("{}", a);
    println!("{}", b);
    println!("{}", c);
}

tokio::spawn — Independent Tasks

tokio::spawn submits a future to the Tokio thread pool as an independent task. Unlike join!, which polls futures within the same task, spawn can run on a different thread. Use it when a task is long-running, CPU-intensive, or when you need to fire-and-forget without waiting for the result immediately.

use tokio::task;

#[tokio::main]
async fn main() {
    // Each spawn returns a JoinHandle — await it later to get the result
    let h1 = task::spawn(async {
        42
    });

    let h2 = task::spawn(async {
        "hello from task"
    });

    // join! on the handles waits for both independent tasks to complete
    let (r1, r2) = tokio::join!(h1, h2);
    println!("{}", r1.unwrap()); // 42
    println!("{}", r2.unwrap()); // hello from task
}

tokio::select! — First to Finish Wins

select! polls multiple futures simultaneously and proceeds with whichever completes first, cancelling the others. This is the building block for timeouts, cancellation, and event loops where you want to react to whichever signal arrives next.

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

#[tokio::main]
async fn main() {
    let mut interval = tokio::time::interval(Duration::from_millis(200));
    let deadline = sleep(Duration::from_millis(550));
    tokio::pin!(deadline);

    let mut ticks = 0;
    loop {
        tokio::select! {
            _ = interval.tick() => {
                ticks += 1;
                println!("tick {}", ticks);
            }
            // When the deadline future completes, this branch wins
            _ = &mut deadline => {
                println!("deadline reached after {} ticks", ticks);
                break;
            }
        }
    }
}

Error Handling in Async Code

Async functions work seamlessly with Result and the ? operator. The rules are the same as synchronous code — ? propagates errors up to the caller, and the return type must be Result. The only difference is that you .await async operations before applying ?.

use std::io;
use tokio::fs;

async fn read_config(path: &str) -> Result<String, io::Error> {
    // .await the async operation, then ? to propagate any error
    let content = fs::read_to_string(path).await?;
    Ok(content)
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    match read_config("config.toml").await {
        Ok(content) => println!("config: {} bytes", content.len()),
        Err(e) => eprintln!("failed to read config: {}", e),
    }
    Ok(())
}

HTTP Client with reqwest

[dependencies]
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.11", features = ["json"] }
serde = { version = "1", features = ["derive"] }

reqwest is an async HTTP client built on Tokio. Fetching multiple resources concurrently with join_all shows the real benefit of async — the requests all fly out simultaneously and results are collected when all have responded, far faster than fetching sequentially.

use serde::Deserialize;

#[derive(Deserialize, Debug)]
struct Post {
    id: u32,
    title: String,
    body: String,
}

async fn fetch_post(id: u32) -> Result<Post, reqwest::Error> {
    let url = format!("https://jsonplaceholder.typicode.com/posts/{}", id);
    reqwest::get(&url).await?.json::<Post>().await
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // All three requests are in-flight at the same time
    let futures = (1..=3).map(|id| fetch_post(id));
    let posts = futures::future::join_all(futures).await;

    for post in posts {
        match post {
            Ok(p) => println!("[{}] {}", p.id, p.title),
            Err(e) => eprintln!("error: {}", e),
        }
    }
    Ok(())
}

Async TCP Server

An async TCP server handles every client connection as an independent task. When a client connection is accepted, tokio::spawn hands it off to a new task so the accept loop can immediately go back to waiting for the next connection. This lets a single-threaded runtime handle thousands of simultaneous clients efficiently.

use tokio::net::{TcpListener, TcpStream};
use tokio::io::{AsyncReadExt, AsyncWriteExt};

async fn handle_client(mut stream: TcpStream) {
    let mut buf = vec![0u8; 1024];
    loop {
        let n = match stream.read(&mut buf).await {
            Ok(0) => return,          // client disconnected cleanly
            Ok(n) => n,
            Err(e) => { eprintln!("read error: {}", e); return; }
        };

        // Echo the data back to the client
        if let Err(e) = stream.write_all(&buf[..n]).await {
            eprintln!("write error: {}", e);
            return;
        }
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let listener = TcpListener::bind("127.0.0.1:8080").await?;
    println!("echo server listening on port 8080");

    loop {
        let (stream, addr) = listener.accept().await?;
        println!("new connection from {}", addr);
        // Each client runs in its own independent task
        tokio::spawn(handle_client(stream));
    }
}

Async Streams

Streams are the async equivalent of iterators — they produce values one at a time, and each next() call is an async operation that may suspend until the next value is ready. Use them for paginated API responses, database result sets, or any data source that trickles in over time.

[dependencies]
tokio-stream = "0.1"
use tokio_stream::{self as stream, StreamExt};

#[tokio::main]
async fn main() {
    let mut s = stream::iter(vec![1, 2, 3, 4, 5]);

    // while let drives the stream, awaiting each item
    while let Some(n) = s.next().await {
        println!("{}", n);
    }

    // Streams support familiar adapter-style operations
    let sum: i32 = stream::iter(1..=100)
        .filter(|&x| async move { x % 2 == 0 })
        .fold(0, |acc, x| async move { acc + x })
        .await;

    println!("sum of even 1..=100: {}", sum); // 2550
}

Timeouts

Every async operation that could stall needs a timeout in production code. tokio::time::timeout wraps any future and cancels it if it has not completed within the deadline. It returns Ok(result) if the future finished in time, or Err(Elapsed) if it timed out — no threads are blocked in either case.

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

async fn slow_operation() -> String {
    tokio::time::sleep(Duration::from_secs(5)).await;
    "done".to_string()
}

#[tokio::main]
async fn main() {
    // The future is cancelled cleanly if it does not complete within 500ms
    match timeout(Duration::from_millis(500), slow_operation()).await {
        Ok(result) => println!("got: {}", result),
        Err(_) => println!("operation timed out"),
    }
}

Key Concepts Summary

ConceptDescription
async fnReturns a Future; body is lazy — does not run until polled
.awaitSuspends current task until future resolves; thread is not blocked
tokio::mainStarts the Tokio runtime and runs the async main function
tokio::spawnSpawns an independent async task on the thread pool
tokio::join!Runs futures concurrently in the same task, waits for all
tokio::select!Runs futures concurrently, proceeds on the first to complete
tokio::time::timeoutCancels a future if it does not complete within a deadline
StreamAsync version of Iterator — values arrive over time
Pin<&mut Self>Prevents self-referential futures from being moved in memory

Frequently Asked Questions

Does Rust have a built-in async runtime?
No. Rust provides the async/await syntax and the Future trait in the standard library, but you need an external runtime like Tokio or async-std to actually drive futures to completion.
What is a Future in Rust?
A Future is a value representing a computation that may not have completed yet. Calling .await on a Future suspends the current task until the Future resolves, without blocking the underlying thread.
What is the difference between async fn and regular fn?
An async fn returns a Future. When called, it does not execute immediately — it returns a Future that must be .awaited or driven by a runtime.