Async Rust with Tokio

Reviewed & published by Brayan K

Rust's async/await lets you handle thousands of concurrent operations on just a handful of threads — and Tokio is the runtime that makes it practical for real networking and I/O.

Part of the free Rust course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

In this lesson you'll write async functions, drive them with the Tokio runtime, run tasks concurrently with spawn and join!, and pass messages between tasks with channels.

What You'll Learn in This Lesson

1️⃣ async / await and the Tokio Runtime

An async fn returns a Future — a value that does no work until it is driven. The .await keyword drives a future to completion, yielding control to the runtime while it waits. The #[tokio::main] attribute sets up that runtime and runs your async main.

// Async functions return a Future. They do NOTHING until you .await them.
// #[tokio::main] starts a Tokio runtime and runs this async main for you.
use tokio::time::{sleep, Duration};

async fn greet(name: &str) -> String {
    // sleep is an async pause: it yields to the runtime instead of blocking.
    sleep(Duration::from_millis(50)).await;
    format!("Hello, {name}!")
}

#[tokio::main]
async fn main() {
    // Calling greet(...) builds a future. Awaiting it drives it to completion.
    let message = greet("Tokio").await;
    println!("{message}");
}

Nothing ran until .await. While sleep waited, the runtime was free to do other work instead of blocking the thread.

2️⃣ Concurrency with spawn and join!

tokio::spawn launches an independent task and returns a JoinHandle you can await for its result. tokio::join! runs several futures concurrently on the current task and waits for all of them — overlapping the waits instead of adding them up.

// Concurrency: run many things at once on a small number of threads.
use tokio::time::{sleep, Duration};

async fn fetch(id: u32) -> u32 {
    sleep(Duration::from_millis(100)).await; // pretend this is a network call
    id * 10
}

#[tokio::main]
async fn main() {
    // tokio::spawn schedules a task that runs concurrently. It returns a
    // JoinHandle you can .await to get the task's result.
    let handle = tokio::spawn(async { fetch(1).await });

    // tokio::join! awaits several futures concurrently on THIS task.
    // All three "network calls" overlap, so this takes ~100ms, not ~300ms.
    let (a, b, c) = tokio::join!(fetch(2), fetch(3), fetch(4));

    let spawned = handle.await.unwrap(); // unwrap the JoinHandle result
    println!("spawned = {spawned}");
    println!("joined  = {a}, {b}, {c}");
}

Three 100ms "fetches" finished in about 100ms total because they overlapped. That overlap, not extra threads, is what async concurrency buys you.

3️⃣ Talking Between Tasks with Channels

Tasks share data safely by message passing. tokio::sync::mpsc is a multi-producer, single-consumer channel: many tasks can send, one task recvs. When every sender is dropped, recv() returns None.

// Channels let tasks communicate. mpsc = multi-producer, single-consumer.
use tokio::sync::mpsc;

#[tokio::main]
async fn main() {
    // Create a bounded channel with capacity 8.
    let (tx, mut rx) = mpsc::channel::<i32>(8);

    // A producer task sends values, then drops its sender.
    tokio::spawn(async move {
        for i in 1..=3 {
            tx.send(i).await.unwrap(); // .await: waits if the channel is full
        }
    });

    // The consumer receives until every sender is dropped (recv returns None).
    while let Some(value) = rx.recv().await {
        println!("got {value}");
    }
}

A bounded channel also gives you backpressure: if the buffer is full, send().await waits until there is room, so a fast producer can't overwhelm a slow consumer.

Mini-Challenge: Overlap the Waits

Run three async "downloads" concurrently with tokio::join! so the program finishes in the time of the longest one.

// 🎯 MINI-CHALLENGE: download three things concurrently.
//
// 1. Write an async fn work(ms: u64) -> u64 that sleeps for ms milliseconds
//    (use tokio::time::sleep) and then returns ms.
// 2. In an async main marked with #[tokio::main], use tokio::join! to run
//    work(30), work(20), and work(10) concurrently.
// 3. Print the three results. Because they overlap, the whole program should
//    finish in about 30ms, not 60ms.

#[tokio::main]
async fn main() {
    // your code here
}

Common Errors (and the fix)

Pro Tips

📋 Quick Reference — Async & Tokio

GoalRust Syntax
Async functionasync fn f() -> T { }
Start a runtime#[tokio::main] async fn main()
Await a futurelet x = f().await;
Spawn a tasklet h = tokio::spawn(async { });
Run concurrentlylet (a, b) = tokio::join!(x, y);
Channelmpsc::channel::<i32>(8)

🎉 Lesson Complete!

Practice quiz

What does the .await keyword do?

  • Yields control back to the runtime until the future is ready
  • Cancels the future
  • Blocks the OS thread until done
  • Spawns a new OS thread

Answer: Yields control back to the runtime until the future is ready. Awaiting a future yields to the runtime so other tasks can run until this one is ready.

What is the role of the #[tokio::main] attribute?

  • It marks main as unsafe
  • It imports the tokio crate
  • It sets up a Tokio runtime and runs your async main
  • It enables compiler optimizations

Answer: It sets up a Tokio runtime and runs your async main. It rewrites main to start a Tokio runtime that drives your async code.

What does tokio::spawn return?

  • A Result
  • A JoinHandle that you can await
  • A thread handle
  • Nothing

Answer: A JoinHandle that you can await. tokio::spawn returns a JoinHandle which resolves to the task output when awaited.

What does tokio::join! do?

  • Joins two strings
  • Locks a mutex
  • Joins OS threads
  • Awaits multiple futures concurrently on the same task

Answer: Awaits multiple futures concurrently on the same task. join! polls several futures concurrently and waits for all of them to finish.

A plain async fn that is never awaited will...

  • Do nothing, because futures are lazy
  • Run once at startup
  • Run on a background thread
  • Panic immediately

Answer: Do nothing, because futures are lazy. Futures in Rust are lazy and only make progress when polled by awaiting or spawning them.

Which type does an async fn implicitly return?

  • A Thread
  • A Future
  • A Result
  • An Option

Answer: A Future. An async fn returns an anonymous type that implements the Future trait.

Which Tokio module provides a multi-producer single-consumer channel?

  • tokio::net
  • tokio::fs
  • tokio::time
  • tokio::sync::mpsc

Answer: tokio::sync::mpsc. tokio::sync::mpsc gives an async multi-producer, single-consumer channel.

How is an async task different from an OS thread?

  • Tasks always use more memory
  • Tasks cannot run concurrently
  • Tasks are scheduled cooperatively by the runtime, not the OS
  • Tasks block the runtime

Answer: Tasks are scheduled cooperatively by the runtime, not the OS. Tasks are lightweight and cooperatively scheduled by Tokio rather than the operating system.

What happens when you call .await inside a non-async function?

  • It runs synchronously
  • It is a compile error
  • It spawns a thread
  • It returns immediately

Answer: It is a compile error. The .await keyword is only allowed inside async functions or blocks.

Why are Tokio tasks well suited to high I/O workloads?

  • Many tasks can wait on I/O on a few threads without blocking them
  • They precompute results
  • They disable the borrow checker
  • They use one thread per connection

Answer: Many tasks can wait on I/O on a few threads without blocking them. Async tasks yield while waiting on I/O, letting a small thread pool serve many connections.

Continue this course

Frequently asked questions

What is the difference between async tasks and OS threads?

OS threads are scheduled preemptively by the operating system and each carries its own stack, so spawning thousands of them is expensive. Tokio tasks are lightweight futures scheduled cooperatively by the runtime on a small pool of worker threads. A task only gives up its thread at an .await point, which is why async shines for I/O-bound work: a few threads can manage tens of thousands of connections because each task yields while it waits for the network. For CPU-bound work that never awaits, real threads (or tokio::task::spawn_blocking) are usually the better fit.

Why does my async function do nothing when I call it?

Futures in Rust are lazy. Calling an async fn does not run its body — it just builds a Future value that represents the work. The body only makes progress when something polls the future, which happens when you .await it or hand it to the runtime with tokio::spawn. This is different from many languages where calling an async function immediately starts it. If you see a warning like 'unused implementer of Future that must be used', it means you created a future and forgot to await or spawn it.

When should I use tokio::spawn versus tokio::join!?

Use tokio::join! when you want to run a fixed set of futures concurrently on the current task and wait for all of them together — it returns a tuple of their results. Use tokio::spawn when you want a task to run independently in the background, possibly outliving the current scope, and you get back a JoinHandle you can await later (or simply let run). spawn requires its future to be Send and 'static because it can move to another worker thread, while join! has no such requirement.

How do tasks communicate safely?

The idiomatic way is message passing with channels from tokio::sync, such as mpsc (multi-producer, single-consumer), oneshot (a single value), or broadcast. Sending on an mpsc channel is itself async: tx.send(value).await waits if a bounded channel is full, providing natural backpressure. The receiver's rx.recv().await returns Some(value) for each message and None once all senders are dropped. Channels avoid sharing mutable state directly; when you do need shared state, reach for tokio::sync::Mutex or an Arc around your data.