Async Basics: async/await and Tokio

Async Basics: async/await and Tokio

Smart Pointers & Concurrency covered Rust's model for doing many things at once, on real OS threads. This lesson covers the other kind of "at the same time": waiting on many things at once - network calls, timers, file reads - using async/await and the tokio runtime, an external crate (#[tokio::main]) since the standard library deliberately ships no executor. Two nearly-identical programs make the whole idea concrete: awaiting two 100ms/150ms waits back to back takes about 250ms, the sum - but tokio::join!-ing the same two futures takes about 150ms, the longer one - on a single thread, with no std::thread::spawn involved. Closes with tokio::spawn, async's answer to thread::spawn from the fearless-concurrency lesson, and why tasks scale to hundreds of thousands where OS threads can't.

17 min read4 learning objectives

What You'll Learn

  • Explain what an async fn actually returns (a Future, not a running computation) and what .await does to it
  • Explain why async fn main() needs #[tokio::main]: async/await is a language feature, but running a Future requires an executor, which the standard library deliberately does not provide
  • Predict and verify the difference between awaiting two futures sequentially (sums their durations) and running them with tokio::join! (takes the longer one)
  • Use tokio::spawn to run a future in the background, and connect its JoinHandle/.await to std::thread's JoinHandle/.join() from the fearless-concurrency lesson

The last three lessons were about doing many things at once — real OS threads, genuinely running on different cores, coordinated with channels, Mutex, and Arc. This lesson is about a different kind of "at the same time": waiting on many things at once. A web server handling a thousand slow network connections doesn't need a thousand CPU cores running in parallel — it needs to not sit idle while connection #1's response is still in transit. That's async/await.

async fn returns a Future

Async code needs one more dependency — add this to Cargo.toml:

One new keyword on each line, and both matter. async fn say_hello() looks like an ordinary function — but calling it, say_hello(), doesn't run the body. It returns a value: a Future — Rust's name for "this work, described, but not done yet." .await is what actually runs it — pause the current async function right here, run the Future until it produces a result, then keep going with that result in hand (here, ()say_hello returns nothing).

#[tokio::main] does more than it looks like. main itself can never be async directly — something has to exist before any code runs, to actually drive futures: poll them, know when they're ready, wake them back up. That something is an async runtime, and the standard library deliberately doesn't ship one — async/await is a language feature; running a Future is a library's job. Tokio is the most widely used such library — an external crate, the serde-and-json lesson's cargo add pattern again. #[tokio::main] is a macro that rewrites async fn main() { ... } into a plain fn main() that starts Tokio's runtime and hands it your code. Delete that line and the error is blunt: error[E0752]: `main` function is not allowed to be `async`.

.await runs things one at a time, by default

fetch stands in for anything slow that doesn't need the CPU while it waits — a network request, a database query, a slow disk read. tokio::time::sleep(...).await is the async version of "do nothing for a while" — and crucially, it doesn't block anything. It tells the runtime "wake this task up again in ms milliseconds, and go do something else meanwhile." Instant::now() and .elapsed() read like English: a timestamp, and "how long since."

Run this (your exact number will vary by a few ms): task A done, task B done, elapsed: 258.3815ms. 100 + 150 = 250 — close enough. Two .awaits, back to back, took the sum of both waits. .await pauses the current function — it doesn't make anything else happen while paused. async by itself doesn't make code concurrent; it just makes code pausable. To get task A's wait and task B's wait actually overlapping, something has to run them both — which is tokio::join!.

tokio::join!: actually concurrent

Same fetch, same two calls — one line different. let a = ...await; let b = ...await; becomes let (a, b) = tokio::join!(fetch(...), fetch(...)) — tuple-destructuring, the fearless-concurrency lesson's let (tx, rx) = mpsc::channel() shape again, this time unpacking two results from one expression.

tokio::join! takes any number of futures, polls all of them, switching between whichever has work to do, until every one is finished — then hands back all their results together. Run it: same two lines printed, task A done / task B done — but elapsed: 156.111209ms. Not 250 — about 150, the longer of the two waits, not the sum. While task A's 100ms timer is counting down, the runtime isn't sitting there waiting — it switches to task B, starts its 150ms timer, and now both count down at once. Two waits, overlapped, for the price of the longer one. (And it happens on a single OS thread — no thread::spawn anywhere in this file. Print std::thread::current().id() inside fetch and both calls report the exact same thread.)

This is what async buys: not less work — less waiting.

tokio::spawn: tasks instead of threads

tokio::join! waits for a fixed set of futures, all written at the call site. tokio::spawn is the async cousin of the fearless-concurrency lesson's thread::spawn: hand it a Future, and the runtime starts driving it independently, in the background, immediately — handing back a JoinHandle (same name, same idea as that lesson's std::thread::JoinHandle .await standing in for .join(), and Result/.unwrap() once more, with Err here meaning the spawned task panicked, same as a thread).

Run it: tokio::spawn(fetch("task A", 100)) starts task A immediately — but main doesn't wait. It moves straight on to fetch("task B", 50).await, which finishes first (50ms < 100ms) and prints task B done. Then handle.await.unwrap() collects task A's result — by now finished, or close to it — and prints task A done.

The headline difference from the fearless-concurrency and shared-state lessons' thread::spawn: an OS thread is the operating system's resource — real, megabytes of stack each, genuinely limited to maybe tens of thousands at the absolute most. A Tokio task is just a Future the runtime keeps polling — tokio::spawn scales to hundreds of thousands of them on an ordinary machine. That's the whole pitch: when the work is mostly waiting — a web server holding open thousands of slow connections — tasks go where threads can't follow.

Quick exercise

  1. Add a third call to the tokio::join! example — fetch("task C", 200) — both inside tokio::join!(...) and as a third element of let (a, b, c) = ... (plus a third println!). Run it: elapsed should land around 200ms — the longest of the three — not 450ms, the sum.
  2. In the sequential example, swap the order — fetch("task B", 150) first, then fetch("task A", 100). elapsed doesn't change (still ~250ms — sequential work is sequential regardless of order), but the printed order does. Now make the same swap in the tokio::join! example: does the printed order change? It doesn't — tokio::join!'s tuple always returns results in the order the futures were passed in, not the order they finish.

Two concurrency models, back to back. The last three lessons: OS threads, real parallelism, Mutex/Arc/Send/Sync — for CPU-bound work, spread across cores. This lesson: async/await and an external runtime, cooperative tasks on however many threads the runtime chooses — for I/O-bound work, where the bottleneck is waiting, not computing. Real programs often use both: a tokio::spawned task calling ordinary threaded code for CPU-heavy chunks via spawn_blocking — not covered here, but exactly the kind of name that should make sense by now.

The next lesson turns back to plain std for one last idea, hiding in plain sight since lesson 6: println!, vec!, format! — every name ending in ! used so far is a macro, and macro_rules! is how to write your own.