Async/Await and Tokio
Understanding non-blocking I/O in Actix.
Learning Objectives
- Understand Futures
- Learn how Tokio powers Actix
##Doing many things at once with async
A web server spends most of its time waiting: for the network, for a database, for a file. Blocking a whole thread during that wait would waste resources. Asynchronous Rust lets a few threads juggle thousands of waiting requests.
#async fn and await
An async fn does not run when you call it. It returns a future, which is a description of work that will complete later. You run it by awaiting it:
async fn fetch_name() -> String {
"Ada".to_string()
}
async fn greet() {
let name = fetch_name().await; // pause here, let others run
println!("Hello, {name}");
}.await means "pause this task until the result is ready, and let the runtime run something else meanwhile". Futures in Rust are lazy: if you never await one, it never runs.
#The runtime
Futures need a runtime to drive them. Actix Web uses Tokio. The attribute #[actix_web::main] starts that runtime for you, which is why async fn main works in your server.
#[actix_web::main]
async fn main() {
greet().await;
}#Why handlers are async
Every Actix handler is an async fn. While one request awaits a database query, the same worker thread serves other requests. That is how a handful of threads handle huge traffic.
#Running things concurrently
Awaiting two futures one after the other takes the sum of their times. Use tokio::join! to run them together:
use tokio::time::{sleep, Duration};
async fn task(name: &str, ms: u64) {
sleep(Duration::from_millis(ms)).await;
println!("{name} done");
}
#[actix_web::main]
async fn main() {
tokio::join!(task("a", 300), task("b", 300));
// total: about 300ms, not 600ms
}#Never block the runtime
Calling a blocking function (heavy computation, std::thread::sleep, synchronous file or network calls) inside an async handler freezes that worker so it cannot serve other requests. Use async equivalents such as tokio::time::sleep, or move blocking work to a thread pool with web::block:
let result = web::block(move || expensive_hash(&password)).await?;#Rule of thumb
If it waits on I/O, use async. If it burns CPU for a long time, send it to web::block.
"What If?" Experiment
Learning by doing. Modify the code to see what happens.
Compare sequential and concurrent awaiting.
Replace the two awaits with tokio::join!(work(200), work(200)).
The elapsed time drops from about 400ms to about 200ms.
Explanation: Sequential awaits wait for each other; join! lets both wait at the same time.
Common Mistakes
Anticipate and avoid these frequent pitfalls when working with this concept.
Blocking inside a handler
std::thread::sleep blocks the whole worker thread, not just this request.
Explanation: The async sleep yields to the runtime, so other requests keep being served while this one waits.
Calling an async function without await
The future is created but never polled, so nothing happens.
Explanation: Rust warns about unused futures. Always await a future or hand it to the runtime.
Practical Exercise
Run two tasks concurrently
TaskUse tokio::join! so that two simulated 200ms tasks finish in about 200ms total.
Requirements
- Write an async fn that sleeps for a given number of milliseconds
- Run two of them with tokio::join!
- Print the total elapsed time
Need a hint?
Try solving the exercise in your local environment first. Once you have a solution, or if you get completely stuck, reveal the answer.
Real-World Application
A typical endpoint loads a user and their orders from the database. Running both queries with join! halves the latency, and async I/O lets one server handle tens of thousands of simultaneous connections.
Key Takeaways
- An async fn returns a lazy future that only runs when awaited.
- #[actix_web::main] starts the Tokio-based runtime.
- tokio::join! runs independent futures concurrently.
- Never block inside async code; use async APIs or web::block.
Finished reading?
Mark this lesson as done to track your progress and earn XP.