Skip to main content
Rust · lesson 21 of 30
Rust advanced Lesson 21 of 30

Async/Await in Rust

Learn Rust async/await with Tokio: lazy futures, join!, spawn, select!, timeouts, streams, and an echo server, each with real cargo run output.

By SkillByExample · Updated

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 future = fetch_data(42); // nothing runs yet — this only builds the future
    println!("future created");
    let result = future.await;   // now the body runs
    println!("{}", result);
}

Add to Cargo.toml:

[dependencies]
tokio = { version = "1", features = ["full"] }
$ cargo run -q
future created
data for id=42

future created prints first even though fetch_data(42) was called on the line above it: calling an async fn only builds a state machine. The 100 ms sleep and the format! happen during .await.

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, Instant};

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() {
    let start = Instant::now();
    // 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);
    println!("total: {:.1}s", start.elapsed().as_secs_f64());
}
$ cargo run -q
A done after 300ms
B done after 200ms
C done after 100ms
total: 0.3s

C finished first, but join! returns the results in argument order. The total is the longest sleep, not the 0.6 s sum — awaiting the three calls one after another would take the full 0.6 s.

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());
    println!("{}", r2.unwrap());
}
$ cargo run -q
42
hello from task

Awaiting a JoinHandle gives a Result: Err if the task panicked or was cancelled — hence the unwrap()s.

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;
            }
        }
    }
}
$ cargo run -q
tick 1
tick 2
tick 3
deadline reached after 3 ticks

A Tokio interval fires its first tick immediately, then every 200 ms — at 0, 200 and 400 ms — so three ticks land before the 550 ms deadline. tokio::pin! is needed because select! polls &mut deadline on every loop iteration without consuming it.

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(())
}

Run in a directory without config.toml:

$ cargo run -q
failed to read config: No such file or directory (os error 2)

HTTP Client with reqwest

[dependencies]
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
futures = "0.3"

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!("[{}] {} ({} chars of body)", p.id, p.title, p.body.len()),
            Err(e) => eprintln!("error: {}", e),
        }
    }
    Ok(())
}

This makes real HTTP requests, so it needs network access:

$ cargo run -q
[1] sunt aut facere repellat provident occaecati excepturi optio reprehenderit (158 chars of body)
[2] qui est esse (206 chars of body)
[3] ea molestias quasi exercitationem repellat qui ipsa sit aut (164 chars of body)

join_all comes from the futures crate (hence the extra dependency) and returns results in input order. rustls-tls keeps the build free of a system OpenSSL dependency.

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>> {
    // Port 0 asks the OS for any free port
    let listener = TcpListener::bind("127.0.0.1:0").await?;
    let addr = listener.local_addr()?;

    // The accept loop runs as its own task, forever
    tokio::spawn(async move {
        loop {
            let (stream, _) = listener.accept().await.unwrap();
            // Each client runs in its own independent task
            tokio::spawn(handle_client(stream));
        }
    });

    // Two clients talk to the server concurrently
    let clients = ["hello", "world"].map(|msg| tokio::spawn(async move {
        let mut conn = TcpStream::connect(addr).await.unwrap();
        conn.write_all(msg.as_bytes()).await.unwrap();
        let mut buf = vec![0u8; msg.len()];
        conn.read_exact(&mut buf).await.unwrap();
        String::from_utf8(buf).unwrap()
    }));
    for c in clients {
        println!("echoed: {}", c.await?);
    }
    Ok(())
}
$ cargo run -q
echoed: hello
echoed: world

In a real server main would stop after spawning the accept loop and never return; here two client tasks connect, send, and read the echo, so the program can finish. When main returns the runtime shuts down and cancels the still-running accept loop.

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| x % 2 == 0)
        .fold(0, |acc, x| acc + x)
        .await;

    println!("sum of even 1..=100: {}", sum);
}
$ cargo run -q
1
2
3
4
5
sum of even 1..=100: 2550

tokio_stream::StreamExt’s filter and fold take ordinary closures; only the final .await is asynchronous. (The futures crate has its own StreamExt whose closures return futures — |x| async move { ... } — and mixing up the two traits is a common compile error.)

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"),
    }
}
$ cargo run -q
operation timed out

The program exits after about half a second, not five: when timeout gives up it drops slow_operation’s future, and a dropped future simply never runs again.

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

Practice

  1. In the join! example, replace tokio::join! with three sequential awaits: let a = task("A", 300).await; and so on. What does total print now?

    Solution

    total: 0.6s. Each .await finishes before the next future is even created, so the sleeps add up.

  2. Call fetch_data(1); as a statement without .await. What does the compiler say?

    Solution
    warning: unused implementer of `Future` that must be used
    ...
      = note: futures do nothing unless you `.await` or poll them

    It is only a warning — the program compiles and silently skips the work.

  3. Wrap the slow_operation call with a 6-second timeout instead of 500 ms. What prints, and how long does it take?

    Solution

    got: done, after about 5 seconds — the operation finishes before the deadline, so timeout returns Ok.

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.