Async Rust with Tokio in 2022
TL;DR —
async fncompiles to a state machine implementing theFuturetrait. Tokio is the runtime that polls those futures. Usetokio::spawnfor concurrent tasks,tokio::select!to race futures, andtokio::syncprimitives for shared state. Don’t block in async functions.
After error handling and workspaces, the next conceptual step for backend Rust is async. It’s where the language gets meaningfully different from Go — Go’s “everything is a goroutine” model versus Rust’s “futures + a runtime you pick.”
Tokio is the default runtime in 2022. Nearly every backend Rust library targets it (axum, sqlx, reqwest, tonic). This post is what I needed to understand to stop being scared of async Rust.
What async/await compiles to
An async fn doesn’t return a value. It returns a Future — a struct that, when polled, will eventually produce a value (or pend).
async fn fetch(url: &str) -> Result<String, reqwest::Error> {
let body = reqwest::get(url).await?.text().await?;
Ok(body)
}
The compiler rewrites this into a state machine. Each .await is a suspension point. When poll() is called and the inner future isn’t ready, the state machine returns Pending; when it’s ready, it advances to the next state.
You don’t write the state machine. The compiler does. But knowing it exists explains why:
async fndoesn’t run until something polls it (futures are lazy)async fnrequires.await— without it, you have a future you’ve ignored, not a result- Each
.awaitis a yield point; the runtime can switch tasks there
In Go, every function is implicitly preemptible. In Rust, only .await points are preemption points. This matters for blocking calls — see below.
Picking a runtime
Tokio is what everyone uses. There are alternatives (async-std, smol), but in 2022 the ecosystem effectively requires Tokio compatibility. Unless you have a specific reason, pick Tokio.
Within Tokio, two flavors:
- Multi-threaded scheduler (
rt-multi-thread): default for backend services. Spreads tasks across worker threads. - Current-thread scheduler (
rt): single-threaded. For embedded, CLI, or specific use cases.
For an HTTP server, you want multi-threaded:
tokio = { version = "1.17", features = ["macros", "rt-multi-thread", "signal", "net", "time"] }
Then:
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// server code
Ok(())
}
#[tokio::main] is a proc macro that sets up the runtime. Behind it, equivalent to:
fn main() -> anyhow::Result<()> {
tokio::runtime::Runtime::new()?.block_on(async {
// server code
Ok(())
})
}
Sometimes you want the explicit form (to configure the runtime). Usually the macro is fine.
Tasks: spawn for concurrency
To run things concurrently, spawn them as tasks:
async fn process_orders() {
let h1 = tokio::spawn(send_emails());
let h2 = tokio::spawn(update_inventory());
let h3 = tokio::spawn(notify_warehouse());
// wait for all
let _ = tokio::join!(h1, h2, h3);
}
tokio::spawn schedules a future on the runtime. It returns a JoinHandle<T> that resolves to the future’s output when awaited. Tasks run concurrently, possibly on different threads.
Two gotchas:
- The future passed to
spawnmust be'static— it can’t borrow from the parent scope. Pass owned values, or useArcfor shared state. - The future must be
Sendif the runtime is multi-threaded. Most things are; non-Sendthings (likeRc) need to use the current-thread runtime or get wrapped.
For “wait for any one of N futures to finish”:
tokio::select! {
result = fetch_a() => println!("a finished first: {:?}", result),
result = fetch_b() => println!("b finished first: {:?}", result),
}
select! polls multiple futures concurrently and runs the branch for whichever finishes first. The others are dropped (and their state cleaned up). Useful for timeouts, cancellation, and racing IO.
Don’t block in async functions
A single blocking call in an async function stalls the whole worker thread. On a multi-threaded runtime that means one worker is dead until the blocking call returns; on a current-thread runtime, the whole runtime is dead.
The two worst offenders:
std::thread::sleep— blocks the OS thread. Usetokio::time::sleep.std::fs::*— blocking IO. Usetokio::fs::*.
For genuinely CPU-bound work or unavoidable blocking calls, use spawn_blocking:
let result = tokio::task::spawn_blocking(|| {
// some heavy synchronous work, e.g., bcrypt
bcrypt::hash("password", 12)
}).await?;
spawn_blocking runs the closure on Tokio’s blocking-thread pool (default 512 threads), separate from the async worker pool. The async runtime keeps working.
The rule: an async fn should never block for more than a few microseconds without yielding. If it does, you’re doing it wrong; spawn_blocking is the escape hatch.
Shared state: Mutex, RwLock, Arc
Go has channels and sync.Mutex. Rust async has Arc<Mutex<T>> for shared mutable state across tasks.
use std::sync::Arc;
use tokio::sync::Mutex;
let counter = Arc::new(Mutex::new(0u64));
for _ in 0..10 {
let counter = Arc::clone(&counter);
tokio::spawn(async move {
let mut n = counter.lock().await;
*n += 1;
});
}
Two notes:
- Use
tokio::sync::Mutex, notstd::sync::Mutex, for state held across.awaitpoints.std::sync::Mutexwill deadlock if held during an await;tokio::sync::Mutexwon’t (it’s async-aware). Arc<Mutex<T>>is the Go-equivalent ofmu sync.Mutex; sharedState. Same caveats: keep critical sections short, never call out into other locks from within.
For “channels,” Tokio has several:
mpsc— multi-producer, single-consumer. The Go-channel equivalent.oneshot— single send, single receive. For request-reply patterns.broadcast— multi-producer, multi-consumer, all receivers see all messages.watch— for config / state distribution where new receivers see the latest value.
Cancellation
Futures in Rust are cancel-safe by default in a way Go isn’t. Dropping a future stops it. You don’t have to plumb a context.Context everywhere — dropping the JoinHandle cancels the task.
let handle = tokio::spawn(long_running_task());
tokio::time::sleep(Duration::from_secs(5)).await;
handle.abort(); // task cancelled
That’s it. The task’s drop runs (closing connections, releasing resources). Cancellation is structurally simpler than Go’s context-passing model.
Caveat: cancellation happens at .await points. If your future is doing synchronous CPU work between awaits, abort doesn’t stop it mid-loop. Add tokio::task::yield_now().await in long loops.
A tiny working example
End-to-end. Spawn three concurrent HTTP fetches, race for the first to complete, ignore the others:
use anyhow::Result;
use std::time::Duration;
use tokio::time::timeout;
#[tokio::main]
async fn main() -> Result<()> {
let urls = vec![
"https://api.example.com/a",
"https://api.example.com/b",
"https://api.example.com/c",
];
let result = timeout(Duration::from_secs(2), async {
let (tx, mut rx) = tokio::sync::mpsc::channel(urls.len());
for url in urls {
let tx = tx.clone();
tokio::spawn(async move {
if let Ok(resp) = reqwest::get(url).await {
if let Ok(body) = resp.text().await {
let _ = tx.send(body).await;
}
}
});
}
drop(tx);
rx.recv().await
}).await;
match result {
Ok(Some(body)) => println!("first response ({} bytes)", body.len()),
Ok(None) => println!("all fetches failed"),
Err(_) => println!("timed out"),
}
Ok(())
}
Three spawned tasks, channel for first-wins coordination, top-level timeout. Cleaner than the equivalent in any other language I know.
Common Pitfalls
Calling .lock() on std::sync::Mutex and holding across .await. Compile error (Rust 1.59+ has improved diagnostics here). Use tokio::sync::Mutex instead.
Forgetting .await. let fut = fetch(); creates a future but doesn’t run it. Lints catch most cases; not all.
Spawning futures that borrow from local scope. Compile error: future must be 'static. Move values into the future with async move { ... }.
Using block_on inside an already-running runtime. Panics. If you need sync code to call into async code, structure the call site differently or use tokio::task::block_in_place.
Treating Tokio’s blocking pool as unlimited. Default 512 threads; spawn-blocking thousands at once and you’ll exhaust it. For genuinely high-volume CPU work, use a Rayon thread pool sized to CPU count.
Choosing futures by their first .await. A future does nothing before it’s polled. tokio::spawn(some_future) starts it; some_future.await runs it inline. Be deliberate.
Wrapping Up
Async Rust + Tokio is a more explicit model than Go’s goroutines, but the control over scheduling, cancellation, and shared state is genuinely worth the learning curve. Once #[tokio::main], tokio::spawn, tokio::select!, and Arc<Mutex<T>> are reflex, you can write any concurrent backend pattern. Wednesday: building a JSON API with Axum — putting Tokio to work for real.