How do I work with JoinHandle in Rust?
Walkthrough
A JoinHandle is returned by thread::spawn() and represents a handle to a spawned thread. It allows you to wait for the thread to complete and retrieve its return value. JoinHandle is essential for managing thread lifecycles and collecting results.
Key characteristics:
- Join on drop — If not explicitly joined, the handle joins when dropped (blocking)
- Return value —
join()returnsResult<T, Box<dyn Any>>containing the thread's return value - Panic propagation — If the thread panicked,
join()returnsErrwith the panic payload - Thread ID — Access the underlying thread's ID via
thread().id()
The JoinHandle<T> type is the primary way to interact with spawned threads after creation. It implements Send and Sync, so handles can be shared between threads.
Code Examples
Basic JoinHandle Usage
use std::thread;
use std::time::Duration;
fn main() {
// spawn returns a JoinHandle<T>
let handle: thread::JoinHandle<i32> = thread::spawn(|| {
println!("Thread working...");
thread::sleep(Duration::from_millis(100));
42 // Return value
});
// Do other work while thread runs
println!("Main thread doing other work");
// join() blocks until thread completes, returns Result<T, ...>
let result = handle.join().unwrap();
println!("Thread returned: {}", result);
}Handling Thread Panics
use std::thread;
fn main() {
let handle = thread::spawn(|| {
panic!("Something went wrong in the thread!");
});
// join() returns Err if thread panicked
match handle.join() {
Ok(_) => println!("Thread completed successfully"),
Err(e) => {
// e is Box<dyn Any + Send>
if let Some(msg) = e.downcast_ref::<&str>() {
println!("Thread panicked: {}", msg);
} else if let Some(msg) = e.downcast_ref::<String>() {
println!("Thread panicked: {}", msg);
} else {
println!("Thread panicked with unknown type");
}
}
}
}JoinHandle with Return Values
use std::thread;
fn main() {
let handles: Vec<thread::JoinHandle<i32>> = (0..5)
.map(|i| {
thread::spawn(move || {
// Simulate computation
let result = i * i;
println!("Thread {} computed {}", i, result);
result
})
})
.collect();
// Collect all results
let results: Vec<i32> = handles
.into_iter()
.map(|h| h.join().unwrap())
.collect();
println!("All results: {:?}", results);
println!("Sum: {}", results.iter().sum::<i32>());
}Checking Thread State
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
thread::sleep(Duration::from_millis(500));
println!("Thread completed");
});
// is_finished() checks if thread has terminated (unstable - use join_timeout pattern instead)
// For stable Rust, we use try_join or timing patterns
println!("Waiting for thread...");
// We can check periodically using non-blocking patterns
// Standard JoinHandle doesn't have is_finished() in stable
// Alternative: use a channel to signal completion
handle.join().unwrap();
println!("Thread has finished");
}JoinHandle Thread Information
use std::thread;
fn main() {
let handle = thread::spawn(|| {
println!("Inside spawned thread");
thread::current().id()
});
// Access thread ID before joining
let thread_id = handle.thread().id();
println!("Spawned thread ID: {:?}", thread_id);
// Join and get the return value
let inner_id = handle.join().unwrap();
println!("Thread returned its ID: {:?}", inner_id);
}Multiple Threads with Timeout Pattern
use std::thread;
use std::sync::mpsc;
use std::time::Duration;
fn main() {
let (tx, rx) = mpsc::channel();
let handle = thread::spawn(move || {
thread::sleep(Duration::from_secs(2));
tx.send(42).unwrap();
});
// Wait with timeout using channel
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(value) => println!("Got value: {}", value),
Err(_) => {
println!("Timeout - thread still running");
// We can still join later
handle.join().unwrap();
}
}
}Forgetting a JoinHandle
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
for i in 0..3 {
println!("Fire and forget: {}", i);
thread::sleep(Duration::from_millis(100));
}
});
// thread::spawn returns JoinHandle
// If we don't care about the result, we can "forget" it
// The thread will still run to completion
// But dropping without joining will block until completion!
// To truly fire-and-forget without blocking on drop:
handle.join().unwrap(); // Must explicitly join if we want non-blocking behavior
// Alternative: use thread::spawn with detached semantics (requires crate)
// Or just don't store the handle and let it drop (will block!)
}Error Handling Best Practices
use std::thread;
fn spawn_worker(id: u32) -> thread::JoinHandle<Result<String, String>> {
thread::spawn(move || {
if id == 3 {
Err(format!("Worker {} failed", id))
} else {
Ok(format!("Worker {} succeeded", id))
}
})
}
fn main() {
let handles: Vec<_> = (0..5).map(spawn_worker).collect();
let mut successes = Vec::new();
let mut failures = Vec::new();
for (id, handle) in handles.into_iter().enumerate() {
match handle.join() {
Ok(Ok(msg)) => successes.push(msg),
Ok(Err(e)) => failures.push(e),
Err(panic) => {
if let Some(msg) = panic.downcast_ref::<&str>() {
failures.push(format!("Panic: {}", msg));
} else {
failures.push("Unknown panic".to_string());
}
}
}
}
println!("Successes: {:?}", successes);
println!("Failures: {:?}", failures);
}Sharing JoinHandles Between Threads
use std::thread;
use std::sync::{Arc, Mutex};
fn main() {
// JoinHandle is Send + Sync, so can be shared
let handles = Arc::new(Mutex::new(Vec::<thread::JoinHandle<i32>>::new()));
let h1 = Arc::clone(&handles);
let producer1 = thread::spawn(move || {
let handle = thread::spawn(|| 10);
h1.lock().unwrap().push(handle);
});
let h2 = Arc::clone(&handles);
let producer2 = thread::spawn(move || {
let handle = thread::spawn(|| 20);
h2.lock().unwrap().push(handle);
});
producer1.join().unwrap();
producer2.join().unwrap();
// Now join all stored handles
let handles = Arc::try_unwrap(handles)
.expect("All references dropped")
.into_inner()
.unwrap();
for handle in handles {
println!("Result: {}", handle.join().unwrap());
}
}JoinHandle vs Scoped Threads
use std::thread;
use std::sync::Arc;
fn main() {
let data = vec![1, 2, 3, 4, 5];
// Regular JoinHandle requires 'static data
println!("=== Regular JoinHandle ===");
{
let data = Arc::new(data.clone());
let handles: Vec<_> = (0..5)
.map(|i| {
let data = Arc::clone(&data);
thread::spawn(move || data[i] * 2)
})
.collect();
for handle in handles {
println!("Result: {}", handle.join().unwrap());
}
}
// Scoped threads can borrow
println!("\n=== Scoped Thread ===");
{
thread::scope(|s| {
let handles: Vec<_> = (0..5)
.map(|i| s.spawn(move || data[i] * 2))
.collect();
for handle in handles {
println!("Result: {}", handle.join().unwrap());
}
});
}
}Implementing a Thread Pool with JoinHandle
use std::thread;
use std::sync::{mpsc, Arc, Mutex};
type Job = Box<dyn FnOnce() + Send + 'static>;
struct ThreadPool {
workers: Vec<Worker>,
sender: Option<mpsc::Sender<Job>>,
}
struct Worker {
handle: Option<thread::JoinHandle<()>>,
}
impl Worker {
fn new(receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Self {
let handle = thread::spawn(move || {
loop {
let job = receiver.lock().unwrap().recv();
match job {
Ok(job) => job(),
Err(_) => break, // Channel closed
}
}
});
Self { handle: Some(handle) }
}
}
impl ThreadPool {
fn new(size: usize) -> Self {
let (sender, receiver) = mpsc::channel();
let receiver = Arc::new(Mutex::new(receiver));
let workers = (0..size)
.map(|_| Worker::new(Arc::clone(&receiver)))
.collect();
Self { sender: Some(sender), workers }
}
fn execute<F: FnOnce() + Send + 'static>(&self, f: F) {
self.sender.as_ref().unwrap().send(Box::new(f)).unwrap();
}
}
impl Drop for ThreadPool {
fn drop(&mut self) {
// Close channel
drop(self.sender.take());
// Wait for all workers to finish
for worker in &mut self.workers {
if let Some(handle) = worker.handle.take() {
handle.join().unwrap();
}
}
}
}
fn main() {
let pool = ThreadPool::new(4);
for i in 0..8 {
pool.execute(move || {
println!("Task {} running on thread {:?}",
i, thread::current().id());
});
}
// Pool drops and joins all workers
}Building a Future-like Pattern
use std::thread;
use std::sync::{Arc, Mutex};
use std::time::Duration;
struct AsyncResult<T> {
handle: thread::JoinHandle<T>,
completed: Arc<Mutex<bool>>,
}
impl<T: Send + 'static> AsyncResult<T> {
fn new<F>(f: F) -> Self
where
F: FnOnce() -> T + Send + 'static,
{
let completed = Arc::new(Mutex::new(false));
let completed_clone = Arc::clone(&completed);
let handle = thread::spawn(move || {
let result = f();
*completed_clone.lock().unwrap() = true;
result
});
Self { handle, completed }
}
fn is_completed(&self) -> bool {
*self.completed.lock().unwrap()
}
fn wait(self) -> T {
self.handle.join().unwrap()
}
}
fn main() {
let result = AsyncResult::new(|| {
thread::sleep(Duration::from_secs(1));
42
});
while !result.is_completed() {
println!("Still computing...");
thread::sleep(Duration::from_millis(200));
}
println!("Result: {}", result.wait());
}Cancellation Pattern
use std::thread;
use std::sync::{Arc, atomic::{AtomicBool, Ordering}};
use std::time::Duration;
fn main() {
let cancelled = Arc::new(AtomicBool::new(false));
let cancelled_clone = Arc::clone(&cancelled);
let handle = thread::spawn(move || {
for i in 0..100 {
if cancelled_clone.load(Ordering::Relaxed) {
println!("Thread cancelled at iteration {}", i);
return "cancelled";
}
thread::sleep(Duration::from_millis(50));
}
"completed"
});
// Simulate user cancellation
thread::sleep(Duration::from_millis(200));
cancelled.store(true, Ordering::Relaxed);
let result = handle.join().unwrap();
println!("Thread result: {}", result);
}Summary
| Method | Description |
|---|---|
join() |
Block until thread completes, return Result<T, Box<dyn Any>> |
thread() |
Get reference to the underlying Thread |
thread().id() |
Get the thread's ID |
JoinHandle Lifecycle:
spawn() → Running → Completed/panicked
↓ ↓ ↓
JoinHandle JoinHandle join() returns Ok/Err
Common Patterns:
| Pattern | Description |
|---|---|
| Collect handles | Store handles in a Vec, join all later |
| Check result | handle.join() returns thread's value |
| Handle panic | Check join() Err for panic payload |
| Fire-and-forget | Drop handle (blocks) or join explicitly |
Key Points:
join()blocks the calling thread until the spawned thread finishes- Thread panics are caught and returned as
Errfromjoin() - Use
downcast_ref()to extract panic message - JoinHandle implements
SendandSync - Dropping a JoinHandle blocks until the thread completes
- For non-blocking checks, use channels or atomic flags
- Scoped threads provide an alternative with borrowed data support
