Lesson 7 of 44
join!, try_join!, and Running Work Concurrently
Awaiting futures one after another runs them one after another. To overlap their waiting, the program has to poll them together. This lesson covers two ways: combining futures inside one task with join! and try_join!, and running each future as its own task and collecting the handles.
| Tool | Runs the futures | Result |
|---|---|---|
tokio::join!(a, b, c) |
concurrently, inside the current task | a tuple with every output |
tokio::try_join!(a, b, c) |
concurrently, inside the current task | Ok(tuple) or the first Err |
futures::future::join_all(iter) |
concurrently, inside the current task | Vec of outputs in input order |
futures::future::try_join_all(iter) |
concurrently, inside the current task | Ok(Vec) or the first Err |
tokio::spawn per future, then await the handles |
as separate tasks, possibly in parallel | one Result<T, JoinError> per handle |
The examples that measure elapsed time run on Tokio's paused clock: with start_paused = true on the current-thread runtime, a sleep completes as soon as every task is idle, and the clock jumps to the sleep's deadline. Elapsed times are then exact and the output never varies. The attribute needs the test-util feature (cargo add tokio --features test-util in addition to the features from the introduction), and Time: sleep, timeout, and interval covers the paused clock. The futures examples need cargo add futures.
Example 1: Sequential awaits add up
Two .await expressions in a row run one after the other. The second future does not start until the first finishes.
use std::time::Duration;
use tokio::time::{Instant, sleep};
async fn fetch(name: &str, millis: u64) -> String {
sleep(Duration::from_millis(millis)).await;
format!("{name} done")
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let start = Instant::now();
let first = fetch("first", 100).await;
let second = fetch("second", 200).await;
println!("{first}, {second}");
println!("elapsed: {} ms", start.elapsed().as_millis());
}first done, second done
elapsed: 300 msThe total is the sum of the two waits: 100 ms plus 200 ms.
Example 2: join! overlaps the waiting
tokio::join! takes several futures, polls all of them, and returns when all have finished. The result is a tuple with one element per future.
use std::time::Duration;
use tokio::time::{Instant, sleep};
async fn fetch(name: &str, millis: u64) -> String {
sleep(Duration::from_millis(millis)).await;
format!("{name} done")
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let start = Instant::now();
let (first, second) = tokio::join!(fetch("first", 100), fetch("second", 200));
println!("{first}, {second}");
println!("elapsed: {} ms", start.elapsed().as_millis());
}first done, second done
elapsed: 200 msBoth sleeps ran during the same 200 ms. The total is the longest wait, not the sum.
Example 3: Results follow argument order, not completion order
The tuple always lists results in the order the futures were written. The futures themselves finish in the order their work finishes. Each future here prints when it completes.
use std::time::Duration;
use tokio::time::sleep;
async fn job(name: &'static str, millis: u64) -> &'static str {
sleep(Duration::from_millis(millis)).await;
println!("{name} finished");
name
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let (a, b, c) = tokio::join!(job("a", 300), job("b", 100), job("c", 200));
println!("results: {a} {b} {c}");
}b finished
c finished
a finished
results: a b cThe prints show completion order (b, c, a). The tuple keeps declaration order (a, b, c).
Example 4: Branches can have different types
Each branch is a separate future with its own output type, so the tuple mixes types. A branch that returns () contributes a () element.
async fn number() -> u32 {
42
}
async fn text() -> String {
String::from("hello")
}
async fn nothing() {}
#[tokio::main]
async fn main() {
let (n, t, unit) = tokio::join!(number(), text(), nothing());
println!("{n} {t} {unit:?}");
}42 hello ()Example 5: join! can borrow local data
The branches of join! run inside the current task, so they are not required to be 'static. They can borrow variables from the surrounding function. tokio::spawn cannot do this (Spawning Tasks explains the 'static bound).
async fn sum(values: &[u32]) -> u32 {
values.iter().sum()
}
async fn longest(words: &[&str]) -> usize {
words.iter().map(|word| word.len()).max().unwrap_or(0)
}
#[tokio::main]
async fn main() {
let values = vec![1, 2, 3, 4];
let words = ["tokio", "join", "select"];
let (total, length) = tokio::join!(sum(&values), longest(&words));
println!("total {total}, longest word {length}");
println!("values are still usable: {}", values.len());
}total 10, longest word 6
values are still usable: 4Example 6: join! waits for every branch, even after an error
join! does not look inside the outputs. If the futures return Result, a failed branch does not stop the others. All results are collected and the code decides what to do with them.
use std::time::Duration;
use tokio::time::{Instant, sleep};
async fn step(name: &'static str, millis: u64, ok: bool) -> Result<&'static str, String> {
sleep(Duration::from_millis(millis)).await;
if ok { Ok(name) } else { Err(format!("{name} failed")) }
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let start = Instant::now();
let (a, b, c) = tokio::join!(step("a", 100, true), step("b", 50, false), step("c", 200, false));
println!("{a:?}");
println!("{b:?}");
println!("{c:?}");
println!("elapsed: {} ms", start.elapsed().as_millis());
}Ok("a")
Err("b failed")
Err("c failed")
elapsed: 200 msBranch b failed after 50 ms, but the call returned only after the slowest branch (200 ms).
Example 7: try_join! stops at the first error
tokio::try_join! is for futures that return Result. If every branch returns Ok, the result is Ok with a tuple of the Ok values. When one branch returns Err, try_join! returns that error at once.
async fn parse(text: &str) -> Result<i32, std::num::ParseIntError> {
text.parse::<i32>()
}
#[tokio::main]
async fn main() {
let all_good = tokio::try_join!(parse("1"), parse("2"), parse("3"));
println!("{all_good:?}");
let one_bad = tokio::try_join!(parse("1"), parse("x"), parse("3"));
println!("{one_bad:?}");
}Ok((1, 2, 3))
Err(ParseIntError { kind: InvalidDigit })In the first call the result is Ok((1, 2, 3)). In the second call it is the ParseIntError from the middle branch.
Example 8: try_join! drops the unfinished branches
When try_join! returns early with an error, the futures that have not finished are dropped. Dropping a future cancels it: it never runs again, and its local variables are destroyed. The slow branch below owns a Guard whose Drop prints a line, so the cancellation is visible (Cancellation, abort, and Panics covers this in depth).
use std::time::Duration;
use tokio::time::{Instant, sleep};
struct Guard(&'static str);
impl Drop for Guard {
fn drop(&mut self) {
println!("{}: dropped", self.0);
}
}
async fn fails_early() -> Result<(), &'static str> {
sleep(Duration::from_millis(100)).await;
Err("fails_early: error")
}
async fn slow() -> Result<(), &'static str> {
let _guard = Guard("slow");
sleep(Duration::from_millis(500)).await;
println!("slow: finished");
Ok(())
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let start = Instant::now();
let result = tokio::try_join!(fails_early(), slow());
println!("result: {result:?}");
println!("elapsed: {} ms", start.elapsed().as_millis());
}slow: dropped
result: Err("fails_early: error")
elapsed: 100 msThe call returned after 100 ms. slow never printed "finished" because it was dropped at its .await. The _guard was destroyed during the drop.
Example 9: All error types must be the same
try_join! returns the first Err unchanged. It does not convert it with From the way ? does, so all branches must use the same error type. Two different error types do not compile.
async fn read_number() -> Result<u32, std::num::ParseIntError> {
"5".parse::<u32>()
}
async fn read_flag() -> Result<bool, std::str::ParseBoolError> {
"true".parse::<bool>()
}
#[tokio::main]
async fn main() {
let result = tokio::try_join!(read_number(), read_flag());
println!("{result:?}");
}error[E0308]: mismatched types
--> src/main.rs:11:18
|
11 | let result = tokio::try_join!(read_number(), read_flag());
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| |
| expected `ParseIntError`, found `ParseBoolError`
| arguments to this enum variant are incorrect
|
help: the type constructed contains `ParseBoolError` due to the type of the argument passed
--> src/main.rs:11:18
|
11 | let result = tokio::try_join!(read_number(), read_flag());
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ this argument influences the type of `Err`
note: tuple variant defined here
--> /rustc/<version>/library/core/src/result.rs:566:4Converting each error into one type before the call fixes it. map_err with Box<dyn Error> is the shortest way.
use std::error::Error;
async fn read_number() -> Result<u32, std::num::ParseIntError> {
"5".parse::<u32>()
}
async fn read_flag() -> Result<bool, std::str::ParseBoolError> {
"true".parse::<bool>()
}
type BoxError = Box<dyn Error>;
#[tokio::main]
async fn main() {
let result: Result<(u32, bool), BoxError> = tokio::try_join!(
async { read_number().await.map_err(BoxError::from) },
async { read_flag().await.map_err(BoxError::from) },
);
println!("{result:?}");
}Ok((5, true))Example 10: join! runs inside one task
join! does not create tasks. Its branches are polled by the task that contains the join!. Two branches report the same task id, and two spawned tasks report different ids. (Spawning Tasks introduces task::id.)
use tokio::task;
#[tokio::main]
async fn main() {
let joined = tokio::spawn(async {
tokio::join!(async { task::id() }, async { task::id() })
});
let (first, second) = joined.await.unwrap();
println!("branches of join! share a task: {}", first == second);
let a = tokio::spawn(async { task::id() });
let b = tokio::spawn(async { task::id() });
let (a, b) = (a.await.unwrap(), b.await.unwrap());
println!("two spawned tasks share a task: {}", a == b);
}branches of join! share a task: true
two spawned tasks share a task: falseThis has two consequences, both from the join! documentation. The branches run concurrently but not in parallel: they take turns on whichever thread is polling the task. A branch that blocks the thread, or does long work without an .await, stops all the other branches. To get parallelism, spawn each piece of work and pass the join handles to join!.
Example 11: join! with spawned tasks
A JoinHandle is a future, so it can be a join! branch. The tasks then run as independent tasks (in parallel on the multi-thread runtime), and join! waits for all of them. Each branch yields a Result<T, JoinError>.
use std::time::Duration;
use tokio::time::{Instant, sleep};
async fn work(name: &'static str, millis: u64) -> String {
sleep(Duration::from_millis(millis)).await;
format!("{name} done")
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let start = Instant::now();
let first = tokio::spawn(work("first", 100));
let second = tokio::spawn(work("second", 200));
let (first, second) = tokio::join!(first, second);
println!("{:?}", first);
println!("{:?}", second);
println!("elapsed: {} ms", start.elapsed().as_millis());
}Ok("first done")
Ok("second done")
elapsed: 200 msThe tasks started at their spawn calls. join! only waits for them, so the order of the handles inside join! does not change when the work starts.
Example 12: try_join! with spawned tasks needs a flattening step
A spawned task that returns Result<T, E> produces Result<Result<T, E>, JoinError> when awaited. try_join! needs a single Result per branch. A small helper turns the nested result into one, using an error type that can hold both kinds of failure.
use std::time::Duration;
use tokio::task::{JoinError, JoinHandle};
use tokio::time::{Instant, sleep};
#[derive(Debug)]
enum TaskError {
Failed(String),
Join(JoinError),
}
impl From<JoinError> for TaskError {
fn from(error: JoinError) -> Self {
TaskError::Join(error)
}
}
async fn flatten<T>(handle: JoinHandle<Result<T, String>>) -> Result<T, TaskError> {
match handle.await {
Ok(Ok(value)) => Ok(value),
Ok(Err(message)) => Err(TaskError::Failed(message)),
Err(join_error) => Err(join_error.into()),
}
}
async fn job(millis: u64, ok: bool) -> Result<u64, String> {
sleep(Duration::from_millis(millis)).await;
if ok { Ok(millis) } else { Err(format!("job of {millis} ms failed")) }
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let start = Instant::now();
let quick = tokio::spawn(job(100, false));
let slow = tokio::spawn(job(500, true));
let result = tokio::try_join!(flatten(quick), flatten(slow));
println!("{result:?}");
println!("elapsed: {} ms", start.elapsed().as_millis());
}Err(Failed("job of 100 ms failed"))
elapsed: 100 mstry_join! returned at 100 ms with the first error. Only the flatten futures were dropped. Dropping a JoinHandle detaches its task instead of cancelling it, so the slow task is still running. The next example checks that.
Example 13: Spawned tasks keep running after try_join! returns an error
The two behaviors differ. Dropping a plain future cancels it (Example 8). Dropping a JoinHandle leaves the task running. The slow task below sets a flag when it finishes, and the program checks the flag after the try_join! has already failed.
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tokio::task::JoinHandle;
use tokio::time::sleep;
async fn flatten<T>(handle: JoinHandle<Result<T, &'static str>>) -> Result<T, &'static str> {
handle.await.unwrap_or(Err("task failed to complete"))
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let finished = Arc::new(AtomicBool::new(false));
let failing = tokio::spawn(async {
sleep(Duration::from_millis(100)).await;
Err::<(), _>("failing task: error")
});
let slow = {
let finished = Arc::clone(&finished);
tokio::spawn(async move {
sleep(Duration::from_millis(500)).await;
finished.store(true, Ordering::SeqCst);
Ok(())
})
};
let result = tokio::try_join!(flatten(failing), flatten(slow));
println!("try_join!: {result:?}");
println!("slow task finished at that moment: {}", finished.load(Ordering::SeqCst));
sleep(Duration::from_secs(1)).await;
println!("slow task finished later: {}", finished.load(Ordering::SeqCst));
}try_join!: Err("failing task: error")
slow task finished at that moment: false
slow task finished later: trueTo stop the other tasks when one fails, abort them explicitly (Cancellation, abort, and Panics) or manage them as a group with JoinSet (JoinSet and Structured Concurrency).
Example 14: join! polling order and biased;
By default, join! changes which branch it polls first each time it is polled. The documentation gives the reason: every branch can make progress even if one branch uses up the task's whole scheduling budget. Because of this rotation, code must not depend on which branch runs first.
Adding biased; as the first item makes join! poll the branches from top to bottom on every poll. Then the order is fixed. The caller becomes responsible for fairness: a branch placed first is always polled first, so a long-running first branch can delay the rest.
#[tokio::main]
async fn main() {
tokio::join!(
biased;
async { println!("branch 1 polled") },
async { println!("branch 2 polled") },
async { println!("branch 3 polled") },
);
}branch 1 polled
branch 2 polled
branch 3 polledExample 15: A panic inside a join! branch panics the task
Branches are not tasks, so a panic in a branch is not caught at the branch. It unwinds through the join! and out of the task that holds it. If that task was spawned, its handle reports the panic. The other branches are dropped.
use std::time::Duration;
use tokio::time::sleep;
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let handle = tokio::spawn(async {
tokio::join!(
async {
sleep(Duration::from_millis(10)).await;
panic!("branch failed");
},
async {
sleep(Duration::from_millis(100)).await;
println!("this branch never finishes");
},
)
});
let error = handle.await.unwrap_err();
println!("task panicked: {}", error.is_panic());
}task panicked: trueSpawned branches behave differently: a panic in a spawned task is caught by the runtime and returned as a JoinError to whoever awaits that task's handle.
Example 16: join_all for a number of futures known at run time
join! needs the number of branches written in the source. For a Vec or any iterator of futures, futures::future::join_all waits for all of them and returns their outputs in a Vec, in input order.
use futures::future::join_all;
use std::time::Duration;
use tokio::time::{Instant, sleep};
async fn square_after(value: u64, millis: u64) -> u64 {
sleep(Duration::from_millis(millis)).await;
value * value
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let start = Instant::now();
let futures = (1..=5).map(|value| square_after(value, 500 - value * 100));
let results = join_all(futures).await;
println!("{results:?}");
println!("elapsed: {} ms", start.elapsed().as_millis());
}[1, 4, 9, 16, 25]
elapsed: 400 msThe waits were 400, 300, 200, 100, and 0 ms. The total is the longest of them, and the results are in input order. Like join!, join_all runs all futures inside the current task.
Example 17: try_join_all stops at the first error
futures::future::try_join_all is the Vec version of try_join!. It returns Ok(Vec) when all futures succeed and the first Err otherwise, dropping the futures that are still running.
use futures::future::try_join_all;
use std::time::Duration;
use tokio::time::{Instant, sleep};
async fn check(id: u32, millis: u64, ok: bool) -> Result<u32, String> {
sleep(Duration::from_millis(millis)).await;
if ok { Ok(id) } else { Err(format!("check {id} failed")) }
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let start = Instant::now();
let good = try_join_all(vec![check(1, 30, true), check(2, 10, true), check(3, 20, true)]).await;
println!("{good:?}");
let bad = try_join_all(vec![check(1, 300, true), check(2, 50, false), check(3, 400, true)]).await;
println!("{bad:?}");
println!("elapsed: {} ms", start.elapsed().as_millis());
}Ok([1, 2, 3])
Err("check 2 failed")
elapsed: 80 msThe first call took 30 ms. The second stopped at 50 ms, so the total is 80 ms. Checks 1 and 3 were dropped.
Example 18: join_all with a small and a large input
join_all has two internal modes, chosen when it is called. For up to 30 futures it stores them in a slice and polls all of them each time it is woken. For more than 30 it uses FuturesOrdered, which polls only the futures that were woken. The results are the same in both modes. The limit comes from the SMALL constant in futures-util 0.3.34. The join_all documentation recommends FuturesOrdered or FuturesUnordered for large sets, or for adding futures after the set has started.
use futures::future::join_all;
async fn double(value: u32) -> u32 {
value * 2
}
#[tokio::main]
async fn main() {
let small = join_all((0..30).map(double)).await;
let large = join_all((0..3000).map(double)).await;
println!("small: {} results, last {}", small.len(), small[29]);
println!("large: {} results, last {}", large.len(), large[2999]);
}small: 30 results, last 58
large: 3000 results, last 5998Example 19: Collect JoinHandles to run tasks in parallel
To run one task per item, spawn them all first and await the handles afterwards. Every task starts at its spawn call, so all of them run at the same time, and awaiting the handles in order only collects the results.
use std::time::Duration;
use tokio::time::{Instant, sleep};
async fn work(id: u64, millis: u64) -> u64 {
sleep(Duration::from_millis(millis)).await;
id * 10
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let start = Instant::now();
let delays = [300, 100, 200];
let mut handles = Vec::new();
for (id, millis) in delays.into_iter().enumerate() {
handles.push(tokio::spawn(work(id as u64, millis)));
}
let mut results = Vec::new();
for handle in handles {
results.push(handle.await.unwrap());
}
println!("{results:?}");
println!("elapsed: {} ms", start.elapsed().as_millis());
}[0, 10, 20]
elapsed: 300 msThe total equals the longest delay (300 ms). The results are in spawn order, not in the order the tasks finished.
Example 20: Awaiting inside the spawning loop serializes the work
Adding .await to each spawn call makes the loop wait for one task before starting the next. The tasks are correct, but no two of them overlap.
use std::time::Duration;
use tokio::time::{Instant, sleep};
async fn work(id: u64, millis: u64) -> u64 {
sleep(Duration::from_millis(millis)).await;
id * 10
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let start = Instant::now();
let delays = [300, 100, 200];
let mut results = Vec::new();
for (id, millis) in delays.into_iter().enumerate() {
results.push(tokio::spawn(work(id as u64, millis)).await.unwrap());
}
println!("{results:?}");
println!("elapsed: {} ms", start.elapsed().as_millis());
}[0, 10, 20]
elapsed: 600 msThe results are the same as in Example 19. The elapsed time is the sum of the delays (600 ms) instead of the maximum (300 ms).
Example 21: One failed task in a collected Vec of handles
Awaiting each handle gives one Result per task. A panic in one task appears as an Err for that task only. The loop can keep the successes and count the failures.
#[tokio::main]
async fn main() {
let mut handles = Vec::new();
for number in 1..=5u32 {
handles.push(tokio::spawn(async move {
if number == 3 {
panic!("task {number} hit a bad input");
}
number * number
}));
}
let mut squares = Vec::new();
let mut failures = 0;
for handle in handles {
match handle.await {
Ok(square) => squares.push(square),
Err(_) => failures += 1,
}
}
println!("squares: {squares:?}");
println!("failed tasks: {failures}");
}squares: [1, 4, 16, 25]
failed tasks: 1The panic message goes to standard error and does not appear in the output above. JoinSet returns results in completion order instead of spawn order (JoinSet and Structured Concurrency).
Choosing between the tools
| Situation | Tool |
|---|---|
| A fixed number of futures, results kept separate, work is waiting on I/O or timers | join! |
The same, and the first Err should end the wait |
try_join! |
| Futures may borrow local data | join!, try_join!, join_all (spawned tasks cannot borrow) |
| The number of futures is known only at run time | join_all or try_join_all |
| Work is CPU-heavy or each unit should run on any worker thread | spawn per unit, then await the handles |
| Each unit must survive when the caller stops waiting | spawn (a plain future is dropped when its join! is dropped) |
| Results are needed as each task finishes | JoinSet (later lesson) |
Interview points
join!andtry_join!poll all branches inside the current task. They give concurrency (overlapping waits) and no parallelism.- The
join!result tuple is in argument order. Completion order does not affect it. join!waits for all branches.try_join!returns on the firstErrand drops the other branches.try_join!needs one error type for all branches and does not convert errors.- Dropping a future cancels it; dropping a
JoinHandledetaches the task.try_join!overJoinHandles therefore leaves the other tasks running. - Branches of
join!can borrow locals. Spawned tasks must be'static. - A panic in a
join!branch unwinds the enclosing task. A panic in a spawned task is caught and returned as aJoinError. join_allandtry_join_all(from thefuturescrate) take any number of futures and return results in input order.- Spawn all tasks before awaiting any handle. Awaiting inside the spawning loop runs the tasks one at a time.
join!rotates its polling start point for fairness;biased;fixes the order top to bottom.
Summary
tokio::join!(a, b)returns(A, B)after both finish. Time taken is about the longest branch.tokio::try_join!(a, b)returnsOk((A, B))or the firstErr. AllErrtypes must be equal.- All branches run in one task: concurrent, not parallel. A blocking branch stalls the rest.
- Use
spawnandJoinHandles for parallelism and for work that must outlive the waiter. - Flatten
Result<Result<T, E>, JoinError>into one error type beforetry_join!. join_allandtry_join_allhandle aVecof futures; with more than 30 futures they useFuturesOrderedinternally.biased;injoin!ortry_join!polls in written order.
