Tasks & JoinHandles
Tokio tasks are lightweight units of async work scheduled on the runtime. JoinHandle lets you await results, abort execution, and track task completion.
Recipe
Quick-reference recipe card - copy-paste ready.
#[tokio::main]
async fn main() {
let handle = tokio::spawn(async { 42 });
let answer = handle.await.unwrap();
println!("{answer}");
}When to reach for this: Background work, per-connection handlers, parallel I/O, and structured task groups.
Working Example
use tokio::task::JoinSet;
async fn fetch_id(id: u32) -> u32 {
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
id * 10
}
#[tokio::main]
async fn main() {
let mut set = JoinSet::new();
for id in 1..=5 {
set.spawn(fetch_id(id));
}
while let Some(res) = set.join_next().await {
println!("{}", res.unwrap());
}
}What this demonstrates:
JoinSetmanages dynamic spawned tasks.join_nextyields results as tasks complete.- Set is empty when all tasks finish.
Deep Dive
How It Works
- Task: Boxed future scheduled on Tokio executor.
- JoinHandle: Owned handle;
awaitreturnsResult<T, JoinError>. - abort: Cancels task at next await point.
- JoinSet / JoinMap: Collections for many handles without manual
Vec.
Spawn Variants
| API | Use |
|---|---|
tokio::spawn | Send tasks on runtime |
spawn_blocking | Blocking closure on blocking pool |
spawn_local | !Send on LocalSet |
task::Builder | Name tasks for tracing |
Rust Notes
// Named task for tokio-console / tracing
let handle = tokio::task::Builder::new()
.name("worker")
.spawn(async { /* ... */ })
.unwrap();- Always await or abort handles to avoid leaked tasks.
JoinErrorisis_panic()oris_cancelled().- Parent dropping without awaiting does not cancel child unless configured.
Gotchas
- Fire-and-forget spawn - Errors silently lost. Fix: Await handle, use
JoinSet, or attachtracingerror handler. - Non-Send future in spawn - Compile error on multi-thread runtime. Fix:
spawn_local+LocalSetor removeRc/mutex guards across await. - Blocking inside spawned task - Starves workers. Fix:
spawn_blockingfor sync sections. - Unbounded JoinSet growth - Spawning faster than completing. Fix: Semaphore or bounded worker pool.
- Panic in task -
awaitreturnsErr(JoinError). Fix: Match error; considercatch_unwindfor isolation.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
tokio::join! | Few futures on same task | Long-lived background workers |
FuturesUnordered | Not spawned; same task | Need true parallel OS scheduling |
| OS threads | CPU isolation, blocking APIs | Thousands of lightweight I/O tasks |
| Structured scopes (nightly patterns) | Strict parent-child cancel | Simple one-shot spawn |
FAQs
Task vs OS thread?
Tasks are cooperatively scheduled on few threads - cheaper, but must not block.
JoinHandle without await?
Task still runs but errors are lost - anti-pattern in production.
abort vs drop?
abort cancels spawned task; dropping handle does not cancel the task.
JoinSet vs Vec of handles?
JoinSet drives completion efficiently; Vec + manual join works for small fixed counts.
Task budget?
Tokio may yield periodically - long CPU loops need yield_now().await.
LocalSet?
Runs !Send tasks on one thread - tests and single-threaded embed scenarios.
Axum per-request tasks?
Handlers are tasks; spawn subtasks for background work that outlives response carefully.
tracing integration?
#[tracing::instrument] on async fns + named tasks improves observability.
JoinError handling?
Distinguish panic vs cancel - cancel is expected on shutdown.
Max concurrent tasks?
No hard limit - bounded by memory and FDs; use semaphores for backpressure.
Related
- Tokio Basics - first spawn
- Combining Futures - join macros
- Cancellation & Timeouts - abort
- Graceful Shutdown - draining tasks
- Tracing & Instrumentation - named tasks
Stack versions: This page was written for Rust 1.97.0 (edition 2024), Tokio 1.x, Axum 0.8, serde 1.0, sqlx 0.8, clap 4, and Polars 0.46+.