Rust JoinSet Task Groups
A JoinSet is one owner for a group of spawned tasks. The owner collects results from the set, or cancels the whole group through it.
One set owns the whole group
spawn adds a task to the set — the set, not you, keeps hold of it:
let mut set = JoinSet::new();
set.spawn(async { 1 });
set.spawn(async { 2 });Collect in finish order
join_next hands back whichever task finished first, not the one you spawned first:
set.spawn(async { sleep(Duration::from_millis(30)).await; "slow" });
set.spawn(async { sleep(Duration::from_millis(5)).await; "fast" });
while let Some(done) = set.join_next().await {
println!("finished: {}", done.unwrap());
}Cancel one task by its handle
spawn hands back an AbortHandle for exactly one task. Aborting stops the task at its next await point, and its join result reports the cancellation:
let handle = set.spawn(async {
sleep(Duration::from_secs(60)).await;
1
});
handle.abort();
let res = set.join_next().await.unwrap();
match res {
Ok(_) => println!("finished normally"),
Err(e) => println!("cancelled = {}", e.is_cancelled()),
}abort_all cancels, but keeps the results
The tasks stop, yet stay in the set — you still collect one result per task:
set.spawn(async { sleep(Duration::from_secs(60)).await });
set.spawn(async { sleep(Duration::from_secs(60)).await });
set.abort_all();
let first = set.join_next().await.unwrap();
println!("first result is error = {}", first.is_err());shutdown waits for the silence
shutdown() aborts everything and waits until each task has really stopped. Afterwards the set is empty:
set.shutdown().await;
println!("tasks left = {}", set.len());Example
// Cargo.toml: tokio = { version = "1", features = ["full"] }
use tokio::task::JoinSet;
use tokio::time::{sleep, Duration};
async fn job(name: &'static str, ms: u64) -> &'static str {
sleep(Duration::from_millis(ms)).await;
name
}
#[tokio::main]
async fn main() {
let mut set = JoinSet::new();
set.spawn(job("alpha", 30));
set.spawn(job("beta", 10));
set.spawn(job("gamma", 20));
while let Some(done) = set.join_next().await {
println!("finished: {}", done.unwrap());
}
println!("set empty = {}", set.is_empty());
}Key points
- Use a JoinSet when a group of spawned tasks belongs to one owner.
- That owner must collect — or cancel — the whole group
- Each result is a Result<T, JoinError>: Ok for a finished task, Err for one that panicked or was cancelled — err.is_panic() and err.is_cancelled() say which
- All tasks in one set share one output type T.
- For mixed results, return an enum.
- Or keep loose JoinHandles instead
- Dropping a JoinSet aborts every task still in it.
- Dropping a loose JoinHandle does the exact opposite.
- It detaches its task
- abort_all() cancels every task.
- The cancelled tasks stay in the set to be collected.
- shutdown() aborts all and waits until the set is empty.
- detach_all() empties the set and lets the tasks run free
- Loose JoinHandles for a whole group are easy to lose and hard to stop.
In this example, three jobs go into one set: alpha sleeps 30 ms, beta 10, gamma 20. The loop asks the set for "the next one to finish", whichever it is. Beta wakes first, then gamma, then alpha — finish order, not spawn order. Each result is unwrapped from its Result. A panicked or cancelled task would arrive as an Err instead. When the set runs dry, join_next returns None, the loop ends, and is_empty confirms nothing is left.
Note
join_next is cancel safe — if it loses a race as a select! branch, nothing is removed from the set.
Try it Yourself
Edit the code, then press Run.
Running is simulated · Rust Playground
Exercise
You drop a JoinSet that still holds two running tasks. What happens to them?