Skip to content
rusthacks.dev
Concurrency

JoinSet in Tokio for Managing Many Tasks as a Group

JoinSet owns a group of spawned tasks and returns their results as they finish. See spawn, join_next, abort_all and how errors and panics surface.

8 min read
Summarize in AI
Flat illustration for the post “JoinSet in Tokio for Managing Many Tasks as a Group”, composed around async.

JoinSet is Tokio's answer to "I spawned a pile of tasks and now I need to manage them as a group". It owns the tasks, hands back their results in the order they finish, aborts anything still running when it is dropped, and reports panics and cancellations through the same JoinError as a single handle. Where a Vec<JoinHandle> makes you choose between awaiting in spawn order and writing your own plumbing, and quietly detaches tasks if you drop it, JoinSet does the right thing by default. This post shows each of those behaviours with running code, a bounded fan-out loop, and the cases where a vector of handles is still the better tool. Everything compiled on Rust 1.99, edition 2024, with tokio 1.

Results in completion order

The biggest difference from a vector of handles is the order. join_next().await returns whichever task finished first:

rust
use tokio::task::JoinSet;
use tokio::time::{sleep, Duration, Instant};

#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
    let t = Instant::now();
    let mut set = JoinSet::new();

    for (name, ms) in [("slow", 300u64), ("fast", 100), ("medium", 200)] {
        set.spawn(async move {
            sleep(Duration::from_millis(ms)).await;
            name
        });
    }

    while let Some(res) = set.join_next().await {
        println!("{:>6} finished at {:?}", res.unwrap(), t.elapsed());
    }
}
Text
  fast finished at 100ms
medium finished at 200ms
  slow finished at 300ms

A vector of handles awaited in a loop would have reported slow first, because it waits for the first handle even though two others are done. The completion order matters when you want to process results as they arrive (show progress, stop at the first success, feed a pipeline) and costs you the input order, which you recover by returning an identifier from each task, as the next example does. join_next returns None once the set is empty, which ends the while let.

Dropping the set cancels the tasks

The default is the opposite of a bare JoinHandle: tasks in a JoinSet belong to it, and dropping the set aborts whatever has not finished. This turns a whole class of leaked-task bugs into non-events, because the lifetime of the work is the lifetime of the value that holds it:

rust
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use tokio::task::JoinSet;
use tokio::time::{sleep, Duration};

struct Guard(Arc<AtomicU32>);

impl Drop for Guard {
    fn drop(&mut self) {
        self.0.fetch_add(1, Ordering::SeqCst);
    }
}

#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
    let dropped = Arc::new(AtomicU32::new(0));

    {
        let mut set = JoinSet::new();
        for _ in 0..3 {
            let g = Guard(dropped.clone());
            set.spawn(async move {
                let _g = g;
                sleep(Duration::from_secs(60)).await;
            });
        }
        sleep(Duration::from_millis(10)).await;
        println!("leaving scope with {} tasks running", set.len());
    }

    sleep(Duration::from_millis(10)).await;
    println!("destructors run after drop: {}", dropped.load(Ordering::SeqCst));
}
Text
leaving scope with 3 tasks running
destructors run after drop: 3

All three tasks were aborted when the set went out of scope, and their destructors ran, which the guard counted. The tasks never reached the end of their sleep. If you want the opposite, to let them run on after the set is gone, call detach_all(). That is rarely right; if you find yourself writing it, ask whether the work belongs in a task that someone else owns.

Tracking which task produced which result

Because results arrive out of order, each task should return something that identifies it. Return a tuple, or use spawn with an ID in the output, and collect into a map or a pre-sized vector to restore the input order:

rust
use tokio::task::JoinSet;
use tokio::time::{sleep, Duration};

async fn fetch(id: usize) -> (usize, Result<String, String>) {
    sleep(Duration::from_millis(100 * (5 - id as u64))).await;
    if id == 3 {
        (id, Err(format!("page {id} timed out")))
    } else {
        (id, Ok(format!("page {id}")))
    }
}

#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
    let mut set = JoinSet::new();
    for id in 1..=4 {
        set.spawn(fetch(id));
    }

    let mut pages: Vec<Option<String>> = vec![None; 4];
    let mut failures = Vec::new();
    while let Some(joined) = set.join_next().await {
        match joined {
            Ok((id, Ok(body))) => pages[id - 1] = Some(body),
            Ok((_, Err(e))) => failures.push(e),
            Err(join_err) => failures.push(format!("task failed: {join_err}")),
        }
    }

    println!("{pages:?}");
    println!("{failures:?}");
}
Text
[Some("page 1"), Some("page 2"), None, Some("page 4")]
["page 3 timed out"]

The pattern Ok((id, Ok(body))) matches three layers: the join succeeded, the task returned its tuple, and the work itself succeeded. A failure in the middle layer is the task's own error, and in the outer layer it is a panic or an abort. Treating them separately matters, because a panic is a bug to report and a timeout is data. Flattening the two into one error type is shown in spawn and join.

A bounded fan-out: at most N at a time

Spawning one task per input is wasteful for large inputs, and sometimes harmful (a million sockets). A JoinSet makes a fixed-size window easy: keep it at N tasks by joining one before spawning the next.

rust
use tokio::task::JoinSet;
use tokio::time::{sleep, Duration, Instant};

#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
    let limit = 3;
    let jobs: Vec<u64> = (1..=8).collect();
    let t = Instant::now();

    let mut set = JoinSet::new();
    let mut peak = 0;
    let mut done = 0;

    for job in jobs {
        if set.len() >= limit {
            set.join_next().await.unwrap().unwrap();
            done += 1;
        }
        set.spawn(async move {
            sleep(Duration::from_millis(100)).await;
            job
        });
        peak = peak.max(set.len());
    }
    while let Some(r) = set.join_next().await {
        r.unwrap();
        done += 1;
    }

    println!("completed {done} jobs, at most {peak} at once, in {:?}", t.elapsed());
}
Text
completed 8 jobs, at most 3 at once, in 300ms

Eight 100 ms jobs with a window of three finish in 300 ms: three rounds of up to three, the last with two. The window is enforced by the loop, which blocks on join_next when the set is full. A Semaphore does the same job with permits and also bounds work that is spread across many places, as in limiting concurrency with a semaphore; for a single loop, the set alone is simpler. The other tool for it is buffer_unordered on a stream, in buffer_unordered.

Panics and abort

A panicking task does not stop the others. join_next yields Err(JoinError) for it, and the set continues. You can also cancel the whole group at once with abort_all(), then drain the set to collect the cancellation errors:

rust
use tokio::task::JoinSet;
use tokio::time::{sleep, Duration};

#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
    let mut set = JoinSet::new();
    set.spawn(async { 1 });
    set.spawn(async {
        if true {
            panic!("worker bug");
        }
        2
    });
    set.spawn(async {
        sleep(Duration::from_secs(3600)).await;
        3
    });

    let first = set.join_next().await;
    println!("first: {first:?}");
    let second = set.join_next().await.unwrap();
    println!("second panicked: {}", second.as_ref().is_err_and(|e| e.is_panic()));

    set.abort_all();
    while let Some(r) = set.join_next().await {
        println!("after abort_all: cancelled = {}", r.unwrap_err().is_cancelled());
    }
    println!("set empty: {}", set.is_empty());
}
Text
first: Some(Ok(1))
second panicked: true
after abort_all: cancelled = true
set empty: true

This is the shape of a "first failure cancels the rest" loop: on the first Err, call abort_all(), then drain. Draining matters: until you join an aborted task, its destructors may not have run, and the runtime reports it as completed only when you collect it.

The neighbouring tools

ToolTasks?Result orderFutures may borrow?Cancels on drop
Vec<JoinHandle>YesAs you await themNo ('static)No, detaches
JoinSetYesCompletion orderNo ('static)Yes
FuturesUnordered (futures crate)No, one taskCompletion orderYesYes
join_allNo, one taskInput orderYesYes
stream.buffer_unordered(n)No, one taskCompletion orderYesYes

The rows without tasks run every future on the calling task, so there is no parallelism, but they can borrow local data and cost no allocation per future. The decision between rows is the same as between spawn and join!: spawn when you need parallel CPU use, panic isolation or a lifetime longer than the caller; otherwise keep the futures on the current task. JoinSet also has spawn_blocking (for blocking closures that you want to manage in the same group), spawn_on (onto a chosen runtime handle) and try_join_next (poll for a finished task without waiting), which round out the API for most needs.

One thing to check in review: a JoinSet inside a struct. If the struct holds the set as a field, then dropping the struct aborts its tasks, which is usually the intent for a component such as a connection pool or an in-process job runner. Document it. A struct whose destructor silently cancels in-flight work should say so, and should offer a method that drains the set (shutdown().await) for callers who want the work to finish first, since Drop cannot await.

When a Vec of JoinHandles is still right

If you need results in input order and every task runs to completion anyway, a vector of handles awaited in sequence is simpler, as in the earlier fan-out example. If you pass handles to different owners (one for the response, one for a logger), a bare handle is the unit that can be moved. And if the tasks must outlive the scope that spawned them, a set that cancels on drop is the wrong default; hand the work to a longer-lived owner instead. JoinSet also cannot hold tasks that return different types, so a heterogeneous group needs an enum for the output or separate handles. For the bigger picture of owning tasks and stopping them in the right order at exit, see graceful shutdown.

Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.