5/5
## Concurrently Executing Futures in Tokio: `join!` vs. `select!` In the realm of asynchronous programming with Tokio in Rust, managing multiple concurrent operations efficiently is paramount. Tokio provides powerful macros to orchestrate these operations, and two of the most fundamental are `join!` and `select!`. This lesson will delve into how these macros work, their key differences, and when to use each, enabling you to write more robust and responsive asynchronous Rust applications. ## Understanding `join!` and `select!` - The Core Differences At their heart, both `join!` and `select!` are designed to poll multiple futures concurrently. However, their behavior, return values, and implications for the futures they manage differ significantly. **1. The `join!` Macro** * **Purpose:** `join!` is used when you need to execute several asynchronous operations concurrently and wait for *all* of them to complete before proceeding. * **Behavior:** It polls all provided futures, driving them towards completion. The `join!` macro itself will only complete once every single future passed to it has completed. * **Return Value:** Upon completion, `join!` returns a tuple. This tuple contains the results of each future, in the same order that the futures were provided to the macro. * **Analogy:** Think of `join!` as saying, "Wait for all of these results to return. I need every single one." **2. The `select!` Macro** * **Purpose:** `select!` is employed when you have multiple asynchronous operations and you're interested in the result of whichever one finishes *first*. * **Behavior:** It polls all provided futures concurrently. As soon as any one of the futures completes, `select!` returns. * **Cancellation:** This is a critical distinction: once one future completes and `select!` is ready to return, all other futures that were being polled but had not yet completed are immediately cancelled. Their execution is stopped, and they are dropped. This prevents unnecessary work and resource consumption. * **Return Value:** `select!` returns the result of the single future that completed first. * **Analogy:** `select!` operates on the principle of, "Just give me one of the results—whichever one returns the earliest." ## Setting the Stage: The `make` Helper Function To illustrate the behavior of `join!` and `select!`, we'll use a simple asynchronous helper function named `make`. This function simulates an asynchronous task that takes a specified amount of time to complete and then returns a predefined value. Here's the code for our `make` function: ```rust use std::time::Duration; // Required import for Duration // Assume other necessary tokio imports like tokio::time::sleep are present // Simulates an async task that completes after `dt` milliseconds async fn make(val: &'static str, dt: u64) -> &'static str { tokio::time::sleep(Duration::from_millis(dt)).await; val } ``` **Explanation:** * The `make` function is an `async fn`, meaning it returns a future. * It accepts two arguments: * `val`: A static string slice (`&'static str`) which will be the return value of the future. * `dt`: A `u64` representing the duration in milliseconds for which this simulated task should "run" or "sleep." * Inside the function, `tokio::time::sleep(Duration::from_millis(dt)).await` pauses the execution of this specific future for `dt` milliseconds. The `.await` keyword allows other tasks to run while this one is sleeping. * After the sleep duration elapses, the function returns the `val` that was passed in. This `make` function will serve as our building block for creating multiple futures with varying completion times, allowing us to observe how `join!` and `select!` handle them. ## `join!` in Action: Waiting for All Results Let's see how the `join!` macro behaves when we provide it with multiple instances of our `make` future, each with a different simulated delay. **Code Setup:** We'll set up a `main` function (annotated with `#[tokio::main]` to run within the Tokio runtime) and use `join!` to execute three `make` futures concurrently. ```rust use std::time::Duration; use tokio::{join, select}; // Ensure macros are imported // ... (make function definition as above) #[tokio::main] async fn main() { println!("Starting join! example..."); let start_time = std::time::Instant::now(); let (res1, res2, res3) = join!( make("coffee", 100), // Simulates a task taking 100ms make("green tea", 50), // Simulates a task taking 50ms make("lemonade", 20) // Simulates a task taking 20ms ); println!("join! completed in: {:?}", start_time.elapsed()); println!("join: res1 = {:?}", res1); println!("join: res2 = {:?}", res2); println!("join: res3 = {:?}", res3); // ... (select! example will follow here) } ``` **Discussion:** * We invoke `join!` with three calls to `make`: * `make("coffee", 100)`: This future will complete after approximately 100 milliseconds. * `make("green tea", 50)`: This future will complete after approximately 50 milliseconds. * `make("lemonade", 20)`: This future will complete after approximately 20 milliseconds. * The `join!` macro will start polling all three of these futures concurrently. * "lemonade" will finish first (after ~20ms), then "green tea" (after ~50ms from the start), and finally "coffee" (after ~100ms from the start). * However, `join!` waits for *all* of them. Therefore, the entire `join!` expression will only complete after the longest-running future, "coffee," finishes. This means the code will pause at the `join!` line for approximately 100 milliseconds. * Once all futures complete, their results are collected into a tuple. We use destructuring assignment `let (res1, res2, res3) = ...` to assign these results to individual variables. The order of results in the tuple matches the order of futures passed to `join!`. **Expected Output:** After running this code, you'll observe output similar to the following (the exact duration might vary slightly): ``` Starting join! example... join! completed in: 100.XXXms // Approximately 100ms join: res1 = "coffee" join: res2 = "green tea" join: res3 = "lemonade" ``` This output confirms that `join!` waited for all tasks, with the total time dictated by the slowest task ("coffee" at 100ms), and all results are available. ## `select!` in Action: Racing for the First Result Now, let's contrast this with the `select!` macro, using the same set of `make` futures. **Code Setup (Continuing within the same `main` function):** ```rust // ... (previous join! example code) println!("\nStarting select! example..."); let start_time_select = std::time::Instant::now(); let res = select! { val = make("coffee", 100) => { println!("select!: 'coffee' (100ms) future finished first"); val // This `val` is "coffee" }, val = make("green tea", 50) => { println!("select!: 'green tea' (50ms) future finished first"); val // This `val` is "green tea" }, val = make("lemonade", 20) => { println!("select!: 'lemonade' (20ms) future finished first"); val // This `val` is "lemonade" }, }; println!("select! completed in: {:?}", start_time_select.elapsed()); println!("select: res = {:?}", res); } ``` **Discussion:** * The `select!` macro also takes multiple futures, but its syntax is different: `pattern = future => { expression_block }`. Each branch consists of a future to poll, a pattern to bind its result if it completes first, and an expression block to execute. * We provide the same three `make` futures: * `make("coffee", 100)` * `make("green tea", 50)` * `make("lemonade", 20)` * `select!` polls all these futures concurrently. * The `make("lemonade", 20)` future is the fastest, expected to complete in approximately 20 milliseconds. * As soon as "lemonade" completes: * Its result ("lemonade") is bound to `val` in its corresponding branch. * The expression block for that branch is executed: `println!("select!: 'lemonade' (20ms) future finished first");` and the value of `val` ("lemonade") is returned by this block. * The `select!` macro as a whole then resolves to this value ("lemonade"). * Crucially, the other two futures (`make("coffee", 100)` and `make("green tea", 50)`) are immediately cancelled. They do not run to completion, and their respective `println!` statements within their `select!` branches will not execute. **Expected Output:** The output for the `select!` part will appear much faster, after approximately 20 milliseconds: ``` Starting select! example... select!: 'lemonade' (20ms) future finished first select! completed in: 20.XXXms // Approximately 20ms select: res = "lemonade" ``` This demonstrates that `select!` indeed returns as soon as the first future completes ("lemonade" in this case), and the overall operation is much quicker because it doesn't wait for the slower tasks. ## `select!` and Tie-Breaking: Handling Equally Fast Futures An interesting scenario arises when multiple futures passed to `select!` might complete at roughly the same time. How does `select!` choose which one "wins"? Let's modify our `select!` example so that all futures have the same simulated completion time. **Modified `select!` Code:** ```rust // ... (inside main, after the first select! example or as a new example) println!("\nStarting select! with equal times example..."); let start_time_select_equal = std::time::Instant::now(); let res_equal = select! { val = make("coffee", 20) => { // Changed from 100ms to 20ms println!("select! (equal): 'coffee' (20ms) future finished first"); val }, val = make("green tea", 20) => { // Changed from 50ms to 20ms println!("select! (equal): 'green tea' (20ms) future finished first"); val }, val = make("lemonade", 20) => { // Stays at 20ms println!("select! (equal): 'lemonade' (20ms) future finished first"); val }, }; println!("select! (equal) completed in: {:?}", start_time_select_equal.elapsed()); println!("select! (equal): res = {:?}", res_equal); ``` **Discussion:** * Now, all three futures (`make("coffee", 20)`, `make("green tea", 20)`, and `make("lemonade", 20)`) are set to complete after 20 milliseconds. * When `select!` polls these futures, the Tokio runtime's scheduler will determine the precise order in which futures are polled and become ready. While they all *should* complete around the same time, slight variations in scheduling and polling can lead to one being recognized as "complete" just before the others. * The `select!` macro doesn't guarantee a specific branch will be chosen if multiple branches become ready simultaneously in the same poll. It typically picks the one that completes its polling first within that round. This can lead to seemingly non-deterministic behavior if you run the code multiple times. **Output (May Vary Across Runs):** If you run this modified code several times, you might see different futures "winning": * **Run 1:** ``` Starting select! with equal times example... select! (equal): 'lemonade' (20ms) future finished first select! (equal) completed in: 20.XXXms select! (equal): res = "lemonade" ``` * **Run 2:** ``` Starting select! with equal times example... select! (equal): 'green tea' (20ms) future finished first select! (equal) completed in: 20.XXXms select! (equal): res = "green tea" ``` * **Run 3:** ``` Starting select! with equal times example... select! (equal): 'coffee' (20ms) future finished first select! (equal) completed in: 20.XXXms select! (equal): res = "coffee" ``` This variability is normal and highlights that `select!` is about getting the *first* result from a set of concurrently progressing tasks. If they are equally fast, any one of them might be deemed the first based on the intricacies of the runtime's polling mechanism. ## Key Takeaways: `join!` vs. `select!` Understanding the distinct behaviors of `join!` and `select!` is crucial for effective asynchronous programming in Tokio. * **Use `join!` when:** * You need the results of *all* spawned asynchronous operations. * Your program logic depends on the successful completion of every task in a set before it can proceed. * The total time taken will be at least as long as the slowest operation. * **Use `select!` when:** * You are interested in the result of only the *first* operation to complete out of a group. * You want to race multiple tasks against each other (e.g., fetching a resource from multiple mirrors). * You need to implement timeouts (e.g., `select!` an operation against a `tokio::time::sleep` future). * Resource efficiency is important, as `select!` cancels pending futures once one completes, preventing them from doing further work. * **Concurrency vs. Parallelism:** Both `join!` and `select!` enable futures to make progress *concurrently*. This means they can be interleaved in their execution, especially when one future `await`s. Whether they run in *parallel* (simultaneously on different CPU cores) depends on your Tokio runtime configuration (e.g., multi-threaded scheduler) and the number of available CPU cores. * **The Power of Cancellation:** The cancellation behavior of `select!` is a defining feature. It's not just about getting the first result; it's also about efficiently managing resources by stopping work that is no longer needed. This makes `select!` invaluable for building responsive systems that don't waste cycles on superseded tasks. By mastering `join!` and `select!`, you gain fine-grained control over how your asynchronous tasks are executed and how their results are handled, leading to more performant and sophisticated Tokio applications.
join!
vs. select!
In the realm of asynchronous programming with Tokio in Rust, managing multiple concurrent operations efficiently is paramount. Tokio provides powerful macros to orchestrate these operations, and two of the most fundamental are join!
and select!
. This lesson will delve into how these macros work, their key differences, and when to use each, enabling you to write more robust and responsive asynchronous Rust applications.
join!
and select!
- The Core DifferencesAt their heart, both join!
and select!
are designed to poll multiple futures concurrently. However, their behavior, return values, and implications for the futures they manage differ significantly.
1. The join!
Macro
Purpose: join!
is used when you need to execute several asynchronous operations concurrently and wait for all of them to complete before proceeding.
Behavior: It polls all provided futures, driving them towards completion. The join!
macro itself will only complete once every single future passed to it has completed.
Return Value: Upon completion, join!
returns a tuple. This tuple contains the results of each future, in the same order that the futures were provided to the macro.
Analogy: Think of join!
as saying, "Wait for all of these results to return. I need every single one."
2. The select!
Macro
Purpose: select!
is employed when you have multiple asynchronous operations and you're interested in the result of whichever one finishes first.
Behavior: It polls all provided futures concurrently. As soon as any one of the futures completes, select!
returns.
Cancellation: This is a critical distinction: once one future completes and select!
is ready to return, all other futures that were being polled but had not yet completed are immediately cancelled. Their execution is stopped, and they are dropped. This prevents unnecessary work and resource consumption.
Return Value: select!
returns the result of the single future that completed first.
Analogy: select!
operates on the principle of, "Just give me one of the results—whichever one returns the earliest."
make
Helper FunctionTo illustrate the behavior of join!
and select!
, we'll use a simple asynchronous helper function named make
. This function simulates an asynchronous task that takes a specified amount of time to complete and then returns a predefined value.
Here's the code for our make
function:
Explanation:
The make
function is an async fn
, meaning it returns a future.
It accepts two arguments:
val
: A static string slice (&'static str
) which will be the return value of the future.
dt
: A u64
representing the duration in milliseconds for which this simulated task should "run" or "sleep."
Inside the function, tokio::time::sleep(Duration::from_millis(dt)).await
pauses the execution of this specific future for dt
milliseconds. The .await
keyword allows other tasks to run while this one is sleeping.
After the sleep duration elapses, the function returns the val
that was passed in.
This make
function will serve as our building block for creating multiple futures with varying completion times, allowing us to observe how join!
and select!
handle them.
join!
in Action: Waiting for All ResultsLet's see how the join!
macro behaves when we provide it with multiple instances of our make
future, each with a different simulated delay.
Code Setup:
We'll set up a main
function (annotated with #[tokio::main]
to run within the Tokio runtime) and use join!
to execute three make
futures concurrently.
Discussion:
We invoke join!
with three calls to make
:
make("coffee", 100)
: This future will complete after approximately 100 milliseconds.
make("green tea", 50)
: This future will complete after approximately 50 milliseconds.
make("lemonade", 20)
: This future will complete after approximately 20 milliseconds.
The join!
macro will start polling all three of these futures concurrently.
"lemonade" will finish first (after ~20ms), then "green tea" (after ~50ms from the start), and finally "coffee" (after ~100ms from the start).
However, join!
waits for all of them. Therefore, the entire join!
expression will only complete after the longest-running future, "coffee," finishes. This means the code will pause at the join!
line for approximately 100 milliseconds.
Once all futures complete, their results are collected into a tuple. We use destructuring assignment let (res1, res2, res3) = ...
to assign these results to individual variables. The order of results in the tuple matches the order of futures passed to join!
.
Expected Output:
After running this code, you'll observe output similar to the following (the exact duration might vary slightly):
This output confirms that join!
waited for all tasks, with the total time dictated by the slowest task ("coffee" at 100ms), and all results are available.
select!
in Action: Racing for the First ResultNow, let's contrast this with the select!
macro, using the same set of make
futures.
Code Setup (Continuing within the same main
function):
Discussion:
The select!
macro also takes multiple futures, but its syntax is different: pattern = future => { expression_block }
. Each branch consists of a future to poll, a pattern to bind its result if it completes first, and an expression block to execute.
We provide the same three make
futures:
make("coffee", 100)
make("green tea", 50)
make("lemonade", 20)
select!
polls all these futures concurrently.
The make("lemonade", 20)
future is the fastest, expected to complete in approximately 20 milliseconds.
As soon as "lemonade" completes:
Its result ("lemonade") is bound to val
in its corresponding branch.
The expression block for that branch is executed: println!("select!: 'lemonade' (20ms) future finished first");
and the value of val
("lemonade") is returned by this block.
The select!
macro as a whole then resolves to this value ("lemonade").
Crucially, the other two futures (make("coffee", 100)
and make("green tea", 50)
) are immediately cancelled. They do not run to completion, and their respective println!
statements within their select!
branches will not execute.
Expected Output:
The output for the select!
part will appear much faster, after approximately 20 milliseconds:
This demonstrates that select!
indeed returns as soon as the first future completes ("lemonade" in this case), and the overall operation is much quicker because it doesn't wait for the slower tasks.
select!
and Tie-Breaking: Handling Equally Fast FuturesAn interesting scenario arises when multiple futures passed to select!
might complete at roughly the same time. How does select!
choose which one "wins"?
Let's modify our select!
example so that all futures have the same simulated completion time.
Modified select!
Code:
Discussion:
Now, all three futures (make("coffee", 20)
, make("green tea", 20)
, and make("lemonade", 20)
) are set to complete after 20 milliseconds.
When select!
polls these futures, the Tokio runtime's scheduler will determine the precise order in which futures are polled and become ready. While they all should complete around the same time, slight variations in scheduling and polling can lead to one being recognized as "complete" just before the others.
The select!
macro doesn't guarantee a specific branch will be chosen if multiple branches become ready simultaneously in the same poll. It typically picks the one that completes its polling first within that round. This can lead to seemingly non-deterministic behavior if you run the code multiple times.
Output (May Vary Across Runs):
If you run this modified code several times, you might see different futures "winning":
Run 1:
Run 2:
Run 3:
This variability is normal and highlights that select!
is about getting the first result from a set of concurrently progressing tasks. If they are equally fast, any one of them might be deemed the first based on the intricacies of the runtime's polling mechanism.
join!
vs. select!
Understanding the distinct behaviors of join!
and select!
is crucial for effective asynchronous programming in Tokio.
Use join!
when:
You need the results of all spawned asynchronous operations.
Your program logic depends on the successful completion of every task in a set before it can proceed.
The total time taken will be at least as long as the slowest operation.
Use select!
when:
You are interested in the result of only the first operation to complete out of a group.
You want to race multiple tasks against each other (e.g., fetching a resource from multiple mirrors).
You need to implement timeouts (e.g., select!
an operation against a tokio::time::sleep
future).
Resource efficiency is important, as select!
cancels pending futures once one completes, preventing them from doing further work.
Concurrency vs. Parallelism: Both join!
and select!
enable futures to make progress concurrently. This means they can be interleaved in their execution, especially when one future await
s. Whether they run in parallel (simultaneously on different CPU cores) depends on your Tokio runtime configuration (e.g., multi-threaded scheduler) and the number of available CPU cores.
The Power of Cancellation: The cancellation behavior of select!
is a defining feature. It's not just about getting the first result; it's also about efficiently managing resources by stopping work that is no longer needed. This makes select!
invaluable for building responsive systems that don't waste cycles on superseded tasks.
By mastering join!
and select!
, you gain fine-grained control over how your asynchronous tasks are executed and how their results are handled, leading to more performant and sophisticated Tokio applications.
An essential breakdown of Tokio's Concurrent `join!` and `select!` Macros - Uncover how `join!` awaits all async futures, while `select!` races them, returning the first result and cancelling others. Learn to wield these Tokio macros to manage concurrent tasks for robust and responsive Rust applications.
Previous lesson
Previous
Next lesson
Next
Give us feedback
Course Overview
About the course
Introduction to the Rust programming language
Rust variables and functions
Scalar types, arrays, strings, enum, structs, vectors, and hash maps in Rust
Rust control flows: If / else, if let and let else, loop, match
Rust ownership, including borrow and references
Rust error handling
Rust Modules
Rust Traits
Last updated on July 11, 2025
Rust Developer
Rust Programming BasicsDuration: 6min
Duration: 18min
Duration: 47min
Duration: 15min
Duration: 19min
Duration: 8min
Duration: 12min
Duration: 46min
Duration: 14min
Course Overview
About the course
Introduction to the Rust programming language
Rust variables and functions
Scalar types, arrays, strings, enum, structs, vectors, and hash maps in Rust
Rust control flows: If / else, if let and let else, loop, match
Rust ownership, including borrow and references
Rust error handling
Rust Modules
Rust Traits
Last updated on July 11, 2025