Introduction
Tasks are the async equivalent of threads — lightweight, concurrent units of work. Tokio provides several spawn variants for different scenarios: spawn, spawn_local, spawn_blocking, and JoinSet for managing groups of tasks.
Key Concepts
tokio::spawn — runs a future concurrently. Requires 'static + Send:
rustlet handle = tokio::spawn(async { expensive_io().await }); let result = handle.await.unwrap();
spawn_local — for !Send futures (e.g., Rc, Cell):
rustlet local = tokio::task::LocalSet::new(); local.run_until(async { tokio::task::spawn_local(async { let rc = std::rc::Rc::new(42); println!("{rc}"); }).await.unwrap(); }).await;
spawn_blocking — offloads CPU-heavy or blocking sync work:
rustlet result = tokio::task::spawn_blocking(|| { heavy_computation() // Runs on dedicated thread pool }).await?;
Real World Context
Web servers spawn a task per request. Background jobs use spawn. Image processing or crypto use spawn_blocking. JoinSet manages fan-out patterns.
Deep Dive
Why 'static + Send? Spawned tasks may outlive the spawning scope and may run on any executor thread. You must move ownership into the closure:
rustlet data = String::from("hello"); tokio::spawn(async move { println!("{data}"); // data moved in }); // data no longer accessible here
JoinSet manages dynamic collections of tasks:
rustuse tokio::task::JoinSet; let mut set = JoinSet::new(); for i in 0..10 { set.spawn(async move { i * 2 }); } while let Some(result) = set.join_next().await { println!("Got: {:?}", result.unwrap()); }
JoinHandle returns Result<T, JoinError> — the error case handles panics.
Common Pitfalls
- Borrowing local variables into spawned tasks — must
moveorArc - Ignoring JoinHandle — task panics go unnoticed
- Using
spawn_blockingfor async I/O — wastes a thread
Best Practices
- Always handle or log JoinHandle errors
- Use
JoinSetover manualVec<JoinHandle>for dynamic task groups - Reserve
spawn_blockingstrictly for blocking/CPU work
Summary
spawn for concurrent async tasks (Send + 'static), spawn_local for !Send, spawn_blocking for CPU/blocking work. JoinSet manages task groups. Always move data into spawned tasks.
Code Examples
use tokio::task::JoinSet;
#[tokio::main]
async fn main() {
let mut set = JoinSet::new();
for i in 0..5 {
set.spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
i * 2
});
}
while let Some(result) = set.join_next().await {
println!("Got: {:?}", result.unwrap());
}
}