Async/Await with Tokio
Concurrency in Practice covered OS threads: each one is a real, preemptively-scheduled unit of execution, and the right tool when work is CPU-bound. Async is a different answer to a different problem: thousands of tasks that spend almost all their time waiting (on a socket, a database, a timer) rather than computing. Spawning a thread per connection doesn't scale to that, each thread costs real memory and a context switch; async tasks are cheap enough to spawn by the thousands because they're scheduled cooperatively on a small pool of threads.
Rust's std only defines the Future trait and the async/.await syntax, it ships no executor to actually run them. Tokio is the runtime almost all production async Rust uses. This tutorial assumes Tokio is already a dependency and focuses on the patterns you'll actually write.
async fn Is Lazy
An async fn doesn't run when you call it. It immediately returns a Future, a value representing the eventual result, and nothing happens until something polls that future to completion.
async fn fetch_user(id: u64) -> User {
// ...
}
let future = fetch_user(42); // nothing has run yet, this is just a value
let user = future.await; // now it actually executes
.await is the thing that drives a future forward. Forgetting it is a common mistake the compiler catches with a warning (unused future that does nothing), but it's worth internalizing: an async fn call with no .await is a no-op.
The Runtime: Why You Need Tokio
Because std has no executor, every async program needs one to actually poll futures. #[tokio::main] is sugar that wraps your main in a runtime and blocks on it:
#[tokio::main]
async fn main() {
let user = fetch_user(42).await;
println!("{user:?}");
}
This expands to roughly:
fn main() {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let user = fetch_user(42).await;
println!("{user:?}");
});
}
block_on is the bridge between sync and async code: it's the one place a thread sits and drives a future to completion. Everything inside that future tree runs cooperatively on the runtime's worker threads.
tokio::spawn: Concurrent Tasks
.await alone is sequential, each await finishes before the next line runs. tokio::spawn is the async equivalent of thread::spawn: it schedules a future to run concurrently with everything else on the runtime.
let handle = tokio::spawn(async {
fetch_user(42).await
});
let other_work = do_something_else().await;
let user = handle.await.unwrap(); // await the JoinHandle to get the result
Like thread::spawn, the spawned future must be 'static and Send, it may run on a different worker thread than the one that spawned it. This is exactly the Send/Sync requirement from regular threads, just enforced on the future's captured state instead of a closure's.
For running a fixed, known set of futures concurrently and waiting for all of them, tokio::join! is usually cleaner than spawning each one individually:
let (user, posts) = tokio::join!(
fetch_user(42),
fetch_posts(42),
);
join! polls both futures on the current task, interleaving them, it doesn't create new tasks the way spawn does. Use join! when the futures are short-lived and tied to the current task's lifetime; use spawn when you want a future to keep running independently, with its own JoinHandle you can await later or drop.
select!: Racing Futures
tokio::select! runs several futures concurrently and proceeds as soon as the first one completes, cancelling the rest.
use tokio::time::{sleep, Duration};
tokio::select! {
result = fetch_user(42) => {
println!("got user: {result:?}");
}
_ = sleep(Duration::from_secs(5)) => {
println!("timed out");
}
}
This is the standard pattern for timeouts (race the real work against a sleep), and for shutdown signals (race a long-running loop against a "stop" channel). The branches that don't win are dropped, which cancels their work, so don't put anything in a losing branch that needs to run to completion to stay correct (e.g. a write that's only half done).
Async Channels and Mutexes Are Different Types
std::sync::mpsc and std::sync::Mutex block the OS thread when they have nothing to do. Blocking an OS thread inside an async task stalls every other task scheduled on that thread, the executor has no way to run something else while a thread is parked in a blocking syscall. Tokio ships async-aware equivalents that yield control back to the runtime instead:
use tokio::sync::{mpsc, Mutex};
let (tx, mut rx) = mpsc::channel(32);
tx.send(42).await.unwrap(); // .await, not a blocking call
let value = rx.recv().await.unwrap();
let lock = Mutex::new(0);
let mut guard = lock.lock().await; // also .await
*guard += 1;
Holding a
tokio::sync::Mutexguard across an.awaitis fine; holding astd::sync::Mutexguard across one is a bug. Astdguard held across an await point can be held by a task that's been suspended and moved to another thread, which can deadlock or violate the lock's assumptions. If you only ever lock, mutate, and release without an.awaitin between,std::sync::Mutexis fine and cheaper. The moment an await point falls inside the locked section, switch totokio::sync::Mutex.
The Cardinal Sin: Blocking Inside Async
Tokio runs many tasks on a handful of OS threads. Any call that blocks the thread, std::thread::sleep, a synchronous file read, a CPU-heavy loop, a blocking database driver, freezes every other task sharing that thread until it returns.
// WRONG: blocks the worker thread, stalling other tasks
async fn bad() {
std::thread::sleep(Duration::from_secs(1));
}
// RIGHT: yields control back to the runtime
async fn good() {
tokio::time::sleep(Duration::from_secs(1)).await;
}
For work that's unavoidably blocking or CPU-bound (a heavy computation, a synchronous library with no async API), hand it to a dedicated thread pool instead of running it on a worker thread:
let result = tokio::task::spawn_blocking(|| {
expensive_sync_computation()
}).await.unwrap();
spawn_blocking runs the closure on a separate thread pool reserved for exactly this, the async worker threads stay free to keep polling other tasks while it runs.
Key Takeaways
async fnreturns aFuturethat does nothing until.awaited. Calling it without awaiting is a silent no-op.stddefines theFuturetrait but ships no executor. Tokio's runtime is what actually polls futures;#[tokio::main]wraps yourmaininRuntime::block_on.tokio::spawnruns a future concurrently as an independent task (likethread::spawn);tokio::join!runs futures concurrently within the current task and waits for all of them;tokio::select!races futures and cancels the losers.- Spawned futures need
'static + Send, the same requirement as spawned threads, for the same reason: they may run on another worker thread. - Use
tokio::sync::{mpsc, Mutex}over thestdversions whenever an.awaitcan happen while the channel/lock is in use, the async versions yield instead of blocking the worker thread. - Never call a blocking function inside an async task. Use
tokio::time::sleepinstead ofthread::sleep, andspawn_blockingfor CPU-heavy or synchronous-only work.