Step 3 of 7
Running futures at the same time: join! and try_join!
When two calls don't depend on each other, waiting for one before starting the other wastes time. tokio::join! takes several futures, runs them concurrently, and when all of them have finished, returns their outputs as a tuple, in the order you listed them:
use std::time::Duration;
use tokio::time::sleep;
async fn fetch_profile(id: u32) -> String {
sleep(Duration::from_millis(30)).await;
format!("profile of user {id}")
}
async fn fetch_orders(id: u32) -> Vec<u32> {
sleep(Duration::from_millis(10)).await;
vec![id * 100, id * 100 + 1]
}
async fn fetch_balance(id: u32) -> i64 {
sleep(Duration::from_millis(20)).await;
i64::from(id) * 1000
}
#[tokio::main]
async fn main() {
let (profile, orders, balance) = tokio::join!(fetch_profile(7), fetch_orders(7), fetch_balance(7));
println!("{profile}");
println!("orders: {orders:?}");
println!("balance: {balance}");
}
profile of user 7
orders: [700, 701]
balance: 7000
The three waits overlap, so this takes about 30 ms (the slowest one) instead of 60 ms. And the results come back in argument order, not finishing order, so the output never changes.
How can this work without threads? join! runs all three futures inside one task. When one is waiting at an .await (here, sleeping), join! moves on and lets the next one make progress. Nothing needs a second CPU core, because all three are mostly just waiting. That's concurrency without parallelism, the heart of async.
try_join! for things that can fail
With futures that return Result, tokio::try_join! gives Ok((a, b, ...)) if all of them succeed, or the first Err as soon as one fails. The others are then dropped, which cancels them: no point waiting for the rest. It pairs nicely with ?:
async fn load_config() -> Result<String, String> {
Ok(String::from("port=8080"))
}
async fn connect_db() -> Result<u32, String> {
Err(String::from("database unreachable"))
}
async fn start() -> Result<(), String> {
let (config, conn) = tokio::try_join!(load_config(), connect_db())?;
println!("started with {config} and connection {conn}");
Ok(())
}
#[tokio::main]
async fn main() {
if let Err(e) = start().await {
println!("startup failed: {e}");
}
}
startup failed: database unreachable
All the futures in one try_join! must use the same error type. Convert with map_err, or use anyhow::Error (module 39) in application code.
join! takes a fixed list of futures written in your code. For a Vec of work whose length you only know at run time, spawn a task per item: next step.
Common mistakes
- Awaiting independent calls one by one:
let a = f().await; let b = g().await;takes the sum of both times. Usejoin!. - Expecting
join!to use several cores: everything runs in one task. It speeds up waiting, not CPU work (step 7). - Mixed error types in
try_join!: a type mismatch error. Make the errors the same type first.
Your task
dashboard needs three independent pieces of data. Write it so the three fetches run at the same time with tokio::join!, and return "{name}: {points} points, rank #{rank}". The program should print:
ferris: 600 points, rank #3
corro: 1200 points, rank #2
Challenge 2
Fill in the macro that runs both lookups at the same time and returns both results. The program should print pearl: 4 in stock at 250 each.
Challenge 3
Write async fn load_order(order_id: u32) -> Result<Order, String> that fetches the customer and the items concurrently with tokio::try_join!, and returns the first error if either fetch fails. Use ? on the result of try_join!.
Previous: The runtime, and sleeping without blocking Next: Tasks: tokio::spawn and JoinHandle