Asynchronous Programming in Rust with join_all

Rust’s asynchronous capabilities are powerful, allowing developers to execute multiple tasks concurrently with ease. In this article, we’ll explore how to use the futures::future::join_all function along with tokio to manage multiple asynchronous tasks efficiently.

Running Multiple Async Tasks

Understanding join_all

The join_all function, provided by the futures crate, enables us to execute multiple async tasks concurrently and collect their results. It is particularly useful when we have a set of independent tasks that need to run simultaneously without blocking one another.

join_all is basically saying:
👉 “Everyone, do your async tasks, and I’ll wait until all of you are finished before we move on!”

Example: Running Multiple Async Tasks

Let’s take a look at an example where we perform five concurrent tasks and fetch data from a URL.

use futures::future::join_all;
use tokio::time::{sleep, Duration};
use reqwest;

async fn do_work(id: u32) -> u32 {
    sleep(Duration::from_secs(1)).await;
    println!("Task {} done!", id);
    id * 2
}

async fn get_url(url: &str) -> Result<String, Box<dyn std::error::Error>> {
    let response = reqwest::get(url).await?;
    let body = response.text().await?;
    Ok(body)
}

#[tokio::main]
async fn main() {
    let tasks = (1..=5).map(|i| do_work(i)).collect::<Vec<_>>();
    let results = join_all(tasks).await;
    println!("Results: {:?}", results);

    let url = "https://example.com";
    match get_url(url).await {
        Ok(res) => println!("Response: {}", res),
        Err(err) => println!("Error: {}", err),
    }
}

Breaking Down the Code

  1. do_work Function: This function simulates a task that takes one second to complete and returns double its input value.
  2. get_url Function: This function performs an HTTP GET request asynchronously and returns the body of the response.
  3. Using join_all: We create five async tasks and use join_all to execute them concurrently.
  4. Fetching a URL: After completing the tasks, we make an HTTP request and handle any potential errors.

Benefits of join_all

  • Concurrency: Tasks are executed simultaneously, maximizing efficiency.
  • Scalability: Helps in handling multiple independent operations efficiently.
  • Cleaner Code: Eliminates the need for manual loop-based async handling.

By leveraging join_all and Rust’s async runtime, developers can write highly performant and scalable applications with minimal effort.

Yes, in a way! Using join_all with async tasks is conceptually similar to manually managing spawned threads with Arc<Mutex<T>>, but with key differences due to how async and threads work in Rust.

Similarities to Arc<Mutex<T>> with Threads

  • In multithreading, we often spawn threads and use Arc<Mutex<T>> to share data safely between them.
  • In async, we spawn tasks and use join_all to await all of them at once.
  • Both approaches allow independent operations to run in parallel without blocking each other.

Key Differences

Featurejoin_all (async)Arc<Mutex<T>> (Threads)
Execution ModelCooperative async runtime (tokio, async-std)OS-managed preemptive threads
Resource UsageLightweight tasks (non-blocking, uses a single thread pool)Heavyweight, separate OS threads
SynchronizationFutures resolve independentlyRequires Arc<Mutex<T>> for shared state
OverheadLow, efficient context switching within runtimeHigher due to OS thread management

When to Use Which?

  • Use join_all (async) when your tasks are IO-bound (e.g., HTTP requests, DB queries, file reads).
  • Use threads (Arc<Mutex<T>>) when your tasks are CPU-bound (e.g., heavy computation, number crunching).

So, join_all is like pushing handles into a vector when spawning threads, except it works within Rust’s async model instead of OS threads.

If we didn’t use join_all, each async task would still start, but we wouldn’t properly await all of them together.

Scenario 1: Running Without join_all (Bad Example)

#[tokio::main]
async fn main() {
    for i in 1..=5 {
        do_work(i).await; // Each task runs sequentially 😬
    }
}

🔴 Problem:

  • Each do_work(i).await runs one after another, not in parallel.
  • The total runtime would be 5 seconds (1 sec per task × 5 tasks).

Scenario 2: Using tokio::spawn Instead (Needs Manual Handling)

use tokio::task;

#[tokio::main]
async fn main() {
    let mut handles = vec![];

    for i in 1..=5 {
        handles.push(task::spawn(do_work(i))); // Spawns tasks, but we don't await them yet!
    }

    for handle in handles {
        let result = handle.await.unwrap(); // Manually await each spawned task
        println!("Result: {}", result);
    }
}

🟡 This works, but:

  • We need to manually collect and .await each spawned task.
  • Slightly more boilerplate than join_all.

Why join_all is Better

#[tokio::main]
async fn main() {
    let tasks = (1..=5).map(|i| do_work(i)).collect::<Vec<_>>();
    let results = join_all(tasks).await; // Everything runs in parallel 🏃💨
    println!("Results: {:?}", results);
}

Advantage of join_all:

  • Runs all tasks concurrently (1 sec total, instead of 5).
  • No need for extra Vec or .spawn().
  • Collects all results in one go.

So, without join_all, you’d either be blocking sequentially or manually tracking tasks with spawn. join_all is the cleanest way to await all async operations together! 🚀

You’re right to question that! get_url() isn’t actually involved in join_all in your current example.

Why is get_url() there?

It’s just another async function running after join_all(). But it isn’t part of the batch of async tasks that join_all() is handling.

If we wanted get_url() to be inside join_all()

We’d modify the code like this:

#[tokio::main]
async fn main() {
    let url = "https://example.com";

    let tasks = (1..=5).map(|i| do_work(i)).collect::<Vec<_>>();
    let fetch_task = get_url(url); // Add this as another async task

    let all_tasks = join_all(tasks.into_iter().chain(std::iter::once(fetch_task))).await;
    
    println!("All tasks completed: {:?}", all_tasks);
}

🟢 Now, get_url(url) runs concurrently with do_work(i).
🚀 Everything in join_all() runs together at the same time!


In Your Original Code

  • join_all(tasks).await; runs all do_work(i) tasks in parallel.
  • Only after that finishes, get_url(url).await; is called separately.

If you want get_url() to be part of the join_all(), you need to include it in the task list!

Rust Programming

Previous article

OneOrMany implemented in Rust