Tokio basics

Rust · Advanced · 18 min

Tokio is the de facto async runtime behind axum, reqwest, tonic and most Rust network services.

Tokio provides the executor, timers, async networking and filesystem APIs, and sync primitives. Add it with cargo add tokio --features full (or pick features like rt-multi-thread, macros, net, time, sync).

#[tokio::main] turns async fn main into a normal main that starts a multi-threaded, work-stealing runtime and blocks on your future. #[tokio::test] does the same for tests.

Concurrency tools:

  • `tokio::spawn(fut)` runs a future as an independent task, possibly on another worker thread, and returns a JoinHandle you can .await for the result (an Err if it panicked). Spawned futures must be Send + 'static, so move owned or Arc'd data in.
  • `tokio::join!(a, b)` runs futures concurrently within the current task and waits for all; try_join! stops at the first error.
  • `tokio::select!` waits on several futures and runs the branch of whichever finishes first, dropping (cancelling) the others. It is the building block for timeouts, shutdown signals and racing requests.
  • `tokio::time::timeout(dur, fut)` and sleep for timing; never std::thread::sleep.

For shared state, std::sync::Mutex is fine if you never hold the guard across an .await (the compiler usually stops you anyway, because the guard is not Send). If you must hold a lock across .await, use tokio::sync::Mutex. Async channels live in tokio::sync: mpsc, oneshot for a single reply, broadcast, and watch for latest-value state.

Blocking or CPU-heavy work goes to `tokio::task::spawn_blocking`, which runs it on a separate thread pool and gives you a future for the result.

A useful mental model: join! and select! give concurrency inside one task; spawn gives you new tasks that can run in parallel.

Example

// Cargo.toml: tokio = { version = "1", features = ["full"] }
use std::time::Duration;
use tokio::{sync::mpsc, time::{sleep, timeout}};

async fn fetch(id: u32, ms: u64) -> String {
    sleep(Duration::from_millis(ms)).await; // yields; never use thread::sleep here
    format!("item {id}")
}
#[tokio::main]
async fn main() {
    let (a, b) = tokio::join!(fetch(1, 30), fetch(2, 10)); // concurrent, ~30 ms total
    println!("{a}, {b}");                                    // item 1, item 2
    let slow = timeout(Duration::from_millis(5), fetch(3, 50)).await;
    println!("timed out: {}", slow.is_err());                // timed out: true
    let (tx, mut rx) = mpsc::channel(16);
    for id in 0..3 {
        let tx = tx.clone();
        tokio::spawn(async move { tx.send(fetch(id, 5).await).await.unwrap() });
    }
    drop(tx);
    let mut got = Vec::new();
    while let Some(item) = rx.recv().await { got.push(item); }
    let sum = tokio::task::spawn_blocking(|| (1..=1_000_000u64).sum::<u64>()).await.unwrap();
    println!("{} items, sum = {sum}", got.len()); // 3 items, sum = 500000500000
}

Remember

  • #[tokio::main] starts the runtime; tokio::spawn creates Send + 'static tasks
  • join! waits for all, select! takes the first and cancels the rest
  • Use tokio::time and spawn_blocking; never block a worker thread

Watch out for

  • Holding a std::sync::MutexGuard across .await; scope the guard or use tokio::sync::Mutex.

Check yourself

  1. In `tokio::select! { a = fut_a => ..., b = fut_b => ... }`, what happens to fut_b if fut_a finishes first?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: tokio.rs/tokio/tutorial

Builds on

Unlocks

Nothing builds on this yet.