EthicsComputerCourses
Join training +91 90532 10052

Async Rust with Tokio

Rust · Lesson 30 of 43 Advanced 7 min read Updated

Many programs spend most of their time waiting: for a web page to download, a database to answer or a file to arrive over the network. Threads can wait in parallel, but each operating-system thread costs memory, and a server with 10,000 open connections cannot easily run 10,000 threads. Async programming lets a small number of threads juggle thousands of waiting tasks.

This lesson is an overview. You will learn what async and await do, why Rust needs a separate runtime to run async code, and how to use Tokio, the most widely used runtime, for common jobs: running tasks concurrently, spawning background tasks and adding timeouts.

async, await and futures

An async fn does not run when you call it. It returns a future: a value that describes work which can be done later. The work happens when you .await the future. While a future waits (for a timer or the network), the runtime switches to other tasks instead of blocking the thread.

Rust's standard library defines the Future trait and the async/await syntax, but deliberately does not include a runtime, so different projects can choose one that fits (a web server and a microcontroller have very different needs). For servers and command-line tools, the usual choice is Tokio (version 1.54 in October 2026). Add it with cargo add tokio --features full, or write the dependency in Cargo.toml yourself:

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

The full feature turns on everything (timers, networking, file system, macros); production projects often list only the features they use.

Your first async program

Rust
use std::time::Duration;
use tokio::time::sleep;

async fn fetch_price(item: &str) -> u32 {
    sleep(Duration::from_millis(300)).await; // pretend this is a network call
    match item {
        "rice" => 62,
        "dal" => 140,
        "atta" => 45,
        _ => 0,
    }
}

#[tokio::main]
async fn main() {
    let rice = fetch_price("rice").await;
    let dal = fetch_price("dal").await;
    println!("rice: {rice}, dal: {dal}");
}
Output
rice: 62, dal: 140
  • #[tokio::main] turns async fn main into a normal main that starts the Tokio runtime and runs your async code on it.
  • tokio::time::sleep is an async timer. It pauses only this task; the thread is free to run other tasks meanwhile.
  • Each .await waits for one call to finish before the next starts, so this program takes about 600 ms.

Futures are lazy. let f = fetch_price("rice"); creates a future and does nothing else; the compiler even warns about futures that are never awaited. Only f.await (or handing f to the runtime) makes it run.

Running futures concurrently with join!

To let the waits overlap, give several futures to tokio::join!, which runs them concurrently on the same task and returns all results as a tuple:

Rust
// fetch_price as above

#[tokio::main]
async fn main() {
    let (rice, dal, atta) = tokio::join!(
        fetch_price("rice"),
        fetch_price("dal"),
        fetch_price("atta"),
    );
    println!("rice: {rice}, dal: {dal}, atta: {atta}");
    println!("Cart total: Rs. {}", rice + dal + atta);
}
Output
rice: 62, dal: 140, atta: 45
Cart total: Rs. 247

All three timers run at the same time, so the program takes about 300 ms instead of 900 ms. The results come back in the order you listed the futures, whichever finished first. This is concurrency without threads: one thread switches between three waiting futures.

Spawning tasks

tokio::spawn hands a future to the runtime as an independent task, similar to thread::spawn but much cheaper: a task needs only a few hundred bytes, so you can have hundreds of thousands. Tokio runs tasks on a pool of worker threads. spawn returns a JoinHandle; awaiting it gives a Result with the task's output.

Rust
use std::time::Duration;
use tokio::time::sleep;

#[tokio::main]
async fn main() {
    let mut handles = Vec::new();
    for city in ["Rohtak", "Jaipur", "Pune"] {
        handles.push(tokio::spawn(async move {
            sleep(Duration::from_millis(100)).await;
            format!("Weather loaded for {city}")
        }));
    }

    for handle in handles {
        println!("{}", handle.await.unwrap());
    }
}
Output
Weather loaded for Rohtak
Weather loaded for Jaipur
Weather loaded for Pune

The lines always appear in this order because we await the handles in the order they were created, even if the tasks finish in a different order. As with threads, a spawned task may outlive the code that started it, so its future must own its data: hence async move, and the rule that it cannot borrow local variables.

Timeouts and select

Network calls should never wait forever. tokio::time::timeout wraps a future and gives up after a deadline:

Rust
use std::time::Duration;
use tokio::time::{sleep, timeout};

// fetch_price as above (it takes 300 ms)

#[tokio::main]
async fn main() {
    match timeout(Duration::from_millis(100), fetch_price("dal")).await {
        Ok(price) => println!("Price: {price}"),
        Err(_) => println!("Server too slow, please try again"),
    }
}
Output
Server too slow, please try again

When the deadline passes, the unfinished future is simply dropped, which cancels it. For more control, tokio::select! waits on several futures and runs the branch of whichever finishes first, for example "a message arrived" versus "the user pressed Ctrl+C".

Threads or async?

SituationBetter choice
Thousands of network connections, mostly waitingAsync (Tokio)
Heavy calculation using every CPU coreThreads (or the rayon crate)
A few background jobs in a simple programThreads
Calling slow blocking code from async codetokio::task::spawn_blocking

Never call blocking functions such as std::thread::sleep or long CPU loops directly inside an async task: they block the worker thread and freeze every other task scheduled on it. Use the async versions (tokio::time::sleep, tokio::fs) or move the work to spawn_blocking.

Common mistakes

  • Forgetting .await. The future never runs. The compiler warns that futures do nothing unless awaited or polled.
  • Blocking inside async code. std::thread::sleep or heavy loops stall the runtime. Use async APIs or spawn_blocking.
  • Awaiting one by one when work could overlap. Use join! or spawn tasks for independent operations.
  • Borrowing local data in tokio::spawn. Spawned futures must own their data; use async move and owned values or Arc.

Key points

  • An async fn returns a lazy future; .await runs it and yields to other tasks while waiting.
  • Rust includes async/await but no runtime; Tokio is the common runtime, started with #[tokio::main].
  • tokio::join! runs futures concurrently and returns all results in order; tokio::spawn starts independent tasks.
  • timeout and select! cancel slow futures by dropping them.
  • Use async for many waiting I/O tasks, threads for CPU-heavy work, and never block inside async code.

Exercise

Write an async function check_station(name: &str, delay_ms: u64) -> String that sleeps for delay_ms milliseconds and returns "<name>: on time". In main, check three stations concurrently with tokio::join! (delays 200, 50 and 120 ms) and print the results. Then wrap a fourth check with a 500 ms delay in a 300 ms timeout and print a fallback message.

Show solution
Rust
use tokio::time::{sleep, timeout, Duration};

async fn check_station(name: &str, delay_ms: u64) -> String {
    sleep(Duration::from_millis(delay_ms)).await;
    format!("{name}: on time")
}

#[tokio::main]
async fn main() {
    let (a, b, c) = tokio::join!(
        check_station("Lucknow", 200),
        check_station("Kanpur", 50),
        check_station("Agra", 120),
    );
    println!("{a}\n{b}\n{c}");

    match timeout(Duration::from_millis(300), check_station("Delhi", 500)).await {
        Ok(status) => println!("{status}"),
        Err(_) => println!("Delhi: no response, status unknown"),
    }
}
Output
Lucknow: on time
Kanpur: on time
Agra: on time
Delhi: no response, status unknown

The first three checks overlap, so they take about 200 ms in total, and join! returns them in the order written even though Kanpur finished first. The Delhi check needs 500 ms, so the 300 ms timeout fires and drops it.

Quick Quiz

1. What happens when you call an async fn without .await?

2. Why does a Rust program need Tokio (or another runtime) for async code?

3. What does tokio::join!(a(), b()) do?

4. Why should you not call std::thread::sleep inside an async task?

Learn Rust with a mentor

Free lessons get you started. Our Programming & Software Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call