Guide contents

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.

Rust
#[tokio::main]
async fn main() {
    let handle = tokio::spawn(async {
        let total: u32 = (1..=10).sum();
        total
    });

    let result = handle.await;
    println!("{:?}", result);
}
Output
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."

Rust
#[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:?}");
}
Output
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.

Rust
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));
}
Output
after creating a future: 0
after spawning a task: 1

task::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.

Rust
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)
}
Output
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!.

Rust
#[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");
}
Output
task panicked: no numbers to average
main is still running

The 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.

Rust✗ does not compile · E0373
#[tokio::main]
async fn main() {
    let name = String::from("tokio");

    let handle = tokio::spawn(async {
        println!("hello, {name}");
    });

    handle.await.unwrap();
}
Output
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.

Rust
#[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());
}
Output
hello, tokio, 3 times
length: 5

After 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.

Rust
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));
}
Output
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: 1

Each 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.

Rust✗ does not compile
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();
}
Output
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.

Rust
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());
}
Output
6 7

Tasks 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.

Rust
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}");
}
Output
tasks finished: 10000
sum of results: 99990000

The 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.

Rust
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");
}
Output
spawned task done
main task done

The 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.

Rust
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();
}
Output
flag observed

A 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.

Rust
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());
}
Output
handle id matches id inside the task: true
two live tasks have different ids: true
no task id in the main future: true

The 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.

Rust
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());
}
Output
request 1: first task
request 2: second task

The 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.

Rust
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;
}
Output
child without scope sees the value: false
child with its own scope sees: alice

The 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).

Rust
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());
}
Output
main: handle dropped
detached task: finishing
main: finished

The 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".

Rust
#[tokio::main(flavor = "current_thread")]
async fn main() {
    tokio::spawn(async {
        println!("this line is never printed");
    });

    println!("main returns without yielding");
}
Output
main returns without yielding

On 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::spawn creates a task and returns a JoinHandle<T>; the task starts in the background without being awaited, but never inside the spawn call.
  • Awaiting a handle returns Result<T, JoinError>. JoinError means 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. Use async move, clones, and Arc.
  • The future must be Send on the multi-thread runtime because a task can resume on another worker. A non-Send value that is alive across an .await makes the future !Send.
  • Dropping a JoinHandle detaches 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 .await blocks its worker thread. yield_now hands the thread back voluntarily.
  • Task-locals are not inherited by child tasks.

Summary

  • spawn needs Future + Send + 'static and an output that is Send + 'static.
  • handle.await gives Result<T, JoinError>; use ?? when the task returns a Result.
  • JoinError::is_panic and into_panic expose a panic; is_cancelled reports cancellation.
  • Move owned data with async move, share with Arc, and clone before spawning in a loop.
  • Rc and std::sync::MutexGuard are not Send; end their scope before an .await or use a Send type.
  • yield_now sends the current task to the back of the queue; the runtime does not guarantee which task runs next.
  • task::id(), task::try_id(), and JoinHandle::id() identify tasks; the ids are not sequential.
  • task_local! with scope, get, and try_get stores a value per task; child tasks need their own scope.
  • A dropped handle detaches the task; runtime shutdown drops whatever is left.