Lesson 6 of 44
Spawning Tasks
A task is a unit of work that the Tokio runtime schedules on its own. tokio::spawn takes a future, turns it into a task, and starts it running in the background. This lesson covers how to start tasks, collect their results, handle their failures, and satisfy the two trait bounds that spawn places on every future.
| Item | What it does |
|---|---|
tokio::spawn(future) |
creates a task and returns a JoinHandle |
JoinHandle<T> |
a future that resolves to Result<T, JoinError> when the task ends |
JoinError |
says whether the task panicked or was cancelled |
'static and Send bounds |
what a spawned future may own and hold across .await |
task::yield_now |
lets other tasks run before the current one continues |
task::id() |
the id of the running task |
task_local! |
storage that belongs to one task |
Most examples run on the current-thread runtime (#[tokio::main(flavor = "current_thread")], explained in Your First Tokio Program). It runs every task on one thread, one at a time, so the order in which tasks run follows from the code. Examples that do not depend on that use the default multi-thread runtime.
Example 1: Spawn a task and wait for its result
tokio::spawn returns a JoinHandle. Awaiting the handle waits for the task and gives back its result.
#[tokio::main]
async fn main() {
let handle = tokio::spawn(async {
let total: u32 = (1..=10).sum();
total
});
let result = handle.await;
println!("{:?}", result);
}Ok(55)The task returned 55. The handle does not give back 55 directly. It gives Ok(55), because a task can also end without producing a value (Example 5).
Example 2: A spawned task starts in the background
The runtime never runs a spawned future inside the spawn call. The task is queued, and it runs when the runtime gets to it. The spawn documentation states: "It is guaranteed that spawn will not synchronously poll the task being spawned."
#[tokio::main(flavor = "current_thread")]
async fn main() {
let handle = tokio::spawn(async {
println!("task: running");
"task result"
});
println!("main: spawned, task has not run yet");
let result = handle.await.unwrap();
println!("main: got {result:?}");
}main: spawned, task has not run yet
task: running
main: got "task result"The current-thread runtime has one thread. It cannot start the task until main reaches an .await and hands the thread back. Awaiting handle is that point.
Example 3: A future is lazy, a task is not
A future does nothing until something polls it. A spawned task starts on its own, even if nobody awaits its handle. Futures and async/await explains laziness; this example shows the difference in practice.
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use tokio::task;
async fn bump(counter: Arc<AtomicU32>) {
counter.fetch_add(1, Ordering::SeqCst);
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let counter = Arc::new(AtomicU32::new(0));
// A future that is created and never awaited: it never runs.
let _lazy = bump(counter.clone());
task::yield_now().await;
println!("after creating a future: {}", counter.load(Ordering::SeqCst));
// A spawned task runs even though its handle is never awaited.
let _handle = tokio::spawn(bump(counter.clone()));
task::yield_now().await;
println!("after spawning a task: {}", counter.load(Ordering::SeqCst));
}after creating a future: 0
after spawning a task: 1task::yield_now().await gives the runtime a chance to run other tasks; Example 12 explains it. The future _lazy stays unpolled, so the counter does not change. The spawned task runs during the yield and increments the counter.
Example 4: Tasks that return Result
A task that can fail returns a Result. Awaiting its handle then produces a Result inside a Result: the outer one reports whether the task finished (JoinError if not), the inner one is the task's own result. Two ? operators unwrap both.
use std::num::ParseIntError;
async fn parse(text: &'static str) -> Result<i32, ParseIntError> {
text.parse::<i32>()
}
#[tokio::main]
async fn main() {
let good = tokio::spawn(parse("42"));
let bad = tokio::spawn(parse("forty-two"));
println!("{:?}", good.await);
println!("{:?}", bad.await);
let value = run().await;
println!("{value:?}");
}
async fn run() -> Result<i32, Box<dyn std::error::Error>> {
let value = tokio::spawn(parse("7")).await??;
Ok(value * 6)
}Ok(Ok(42))
Ok(Err(ParseIntError { kind: InvalidDigit }))
Ok(42)The first ? handles the JoinError, the second handles the ParseIntError. Both convert into Box<dyn Error>.
Example 5: A panic in a task becomes a JoinError
If the task panics, the panic stops at the task boundary. The handle resolves to Err(JoinError), and the code that awaits it keeps running. JoinError::is_panic tells the two failure reasons apart, and into_panic returns the value passed to panic!.
#[tokio::main]
async fn main() {
let handle = tokio::spawn(async {
let numbers: Vec<u32> = Vec::new();
if numbers.is_empty() {
panic!("no numbers to average");
}
numbers.iter().sum::<u32>() / numbers.len() as u32
});
match handle.await {
Ok(value) => println!("value: {value}"),
Err(error) if error.is_panic() => {
let payload = error.into_panic();
let message = payload.downcast_ref::<&str>().copied().unwrap_or("unknown");
println!("task panicked: {message}");
}
Err(error) => println!("task failed: {error}"),
}
println!("main is still running");
}task panicked: no numbers to average
main is still runningThe payload is a Box<dyn Any + Send>. A panic! with a plain string literal stores a &'static str; a panic! with format arguments stores a String. The panic message also goes to standard error through the panic hook, which this page does not show. Cancellation, abort, and Panics covers JoinError in full.
Example 6: The 'static requirement
spawn requires the future to be 'static. A 'static future owns everything it uses: it holds no reference to a local variable that could be gone before the task finishes. The task may outlive the function that spawned it, so the compiler rejects a borrow.
#[tokio::main]
async fn main() {
let name = String::from("tokio");
let handle = tokio::spawn(async {
println!("hello, {name}");
});
handle.await.unwrap();
}error[E0373]: async block may outlive the current function, but it borrows `name`, which is owned by the current function
--> src/main.rs:5:31
|
5 | let handle = tokio::spawn(async {
| ^^^^^ may outlive borrowed value `name`
6 | println!("hello, {name}");
| ---- `name` is borrowed here
|
= note: async blocks are not executed immediately and must either take a reference or ownership of outside variables they use
help: to force the async block to take ownership of `name` (and any other referenced variables), use the `move` keyword
|
5 | let handle = tokio::spawn(async move {
| ++++The async block borrows name. Nothing stops main from returning while the task still runs, so the borrow could dangle. The compiler suggests async move.
Example 7: Satisfy 'static with move
async move moves the variables into the future. The task now owns the String.
#[tokio::main]
async fn main() {
let name = String::from("tokio");
let count = 3;
let handle = tokio::spawn(async move {
println!("hello, {name}, {count} times");
name.len()
});
println!("length: {}", handle.await.unwrap());
}hello, tokio, 3 times
length: 5After the move, main cannot use name. Types that implement Copy, such as count, are copied, so main keeps its own copy. The value returned from the task (name.len()) must also be 'static and Send; a usize is.
Example 8: Share data between tasks with Arc and clones
When several tasks need the same data, each task needs its own owner. Arc provides shared ownership: cloning an Arc increments a reference count and gives another owner of the same value. The clone is made before the move, so each task gets its own Arc.
use std::sync::Arc;
#[tokio::main]
async fn main() {
let words = Arc::new(vec!["alpha", "beta", "gamma"]);
let mut handles = Vec::new();
for index in 0..3 {
let words = Arc::clone(&words);
handles.push(tokio::spawn(async move {
format!("task {index} sees {} words, first is {}", words.len(), words[0])
}));
}
for handle in handles {
println!("{}", handle.await.unwrap());
}
println!("owners left in main: {}", Arc::strong_count(&words));
}task 0 sees 3 words, first is alpha
task 1 sees 3 words, first is alpha
task 2 sees 3 words, first is alpha
owners left in main: 1Each task removes its Arc when it finishes, so only main still owns the vector. Sharing data that changes needs a lock; Shared State: Mutex and RwLock covers that.
Example 9: The Send requirement
spawn also requires the future to be Send. On the multi-thread runtime, a task can pause on one worker thread and resume on another. A type is Send when moving it to another thread is safe. Rc is not Send, because its reference count is not atomic.
The future is a state machine, and every value that lives across an .await is stored inside it. If any of those values is not Send, the whole future is not Send.
use std::rc::Rc;
#[tokio::main]
async fn main() {
let handle = tokio::spawn(async {
let shared = Rc::new(5);
tokio::task::yield_now().await;
println!("{shared}");
});
handle.await.unwrap();
}error: future cannot be sent between threads safely
--> src/main.rs:5:18
|
5 | let handle = tokio::spawn(async {
| __________________^
6 | | let shared = Rc::new(5);
7 | | tokio::task::yield_now().await;
8 | | println!("{shared}");
9 | | });
| |______^ future created by async block is not `Send`
|
= help: within `{async block@src/main.rs:5:31: 5:36}`, the trait `Send` is not implemented for `Rc<i32>`
note: future is not `Send` as this value is used across an await
--> src/main.rs:7:34
|
6 | let shared = Rc::new(5);
| ------ has type `Rc<i32>` which is not `Send`
7 | tokio::task::yield_now().await;
| ^^^^^ await occurs here, with `shared` maybe used later
note: required by a bound in `tokio::spawn`
--> ~/.cargo/registry/src/.../tokio-1.53.1/src/task/spawn.rs:176:21
|
174 | pub fn spawn<F>(future: F) -> JoinHandle<F::Output>
| ----- required by a bound in this function
175 | where
176 | F: Future + Send + 'static,
| ^^^^ required by this bound in `spawn`The Rc is alive at the .await, so it is stored in the future. The compiler reports that the future cannot be sent between threads safely.
Example 10: Fix a Send error
Two fixes exist. Either end the life of the non-Send value before the .await, or use a Send type.
use std::rc::Rc;
use std::sync::Arc;
#[tokio::main]
async fn main() {
// Fix 1: the Rc is dropped at the end of the inner block, before the await.
let first = tokio::spawn(async {
let total = {
let shared = Rc::new(5);
*shared + 1
};
tokio::task::yield_now().await;
total
});
// Fix 2: Arc is Send, so it may live across the await.
let second = tokio::spawn(async {
let shared = Arc::new(5);
tokio::task::yield_now().await;
*shared + 2
});
println!("{} {}", first.await.unwrap(), second.await.unwrap());
}6 7Tasks that need !Send values across .await points can run on a LocalSet with spawn_local, which keeps the task on one thread. Runtime Flavors and the Builder covers it. A std::sync::MutexGuard is also not Send, which is why holding one across .await fails to compile in a spawned task.
Example 11: Spawn many tasks
Tasks are cheap: a task is one heap allocation that holds a header, the future, and space for its output, and spawning it does not create a thread. The following program runs ten thousand tasks and collects every result.
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
#[tokio::main]
async fn main() {
let finished = Arc::new(AtomicUsize::new(0));
let mut handles = Vec::with_capacity(10_000);
for number in 0..10_000u64 {
let finished = Arc::clone(&finished);
handles.push(tokio::spawn(async move {
finished.fetch_add(1, Ordering::SeqCst);
number * 2
}));
}
let mut sum = 0;
for handle in handles {
sum += handle.await.unwrap();
}
println!("tasks finished: {}", finished.load(Ordering::SeqCst));
println!("sum of results: {sum}");
}tasks finished: 10000
sum of results: 99990000The order in which the tasks ran is not fixed, so the program prints only totals, which do not depend on it. The handles are awaited in spawn order, which is why the results could also be kept in a Vec in the same order.
Example 12: yield_now lets other tasks run
Tasks are scheduled cooperatively: a task keeps its thread until it reaches an .await that cannot complete yet. task::yield_now().await is an .await that always gives the thread up. The current task goes to the back of the queue and other ready tasks run first.
This is the example from the Tokio task documentation. The spawned task runs before the code after the yield.
use tokio::task;
#[tokio::main(flavor = "current_thread")]
async fn main() {
task::spawn(async {
println!("spawned task done");
});
task::yield_now().await;
println!("main task done");
}spawned task done
main task doneThe documentation for yield_now adds a limit: the runtime does not promise which task runs next, and it may poll the yielding task again first. Correct code must not depend on the order.
Example 13: Waiting for another task with yield_now
A task that waits for a value set by another task must give that task a chance to run. On a current-thread runtime, a loop that only checks a flag never lets the other task run, so the program would hang. Yielding inside the loop fixes it, and the result is the same in every order.
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::task;
#[tokio::main(flavor = "current_thread")]
async fn main() {
let ready = Arc::new(AtomicBool::new(false));
let setter = {
let ready = Arc::clone(&ready);
tokio::spawn(async move {
ready.store(true, Ordering::SeqCst);
})
};
while !ready.load(Ordering::SeqCst) {
task::yield_now().await;
}
println!("flag observed");
setter.await.unwrap();
}flag observedA flag polled in a loop is a stand-in for the channels, Notify, and locks taught in later lessons, which wake the waiting task instead of polling. Use polling with yield_now only when nothing better exists.
Example 14: Task ids
Every task has an id. task::id() returns the id of the running task, JoinHandle::id() returns it from outside, and task::try_id() returns None instead of panicking when there is no current task. An id is unique among the tasks that are running or still have a handle. The documentation states that ids are not sequential and do not indicate spawn order, so the example compares ids and never prints them.
use tokio::task;
#[tokio::main]
async fn main() {
let first = tokio::spawn(async { task::id() });
let second = tokio::spawn(async { task::id() });
let first_outside = first.id();
let first_inside = first.await.unwrap();
let second_inside = second.await.unwrap();
println!("handle id matches id inside the task: {}", first_outside == first_inside);
println!("two live tasks have different ids: {}", first_inside != second_inside);
println!("no task id in the main future: {}", task::try_id().is_none());
}handle id matches id inside the task: true
two live tasks have different ids: true
no task id in the main future: trueThe future passed to #[tokio::main] runs through block_on, which is not a task, so task::id() would panic there. Anatomy of a Task explains where the id is stored.
Example 15: Task-local storage
A task-local is a value that belongs to one task, the way a thread-local belongs to one thread. task_local! declares the key. scope(value, future) runs a future with the value set, and get() reads it (for a Clone value). Any function called inside the scope can read the value without an extra parameter, and each task sees its own value.
tokio::task_local! {
static REQUEST_ID: u32;
}
async fn handle_request(step: &str) -> String {
format!("request {}: {step}", REQUEST_ID.get())
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let first = tokio::spawn(REQUEST_ID.scope(1, async {
tokio::task::yield_now().await;
handle_request("first task").await
}));
let second = tokio::spawn(REQUEST_ID.scope(2, async {
handle_request("second task").await
}));
println!("{}", first.await.unwrap());
println!("{}", second.await.unwrap());
}request 1: first task
request 2: second taskThe first task yields before it reads its id, so the two tasks overlap in time, and each still reads its own value.
Example 16: A spawned task does not inherit task-locals
A new task does not copy the parent's task-locals. Reading a key that has no value panics with get and returns an AccessError with try_get. To pass the value on, wrap the child future in another scope.
tokio::task_local! {
static USER: &'static str;
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
USER.scope("alice", async {
let without = tokio::spawn(async { USER.try_get().is_ok() }).await.unwrap();
println!("child without scope sees the value: {without}");
let with = tokio::spawn(USER.scope(USER.get(), async { USER.get() })).await.unwrap();
println!("child with its own scope sees: {with}");
})
.await;
}child without scope sees the value: false
child with its own scope sees: aliceThe scope call takes the value by argument, so the parent reads its own value with USER.get() and passes it to the child.
Example 17: Dropping a JoinHandle detaches the task
Dropping a JoinHandle does not stop the task. It detaches the task: the task keeps running and nobody can await its result. The result is dropped when the task ends. The JoinHandle documentation states this.
The example uses a oneshot channel to observe the detached task. A oneshot channel carries one value from a Sender to a Receiver, and awaiting the receiver waits for the value (oneshot, broadcast, and watch covers channels). It also runs on a paused clock: with start_paused = true, sleep completes as soon as every task is idle, without real waiting (Time covers the paused clock). The attribute needs the test-util feature of Tokio (cargo add tokio --features test-util in addition to the features from the introduction).
use std::time::Duration;
use tokio::sync::oneshot;
use tokio::time::sleep;
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let (sender, receiver) = oneshot::channel();
let handle = tokio::spawn(async move {
sleep(Duration::from_millis(50)).await;
println!("detached task: finishing");
sender.send("finished").unwrap();
"value nobody can read"
});
drop(handle);
println!("main: handle dropped");
println!("main: {}", receiver.await.unwrap());
}main: handle dropped
detached task: finishing
main: finishedThe task ran to the end after its handle was gone. Its return value is lost. If the task had panicked, no code would have seen the JoinError; only the panic message on standard error would show it.
Example 18: Tasks end when the runtime ends
Detached tasks do not keep the program alive. When the runtime shuts down, for example when main returns, every task that is still unfinished is dropped. The spawn documentation says there is "no guarantee that a spawned task will execute to completion".
#[tokio::main(flavor = "current_thread")]
async fn main() {
tokio::spawn(async {
println!("this line is never printed");
});
println!("main returns without yielding");
}main returns without yieldingOn the current-thread runtime the spawned task never got a turn, because main never yielded. On the multi-thread runtime another worker could pick the task up before main returns, so the result there would depend on timing. Wait for tasks with their handles when their work must finish.
Tasks, futures, and threads compared
| Future | Task | OS thread | |
|---|---|---|---|
| Created by | calling an async fn or writing an async block |
tokio::spawn(future) |
std::thread::spawn(closure) |
| Starts running | when first polled by an executor or another future | soon after spawn, in the background |
when spawn returns, in parallel |
| Scheduled by | whatever awaits it | the Tokio runtime, cooperatively | the operating system, preemptively |
| Result | the .await expression |
JoinHandle<T> resolves to Result<T, JoinError> |
JoinHandle<T>::join returns Result<T, _> |
| Panic | unwinds into the code that polls it | caught, reported through the handle | caught, reported through join |
| Dropping the handle | dropping the future cancels it | detaches the task, which keeps running | detaches the thread, which keeps running |
| Memory per unit | the size of its state machine | the future plus a small header | a stack (megabytes reserved) |
| Bounds on the closure or future | none of its own | Send + 'static |
Send + 'static |
A task is a future that the runtime owns and drives. Awaiting a future inside a task does not create another task: the awaited future runs as part of the task's own state machine. Only spawn (and related calls such as JoinSet::spawn) adds a task.
Interview points
tokio::spawncreates a task and returns aJoinHandle<T>; the task starts in the background without being awaited, but never inside thespawncall.- Awaiting a handle returns
Result<T, JoinError>.JoinErrormeans the task panicked or was cancelled. - A panic in a task is caught at the task boundary and reported through the handle. It does not stop other tasks or the runtime.
- The future must be
'static(owns all its data) because the task can outlive the spawning scope. Useasync move, clones, andArc. - The future must be
Sendon the multi-thread runtime because a task can resume on another worker. A non-Sendvalue that is alive across an.awaitmakes the future!Send. - Dropping a
JoinHandledetaches the task; it does not cancel it. Dropping a bare future cancels it. - All unfinished tasks are dropped when the runtime shuts down.
- Scheduling is cooperative: a task that does not reach an
.awaitblocks its worker thread.yield_nowhands the thread back voluntarily. - Task-locals are not inherited by child tasks.
Summary
spawnneedsFuture + Send + 'staticand an output that isSend + 'static.handle.awaitgivesResult<T, JoinError>; use??when the task returns aResult.JoinError::is_panicandinto_panicexpose a panic;is_cancelledreports cancellation.- Move owned data with
async move, share withArc, and clone before spawning in a loop. Rcandstd::sync::MutexGuardare notSend; end their scope before an.awaitor use aSendtype.yield_nowsends the current task to the back of the queue; the runtime does not guarantee which task runs next.task::id(),task::try_id(), andJoinHandle::id()identify tasks; the ids are not sequential.task_local!withscope,get, andtry_getstores a value per task; child tasks need their ownscope.- A dropped handle detaches the task; runtime shutdown drops whatever is left.
