The naming trap: join, join!, and JoinHandle
You are building a service that aggregates data from three APIs. You need all three responses to render the page. You also need a timeout: if the request takes longer than two seconds, you bail out and show a cached result. These two requirements map to two distinct patterns in Rust's async ecosystem. One pattern waits for everything. The other pattern races to the first finish line.
The names join and select describe these patterns, but the terminology gets messy. Rust borrows words from threading, macros, and external crates. join appears in std::thread::JoinHandle, in tokio::task::JoinHandle, and as the tokio::join! macro. select appears as tokio::select! and futures::select!. The current body of the FAQ mentions join as a method on JoinHandle for threads. That is true for synchronous code, but in async, you rarely call .join() on a handle. You await it. The confusion usually comes from mixing the macro join! with the handle method.
Clear the air first. In async Rust, join almost always refers to tokio::join! or futures::join!, which waits for multiple futures to complete. select refers to tokio::select! or futures::select!, which waits for the first future to complete. The difference isn't just about counting results. It's about cancellation, resource management, and how the runtime schedules work.
Join: waiting for the whole team
tokio::join! waits for every future you pass to it. It returns a tuple containing the results of all futures, in the same order you listed them. The macro generates code that polls all futures concurrently. If one future finishes early, join! captures the result and keeps polling the others until they are all done.
Think of join! as a group project submission. You don't hand in the assignment until every team member has finished their part. The deadline is determined by the slowest member.
use tokio::time::{sleep, Duration};
/// Fetches a string after a delay.
async fn fetch_item(id: u32) -> String {
sleep(Duration::from_millis(100 * id)).await;
format!("Item {}", id)
}
#[tokio::main]
async fn main() {
// join! waits for all futures to complete.
// The result is a tuple matching the argument order.
let (a, b, c) = tokio::join!(
fetch_item(1), // Finishes in 100ms
fetch_item(3), // Finishes in 300ms
fetch_item(2), // Finishes in 200ms
);
// The tuple order matches the macro arguments, not completion time.
println!("Results: {}, {}, {}", a, b, c);
}
The macro returns a tuple. The order of the tuple matches the order of the arguments. If you swap arguments, you swap tuple positions. This is a compile-time guarantee. You cannot accidentally mix up results at runtime. The compiler enforces the structure.
Convention aside: tokio::join! is a macro, not a function. You cannot pass a variable number of arguments. If you have a Vec of futures, join! won't work. Reach for futures::future::join_all when the collection size is dynamic.
Don't assume join! is fast. It keeps all futures alive until the last one finishes. If one future hangs, join! hangs. The macro provides no timeout mechanism. You must add that yourself if you need one.
Select: the race and the cancellation
tokio::select! races multiple futures. It waits for the first one to finish and executes the corresponding branch. The other futures are dropped immediately. This drop is the defining characteristic of select!. It isn't just about picking a winner. It's about killing the losers.
Think of select! as a fire alarm system. You don't care which sensor triggers first. You react to the first one that goes off. The other sensors are ignored. If a sensor was in the middle of a self-test, that test is aborted.
use tokio::time::{sleep, Duration};
/// Simulates a slow database query.
async fn query_db() -> &'static str {
sleep(Duration::from_secs(5)).await;
"Database result"
}
#[tokio::main]
async fn main() {
// select! races the futures.
// The first one to complete wins.
// The other futures are dropped immediately.
let result = tokio::select! {
// Branch 1: timeout wins if query takes too long.
_ = sleep(Duration::from_millis(500)) => "Timeout",
// Branch 2: query wins if it finishes fast.
data = query_db() => data,
};
println!("Outcome: {}", result);
}
The critical difference is cancellation. When select! picks a winner, it drops the other futures. Dropping a future runs its Drop implementation. If your future holds a lock, the lock is released. If it holds a file handle, the file is closed. If it holds a network connection, the connection is terminated. This behavior is a feature. It lets you implement timeouts without leaking resources. The timeout wins, the request future is dropped, and the background work stops.
Under the hood, select! generates a state machine. It polls each future in a loop. If a future returns Poll::Ready, the macro executes that branch and returns. The other futures are never polled again in this cycle. They are dropped. This drop happens before the branch code runs. The winner's result is available, but the losers are gone.
Cancellation is a feature. Design your futures to be drop-safe. If a future assumes it runs to completion, select! will break it.
Real-world pattern: timeouts and backpressure
The most common use of select! is implementing timeouts. You race a task against a timer. If the timer wins, you cancel the task. If the task wins, you get the result. This pattern is safe and efficient. The task is cancelled immediately, so you don't waste CPU cycles or hold resources longer than necessary.
use tokio::time::{sleep, Duration};
/// Simulates an HTTP request that might hang.
async fn fetch_url(url: &str) -> Result<String, String> {
// Simulate network delay.
sleep(Duration::from_secs(10)).await;
Ok(format!("Response from {}", url))
}
/// Wraps a request with a timeout.
async fn fetch_with_timeout(url: &str, timeout_ms: u64) -> Result<String, String> {
let request = fetch_url(url);
let timeout = sleep(Duration::from_millis(timeout_ms));
// Race the request against the timeout.
tokio::select! {
// If request finishes first, return the result.
result = request => result,
// If timeout finishes first, return an error.
// The request future is dropped here.
_ = timeout => Err("Request timed out".to_string()),
}
}
#[tokio::main]
async fn main() {
match fetch_with_timeout("https://example.com", 500).await {
Ok(data) => println!("Got: {}", data),
Err(e) => println!("Error: {}", e),
}
}
The fetch_url future is dropped when the timeout wins. If fetch_url had opened a TCP connection, the connection is closed. If it had allocated memory, the memory is freed. The cancellation is clean. This is why select! is preferred over manual polling loops for timeouts. The macro handles the drop logic for you.
You can also use select! for backpressure. Imagine a worker task that processes items from a channel. You want the worker to stop if a shutdown signal arrives. You race the channel receive against the shutdown signal.
use tokio::sync::mpsc;
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
let (tx, mut rx) = mpsc::channel(10);
let shutdown = sleep(Duration::from_secs(2));
// Spawn a worker that races work against shutdown.
tokio::spawn(async move {
loop {
tokio::select! {
// Process next item if available.
Some(msg) = rx.recv() => {
println!("Processing: {}", msg);
}
// Stop if shutdown timer fires.
_ = shutdown => {
println!("Shutdown signal received. Stopping.");
break;
}
}
}
});
// Send some work.
tx.send("Task 1".to_string()).await.unwrap();
tx.send("Task 2".to_string()).await.unwrap();
// Wait for worker to finish.
sleep(Duration::from_secs(3)).await;
}
The worker loop checks both branches every iteration. If the shutdown signal arrives, the loop breaks. The channel receiver is dropped. The worker exits cleanly. This pattern is robust and idiomatic.
Pitfalls: bias, types, and cancellation safety
select! has quirks that trip up developers. The first is bias. By default, select! is unbiased. If two futures are ready at the same time, the runtime picks one. The choice can depend on scheduling details. If you need deterministic behavior, use the biased flag.
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
let timer = sleep(Duration::from_millis(10));
let event = sleep(Duration::from_millis(10));
// Biased select checks branches in source order.
// If both are ready, the first branch always wins.
tokio::select! {
biased =>
_ = timer => println!("Timer won (priority)"),
_ = event => println!("Event won"),
}
}
biased adds a tiny overhead because it checks branches sequentially. It polls the first branch. If ready, it takes it. If not, it moves to the second. Use biased when you have a strict priority order. For example, you might want to check a shutdown signal before processing work, even if work is available. Without biased, the runtime might process work instead of shutting down, delaying the exit.
The second pitfall is return types. All branches in select! must return the same type. If one branch returns String and another returns &str, the compiler rejects this with E0308 (mismatched types). Wrap the results in an enum or a Result to unify the type.
// This fails with E0308.
// tokio::select! {
// _ = sleep(...) => "timeout",
// _ = fetch() => "data".to_string(),
// }
// This works.
enum Outcome {
Timeout,
Data(String),
}
let result = tokio::select! {
_ = sleep(...) => Outcome::Timeout,
data = fetch() => Outcome::Data(data),
};
The third pitfall is cancellation safety. Not all futures are safe to drop. If a future holds a lock and releases it in Drop, that's fine. If a future assumes it runs to completion and leaves state inconsistent when dropped, that's a bug. select! will drop the losers. If your future isn't cancellation-safe, select! will expose the bug.
Convention aside: The community calls this "cancellation safety." A future is cancellation-safe if dropping it has the same effect as not running it at all, or if it cleans up all side effects. Most standard library futures are cancellation-safe. Custom futures must be designed with care.
Treat cancellation as a first-class concern. If you write a future that performs a side effect, ensure the side effect is rolled back on drop.
Decision: when to use join vs select
Use tokio::join! when you need results from all concurrent tasks and the order doesn't matter.
Use tokio::select! when you want to race tasks and react to the first completion.
Use tokio::select! with the biased flag when you have a strict priority order and must check one branch before another, even if both are ready.
Use JoinHandle::await when you have a single spawned task and simply need to wait for its result.
Reach for futures::future::join_all when you have a dynamic collection of futures, like a Vec, and need to wait for every item.
Reach for tokio::select! in a loop when you need to handle multiple streams of events, like a worker that processes messages and responds to shutdown signals.
Don't use join! for timeouts. It waits for everything. The timeout won't cancel the slow task. Use select! for timeouts. The timeout cancels the slow task.
Don't use select! when you need all results. It drops the losers. You lose their data. Use join! when you need all results.
Cancellation is the price of admission for select. If your future doesn't clean up on drop, select will leave a mess.