Updated Aug 6, 2026

24 - Async and Await

📋 Jump to Takeaways

🎁 What if you could run 10,000 network requests at once on just a handful of threads, and write the code as if it ran plainly top to bottom?

Threads give you parallelism, but each one costs an OS thread and real memory. For I/O-bound work (network calls, file reads, database queries) most of that thread just sits waiting. Async lets thousands of waiting tasks share a small pool of threads. You write sequential-looking code, and the runtime juggles who runs when.

What Is a Future

Mark a function async and it no longer returns its value directly. It returns a Future: a value that represents "this result, eventually."

// `async` makes this return a Future<Output = String>, not a String
async fn greeting() -> String {
    String::from("Hello from async!")
}

The Output associated type tells you what the future eventually produces when it completes.

Here's the part that trips people up: calling greeting() runs none of its body. Futures are lazy. Nothing happens until something drives the future forward. If you forget .await, nothing runs. The future is created but never executed, and the compiler warns you: "unused implementer of Future".

Awaiting a Future

The thing that drives a future is .await. It runs the future to completion and hands you the value, but only inside an async context.

// Add to Cargo.toml: tokio = { version = "1", features = ["full"] }

async fn greeting() -> String {
    String::from("Hello from async!")
}

#[tokio::main]
async fn main() {
    let msg = greeting().await; // now the future actually runs
    println!("{}", msg); // Hello from async!
}

#[tokio::main] starts the Tokio runtime so .await works inside main. We'll explain this fully in the next section.

While a future is waiting (say, on a slow network reply), .await yields control back to the runtime so other tasks can run. That yielding is the whole trick.

A common misconception: marking a function async doesn't make it non-blocking. A future only yields at .await points. If the body does synchronous work with no .await inside — reading a file with std::fs::read(), heavy computation — it blocks the tokio thread for the entire duration, stalling every other task on it:

async fn read_file() -> String {
    std::fs::read_to_string("huge.txt").unwrap() // ❌ no .await — blocks the tokio thread
}

For I/O, use tokio's async equivalents which actually yield while waiting:

async fn read_file() -> String {
    tokio::fs::read_to_string("huge.txt").await.unwrap() // ✅ yields while waiting
}

Here's what happens when this runs:

  1. read_file() is called — returns a Future, nothing runs yet
  2. .await starts driving the future
  3. Tokio asks the OS to start reading the file using non-blocking I/O
  4. While the OS does the disk work, the future yields control back to the runtime
  5. Tokio uses the freed thread to run other tasks
  6. When the OS signals "done", Tokio wakes this future back up
  7. .unwrap() runs and the String is returned

The tokio thread is never sitting idle — it's off doing other work while the OS handles the disk.

The caller finds out the file is done via .await — it's the synchronization point. When the caller awaits read_file(), it parks until the result is ready:

#[tokio::main]
async fn main() {
    let contents = read_file().await; // parks here until file is fully read
    println!("got {} bytes", contents.len()); // runs only after done
}

If the caller wants to do other work while the file reads, spawn it as an independent task and join later:

#[tokio::main]
async fn main() {
    let handle = tokio::spawn(read_file()); // starts reading, caller moves on immediately

    do_other_work().await; // runs concurrently while file is being read

    let contents = handle.await.unwrap(); // .await on the handle IS the join — parks until the task finishes
    println!("got {} bytes", contents.len());
}

In Go terms: exactly what happens with goroutines. When a goroutine blocks on I/O, the scheduler parks it and runs another on that OS thread. Same idea, different mechanism.

For CPU-heavy work, use spawn_blocking to move it off tokio's threads entirely (covered in the Mixed Workloads section below).

The Tokio Runtime

Futures need something to poll them. That something is a runtime, and Tokio is the most popular one. Polling means the runtime repeatedly checks whether a future has made progress, similar to how Go's scheduler manages goroutines behind the scenes. The #[tokio::main] attribute wraps your async fn main so it starts a runtime and blocks until your top-level future finishes.

// Add to Cargo.toml: tokio = { version = "1", features = ["full"] }
use tokio::time::{sleep, Duration};

#[tokio::main]
async fn main() {
    println!("start");
    sleep(Duration::from_millis(100)).await; // non-blocking sleep
    println!("100ms later");
}

Notice this sleep is Tokio's, not std::thread::sleep. It parks the task without blocking the thread, so other tasks keep running during those 100ms.

Running Futures Concurrently

Awaiting two futures one after another runs them in sequence. To run them at the same time, hand them to tokio::join!, which polls all of them together.

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

async fn task(name: &str, ms: u64) -> String {
    sleep(Duration::from_millis(ms)).await;
    format!("{} done", name)
}

#[tokio::main]
async fn main() {
    // Both run concurrently: total wait is ~100ms, not 150ms
    let (a, b) = tokio::join!(task("A", 100), task("B", 50));
    println!("{} | {}", a, b); // A done | B done
}

If you had .awaited each task separately, you'd wait 100 then 50, for 150ms total. join! overlaps them, so the slowest one sets the pace.

Spawning Tasks

tokio::spawn hands a future to the runtime as an independent task, then returns a JoinHandle. The task starts running immediately while your code moves on.

You can create a future inline with an async { } block. It works like an async fn but without a name.

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

#[tokio::main]
async fn main() {
    let handle = tokio::spawn(async {
        sleep(Duration::from_millis(50)).await;
        42
    });

    println!("doing other work meanwhile...");

    let result = handle.await.unwrap(); // JoinHandle resolves to Result
    println!("task returned {}", result); // task returned 42
}

Awaiting the JoinHandle gives you a Result, because a spawned task can panic. The runtime catches panics from spawned tasks and wraps them in Err, so the parent task can decide what to do instead of crashing the whole program. That's why you see .unwrap() here.

Async vs Threads

Both give you concurrency, so which do you reach for?

Workload Reach for
Heavy computation (CPU-bound) Threads or Rayon
Network, files, databases (I/O-bound) Async / await
A handful of long-running jobs Threads
Thousands of tasks that mostly wait Async

The rule of thumb: threads for work that keeps a CPU busy, async for work that spends its life waiting on something else.

Mixed Workloads: I/O and CPU Together

Real workloads are often both — read a file over the network (I/O), transcode it (CPU), write the result back (I/O). The pattern is: use tokio for the I/O parts, offload the CPU parts with tokio::task::spawn_blocking.

The reason you can't do CPU-heavy work directly inside an async task: Tokio runs one thread per CPU core. If you block one of those threads with heavy computation, all async tasks scheduled on it stall. spawn_blocking moves the work to a separate thread pool dedicated to blocking/CPU tasks, keeping Tokio's threads free for I/O.

use tokio::task;

async fn process_video(path: &str) -> Vec<u8> {
    // I/O-bound: async read doesn't block Tokio's threads
    let chunk = tokio::fs::read(path).await.unwrap();

    // CPU-bound: offload to blocking thread pool so Tokio stays responsive
    let transcoded = task::spawn_blocking(move || {
        transcode(chunk) // heavy work here
    }).await.unwrap();

    // I/O-bound: async write
    tokio::fs::write("output.mp4", &transcoded).await.unwrap();
    transcoded
}

Rayon fits naturally inside spawn_blocking for data-parallel CPU work — Rayon manages its own thread pool and works independently of Tokio's.

task::spawn_blocking(move || {
    frames.par_iter().map(|f| encode_frame(f)).collect::<Vec<_>>()
}).await.unwrap();
Part of the job Tool
Network, file reads/writes, DB queries tokio async
Heavy CPU work inside async task::spawn_blocking
Data-parallel CPU work rayon inside spawn_blocking

Cancellation with CancellationToken

Go's context.Context propagates a cancellation signal through your async call stack. Rust's closest equivalent is CancellationToken from the tokio-util crate, combined with tokio::select!.

# Cargo.toml
tokio-util = { version = "0.7", features = ["rt"] }
use tokio::time::{sleep, Duration};
use tokio_util::sync::CancellationToken;

async fn do_work(token: CancellationToken) {
    tokio::select! {
        _ = token.cancelled() => {
            println!("cancelled, cleaning up");
        }
        _ = sleep(Duration::from_secs(10)) => {
            println!("work finished naturally");
        }
    }
}

#[tokio::main]
async fn main() {
    let token = CancellationToken::new();
    let child = token.child_token(); // child is cancelled when parent is cancelled

    let handle = tokio::spawn(do_work(child));

    sleep(Duration::from_millis(100)).await;
    token.cancel(); // signal cancellation — like calling cancel() on a Go context

    handle.await.unwrap();
}

tokio::select! races multiple futures and runs whichever branch completes first, cancelling the rest. Here token.cancelled() resolves when .cancel() is called, winning the race and stopping the work early.

You can nest tokens — child_token() creates a child that is cancelled when the parent cancels, just like context.WithCancel in Go.

The Go comparison:

Go Rust async
context.Context CancellationToken
ctx.Done() token.cancelled()
context.WithCancel token.child_token()
cancel() token.cancel()
select { case <-ctx.Done(): } tokio::select! { _ = token.cancelled() => {} }
context.WithTimeout tokio::time::timeout(duration, future)

Key Takeaways

  • An async fn returns a Future, and futures are lazy, nothing runs until you .await
  • .await drives a future to completion and yields control while it waits
  • Futures need a runtime to execute them; #[tokio::main] sets Tokio up for you
  • tokio::join! runs multiple futures concurrently, pacing to the slowest one
  • tokio::spawn launches an independent task and returns a JoinHandle
  • Use async for I/O-bound concurrency, threads or Rayon for CPU-bound work
  • For mixed workloads, use tokio::task::spawn_blocking to offload CPU-heavy work to a separate thread pool without blocking Tokio's I/O threads
  • CancellationToken from tokio-util is the async equivalent of Go's context.Context — cancel a parent and all child tokens cancel too
  • tokio::select! races futures and runs the first branch that completes, cancelling the rest

🎁 You've learned ownership, structs, error handling, iterators, and concurrency. Now it's time to put them all together and build a real CLI tool from scratch that reads files, counts words, and handles errors gracefully.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

Spot something off? Report an issue
© 2026 ByteLearn.dev. Free courses for developers. · Privacy