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 valuejoin() returns Result<T, Box<dyn Any>> containing the thread's return value
  • Panic propagation — If the thread panicked, join() returns Err with 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 Err from join()
  • Use downcast_ref() to extract panic message
  • JoinHandle implements Send and Sync
  • 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