23.3Using Tokio
Async code needs a runtime to run it (lesson 23.2), and Rust ships none. The most widely used one, the de facto standard for network services and async applications, is Tokio. This lesson adds it, gets async code actually executing, and shows the thing async is for: doing many waits concurrently. It's your third dependency, after rand and rayon, and like those, one line in Cargo.toml unlocks a large amount of capability.
Adding Tokio
Add it with cargo add, requesting all its features for now:
$ cargo add tokio --features full Adding tokio v1.40.0 to dependencies
That writes a line like tokio = { version = "1", features = ["full"] } into Cargo.toml. (The exact patch version varies; these examples target the 1.x series.) The full feature set turns on everything, the timer, the task scheduler, async file and network IO. Real projects trim this down to just the features they use, but full is the right choice while learning.
#[tokio::main] starts the runtime
Recall the wall from last lesson: main can't be async, so it can't .await anything, so nothing drives your futures. Tokio's answer is an attribute that rewrites your main for you. Write an async fn main and tag it #[tokio::main]:
#[tokio::main]
async fn main() {
println!("hello from async main");
say_hi().await;
}
async fn say_hi() {
println!("hi from an async function");
}hello from async main
hi from an async function
It runs. The #[tokio::main] attribute is doing something specific and worth understanding: it transforms your async fn main into an ordinary fn main that starts a Tokio runtime and uses it to drive your async body to completion. In other words, it generates the non-async "block on this future" wrapper that the last lesson said had to exist somewhere. Now your main is async, so .await works inside it, and the async chain finally has a place to bottom out. (Attributes like this are produced by procedural macros, which chapter 24 covers; for now, read #[tokio::main] as "set up the runtime and run my async main.")
Tasks: tokio::spawn
Inside a runtime, you create concurrent tasks with tokio::spawn, the async cousin of thread::spawn (chapter 22). It takes a future and runs it as an independent task, returning a handle you can .await for its result:
#[tokio::main]
async fn main() {
let handle = tokio::spawn(async {
println!("task running");
10 + 5
});
let result = handle.await.unwrap();
println!("task returned {result}");
}task running
task returned 15
tokio::spawn(async { ... }) takes an async block (a future written inline, the async equivalent of a closure body) and schedules it as a task. The runtime runs it concurrently with the rest of main; handle.await waits for it and yields its value (it's a Result, since the task could panic, so we unwrap). Spawning many tasks is how a server handles many connections: one lightweight task each, the runtime juggling them all.
Async sleep, and the cardinal rule
Here's where async earns its keep, and where the first footgun lives. To wait without blocking, you use the runtime's async sleep, tokio::time::sleep, awaited, not the std::thread::sleep from chapter 22:
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
let a = tokio::spawn(async {
sleep(Duration::from_millis(100)).await;
println!("task A done");
});
let b = tokio::spawn(async {
sleep(Duration::from_millis(100)).await;
println!("task B done");
});
a.await.unwrap();
b.await.unwrap();
println!("both done");
}task A done
task B done
both done
Both tasks sleep 100 milliseconds, yet the whole program finishes in about 100 milliseconds, not 200. That's the payoff: when task A hits sleep(...).await, it doesn't block the thread, it steps aside, the runtime runs task B, which also yields at its sleep, and both waits happen concurrently on the same thread. Two 100ms waits overlap into one. Scale that to ten thousand network reads and you see why async runs servers.
The crucial detail is sleep(...).await, the async sleep. If you had used std::thread::sleep inside a task instead, it would have blocked the whole thread, freezing every other task scheduled on it, and the two sleeps would have run back to back for 200ms total. That mistake, calling a blocking operation inside async, is the number-one async bug, and the next lesson dissects it. For now, the rule: inside async code, use the runtime's async versions of waiting operations (tokio::time::sleep, Tokio's async file and network IO), never the blocking standard-library ones.
Async and blocking operations don't mix
The defining mistake in async Rust is calling a blocking operation, std::thread::sleep, a synchronous file read, a CPU-heavy loop, inside an async task. Async works by tasks voluntarily yielding the thread at .await points; a blocking call never yields, so it freezes the thread and stalls every other task the runtime had scheduled there. One blocking call can bring a whole server to a crawl. The fix is to use the async equivalent (and await it), or, for unavoidable blocking/CPU work, hand it to a dedicated facility (tokio::task::spawn_blocking). Lesson 23.4 treats this in full; internalize the rule now.
A small concurrent fetcher, in shape
Real async code mostly waits on the network. We can't make live network calls here, but the shape of a concurrent fetcher is exactly the sleep example with the sleeps standing in for requests: spawn a task per URL, each awaiting its (slow, network-bound) fetch, then await all the handles. Because the requests are IO-bound waits, they overlap, ten fetches that each take 200ms finish in about 200ms total, not two seconds. That structure, spawn-many-then-await-all, over genuinely network-bound work, is what async was built for, and it's the engine inside the web server you'll build in chapter 26 (built there on threads, to keep that capstone runtime-free, but the async version is a natural variation).
Quiz time
Question #1
What does the #[tokio::main] attribute do?
Show solution
It transforms your async fn main into an ordinary fn main that starts a Tokio runtime and drives the async body to completion. It supplies the non-async "run this future" wrapper that async code requires (since main can't normally be async or .await). With it, your main is effectively async, so .await works inside it, and the async call chain has somewhere to bottom out.
Question #2
In the two-tasks-each-sleeping-100ms example, why does the program finish in about 100ms rather than 200ms?
Show solution
Because the sleeps are async (tokio::time::sleep(...).await): when a task awaits the sleep, it yields the thread instead of blocking it, so the runtime runs the other task, which also yields at its sleep. The two 100ms waits happen concurrently (overlapping) on the same thread rather than one after the other. That non-blocking overlap of waits is exactly what async provides.
Question #3
Why must you use tokio::time::sleep instead of std::thread::sleep inside an async task?
Show solution
std::thread::sleep blocks the thread: it doesn't yield, so it freezes every other task the runtime scheduled on that thread, destroying the concurrency async is supposed to provide. tokio::time::sleep(...).await is non-blocking, it yields the thread while waiting, letting other tasks run. The general rule: inside async code, use the runtime's async versions of waiting operations and .await them, never the blocking standard-library equivalents.
Tokio makes async code run, and you've seen both its payoff (overlapping waits) and its sharpest edge (blocking calls). The next lesson (23.4) is the honest field guide promised since the chapter opened: the specific ways async bites, blocking the executor, Send troubles, cancellation, and how to step around each.