22. async/await + tokio intro
async/await is the async programming model for handling many I/O-bound tasks efficiently. Rust ships the syntax but not a runtime — you pick one, and tokio is the de-facto standard. This lesson covers Futures, async fn, .await, the tokio runtime entry point, and join! for concurrent tasks.
What you'll learn
- 1Define async functions with `async fn`
- 2Wait for completion with `.await`
- 3Use `#[tokio::main]` to make main an async entry point
- 4Run several tasks concurrently with `join!` / `try_join!`
- 5Tell concurrency from parallelism
Overview
OS threads are heavy (multi-MB stacks each) — thousands of them sink a system. Async lets one thread host thousands of tasks; tasks waiting on I/O yield the CPU automatically. Web servers, DB clients, and network tools are the typical use cases.
Core Concepts
1) Future — a value that isn't ready yet
Every `async fn` returns `impl Future<Output = T>`. The runtime polls it and gets Pending (not yet) or Ready(T) (done).
2) .await — wait for completion
Adding `.await` to a Future **suspends** the current task until the value is ready, freeing the runtime to drive other tasks. This is what makes async code look almost like sync.
3) Pick a runtime — tokio
Rust's async is just syntax; the actual execution engine lives outside the standard library. **tokio** is the practical standard — multi-threaded worker pool + I/O polling.
4) Concurrency vs. parallelism
| Term | Meaning |
|---|---|
| Concurrency | Many tasks alternating (works on one thread too) |
| Parallelism | Many tasks physically simultaneous (multiple cores) |
Async is concurrency-first. tokio's multi-thread runtime adds parallelism on top.
Hands-on Examples
Cargo.toml:
[dependencies]
tokio = { version = "1", features = ["full"] }use tokio::time::{sleep, Duration};
async fn say_after(ms: u64, msg: &str) {
sleep(Duration::from_millis(ms)).await;
println!("{}", msg);
}
#[tokio::main]
async fn main() {
say_after(100, "hello").await;
say_after(50, "world").await;
// Output: hello → world (sequential await)
}Concurrent progress with join!:
use tokio::time::{sleep, Duration};
async fn task(name: &str, ms: u64) {
sleep(Duration::from_millis(ms)).await;
println!("{} done after {}ms", name, ms);
}
#[tokio::main]
async fn main() {
tokio::join!(
task("A", 100),
task("B", 50),
task("C", 80),
);
// Order: B (50ms) → C (80ms) → A (100ms); whole thing in ~100ms
}Async + Result + `?`:
use std::error::Error;
use tokio::fs;
async fn read_file(path: &str) -> Result<String, Box<dyn Error>> {
let s = fs::read_to_string(path).await?;
Ok(s)
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let s = read_file("data.txt").await?;
println!("{} bytes", s.len());
Ok(())
}Common Mistakes
Q. Calling an async fn does nothing — why?
A. Calling it just **creates a Future**; nothing runs. Either `.await` it or hand it to `tokio::spawn` to register with the runtime.
Q. tokio or async-std?
A. **tokio** is effectively standard. The biggest ecosystem (axum / reqwest / sqlx / ...) is built on it.
Q. Can I `std::thread::sleep` inside async?
A. **No.** That blocks the entire thread and stalls every other task on it. Use `tokio::time::sleep(...).await`.
Q. Can I hold a non-Send type across an await?
A. On tokio's multi-threaded runtime any value alive across an await must be Send. If not, the compiler will tell you.
Recap
- async fn returns `impl Future<Output = T>`
- .await suspends, freeing the runtime to drive others
- tokio is the de-facto runtime — enter via #[tokio::main]
- join! / try_join! run multiple Futures concurrently
- Concurrency ≠ parallelism; async wins big on I/O bound work
Try It Yourself
- Three tasks sleeping 100ms / 50ms / 200ms — wrap them in join! and verify total wall-clock is ~200ms
- Read two files concurrently with tokio::fs::read_to_string and sum the lengths
- Add reqwest to Cargo.toml and write an async fn that GETs a URL, propagating errors with `?`
All lecture materials and example code are openly available on GitHub.
View on GitHub ↗