Async/Await with Tokio

Concurrency in Practice covered OS threads: each one is a real, preemptively-scheduled unit of execution, and the right tool when work is CPU-bound. Async is a different answer to a different problem: thousands of tasks that spend almost all their time waiting (on a socket, a database, a timer) rather than computing. Spawning a thread per connection doesn't scale to that, each thread costs real memory and a context switch; async tasks are cheap enough to spawn by the thousands because they're scheduled cooperatively on a small pool of threads.

Rust's std only defines the Future trait and the async/.await syntax, it ships no executor to actually run them. Tokio is the runtime almost all production async Rust uses. This tutorial assumes Tokio is already a dependency and focuses on the patterns you'll actually write.


async fn Is Lazy

An async fn doesn't run when you call it. It immediately returns a Future, a value representing the eventual result, and nothing happens until something polls that future to completion.

async fn fetch_user(id: u64) -> User {
    // ...
}

let future = fetch_user(42);  // nothing has run yet, this is just a value
let user = future.await;      // now it actually executes

.await is the thing that drives a future forward. Forgetting it is a common mistake the compiler catches with a warning (unused future that does nothing), but it's worth internalizing: an async fn call with no .await is a no-op.


The Runtime: Why You Need Tokio

Because std has no executor, every async program needs one to actually poll futures. #[tokio::main] is sugar that wraps your main in a runtime and blocks on it:

#[tokio::main]
async fn main() {
    let user = fetch_user(42).await;
    println!("{user:?}");
}

This expands to roughly:

fn main() {
    let rt = tokio::runtime::Runtime::new().unwrap();
    rt.block_on(async {
        let user = fetch_user(42).await;
        println!("{user:?}");
    });
}

block_on is the bridge between sync and async code: it's the one place a thread sits and drives a future to completion. Everything inside that future tree runs cooperatively on the runtime's worker threads.


tokio::spawn: Concurrent Tasks

.await alone is sequential, each await finishes before the next line runs. tokio::spawn is the async equivalent of thread::spawn: it schedules a future to run concurrently with everything else on the runtime.

let handle = tokio::spawn(async {
    fetch_user(42).await
});

let other_work = do_something_else().await;
let user = handle.await.unwrap();  // await the JoinHandle to get the result

Like thread::spawn, the spawned future must be 'static and Send, it may run on a different worker thread than the one that spawned it. This is exactly the Send/Sync requirement from regular threads, just enforced on the future's captured state instead of a closure's.

For running a fixed, known set of futures concurrently and waiting for all of them, tokio::join! is usually cleaner than spawning each one individually:

let (user, posts) = tokio::join!(
    fetch_user(42),
    fetch_posts(42),
);

join! polls both futures on the current task, interleaving them, it doesn't create new tasks the way spawn does. Use join! when the futures are short-lived and tied to the current task's lifetime; use spawn when you want a future to keep running independently, with its own JoinHandle you can await later or drop.


select!: Racing Futures

tokio::select! runs several futures concurrently and proceeds as soon as the first one completes, cancelling the rest.

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

tokio::select! {
    result = fetch_user(42) => {
        println!("got user: {result:?}");
    }
    _ = sleep(Duration::from_secs(5)) => {
        println!("timed out");
    }
}

This is the standard pattern for timeouts (race the real work against a sleep), and for shutdown signals (race a long-running loop against a "stop" channel). The branches that don't win are dropped, which cancels their work, so don't put anything in a losing branch that needs to run to completion to stay correct (e.g. a write that's only half done).


Async Channels and Mutexes Are Different Types

std::sync::mpsc and std::sync::Mutex block the OS thread when they have nothing to do. Blocking an OS thread inside an async task stalls every other task scheduled on that thread, the executor has no way to run something else while a thread is parked in a blocking syscall. Tokio ships async-aware equivalents that yield control back to the runtime instead:

use tokio::sync::{mpsc, Mutex};

let (tx, mut rx) = mpsc::channel(32);
tx.send(42).await.unwrap();          // .await, not a blocking call
let value = rx.recv().await.unwrap();

let lock = Mutex::new(0);
let mut guard = lock.lock().await;   // also .await
*guard += 1;

Holding a tokio::sync::Mutex guard across an .await is fine; holding a std::sync::Mutex guard across one is a bug. A std guard held across an await point can be held by a task that's been suspended and moved to another thread, which can deadlock or violate the lock's assumptions. If you only ever lock, mutate, and release without an .await in between, std::sync::Mutex is fine and cheaper. The moment an await point falls inside the locked section, switch to tokio::sync::Mutex.


The Cardinal Sin: Blocking Inside Async

Tokio runs many tasks on a handful of OS threads. Any call that blocks the thread, std::thread::sleep, a synchronous file read, a CPU-heavy loop, a blocking database driver, freezes every other task sharing that thread until it returns.

// WRONG: blocks the worker thread, stalling other tasks
async fn bad() {
    std::thread::sleep(Duration::from_secs(1));
}

// RIGHT: yields control back to the runtime
async fn good() {
    tokio::time::sleep(Duration::from_secs(1)).await;
}

For work that's unavoidably blocking or CPU-bound (a heavy computation, a synchronous library with no async API), hand it to a dedicated thread pool instead of running it on a worker thread:

let result = tokio::task::spawn_blocking(|| {
    expensive_sync_computation()
}).await.unwrap();

spawn_blocking runs the closure on a separate thread pool reserved for exactly this, the async worker threads stay free to keep polling other tasks while it runs.


Key Takeaways

  • async fn returns a Future that does nothing until .awaited. Calling it without awaiting is a silent no-op.
  • std defines the Future trait but ships no executor. Tokio's runtime is what actually polls futures; #[tokio::main] wraps your main in Runtime::block_on.
  • tokio::spawn runs a future concurrently as an independent task (like thread::spawn); tokio::join! runs futures concurrently within the current task and waits for all of them; tokio::select! races futures and cancels the losers.
  • Spawned futures need 'static + Send, the same requirement as spawned threads, for the same reason: they may run on another worker thread.
  • Use tokio::sync::{mpsc, Mutex} over the std versions whenever an .await can happen while the channel/lock is in use, the async versions yield instead of blocking the worker thread.
  • Never call a blocking function inside an async task. Use tokio::time::sleep instead of thread::sleep, and spawn_blocking for CPU-heavy or synchronous-only work.