22.4 Creating and Managing OS Threads
Rust’s standard library module std::thread
provides the API for working with OS threads. Conceptually, it’s similar to POSIX threads (pthreads) in C or std::thread
in C++, but Rust’s ownership and lifetime rules provide stronger compile-time safety guarantees.
22.4.1 Spawning Threads with std::thread::spawn
The core function for creating a new thread is std::thread::spawn
. It accepts a closure (or function pointer) containing the code the new thread will execute. The closure must have a 'static
lifetime, meaning it cannot capture references to local variables in the spawning thread’s stack frame unless those variables themselves have a 'static
lifetime (like string literals or leaked allocations). This restriction is crucial for preventing use-after-free errors if the spawning thread finishes before the spawned thread. To transfer ownership of data from the spawning thread to the new thread, use a move
closure.
spawn
returns a JoinHandle<T>
, where T
is the return type of the closure. The JoinHandle
allows the creating thread to wait for the spawned thread to complete and retrieve its result.
use std::thread; use std::time::Duration; fn main() { // Spawn a new thread let handle: thread::JoinHandle<()> = thread::spawn(|| { for i in 1..5 { println!("Hi number {} from the spawned thread!", i); thread::sleep(Duration::from_millis(1)); } // No return value, so JoinHandle<()> }); // Code in the main thread runs concurrently for i in 1..3 { println!("Hi number {} from the main thread!", i); thread::sleep(Duration::from_millis(1)); } // Wait for the spawned thread to finish. // join() blocks the current thread until the spawned thread terminates. // It returns Result<T, Box<dyn Any + Send + 'static>>. // Ok(T) contains the return value of the thread's closure. // Err contains the panic payload if the thread panicked. // We use expect() here for simplicity, assuming success. handle.join().expect("Spawned thread panicked"); println!("Spawned thread finished."); }
Key Points:
- The closure passed to
spawn
runs concurrently with the calling thread (main
). thread::sleep
pauses the current thread, allowing the OS to schedule others.handle.join()
blocks the calling thread until the spawned thread completes. It’s analogous topthread_join
in C orthread::join
in C++. TheResult
return type provides integrated panic handling.
To pass data to a thread or return data from it, use move
closures and return values:
use std::thread; fn main() { let data = vec![1, 2, 3]; // The 'move' keyword transfers ownership of 'data' into the closure. // The closure now owns 'data'. let handle = thread::spawn(move || { // This closure requires 'static lifetime because spawn creates // a thread that can outlive the main function scope without join(). // 'move' ensures captured variables (like data) are owned, // satisfying the 'static requirement for owned types. let sum: i32 = data.iter().sum(); println!("Spawned thread processing data (length {})...", data.len()); sum // Return the sum }); // Accessing 'data' here in main thread is a compile-time error // because ownership was moved to the spawned thread's closure. // # println!("{:?}", data); // Uncommenting causes compile error match handle.join() { Ok(result) => { println!("Sum calculated by spawned thread: {}", result); } Err(e) => { // The error 'e' is Box<dyn Any + Send>, representing the panic value. eprintln!("Spawned thread panicked!"); // You could try to downcast 'e' to a specific type if needed. } } }
The 'static
lifetime requirement for spawn
sometimes necessitates using techniques like Arc
(discussed later) to share data that needs to be accessed by both the parent and child threads, or using scoped threads (also discussed later) if borrowing is sufficient and the child thread is guaranteed to finish before the data goes out of scope.
Tip: Directly spawning OS threads can be resource-intensive. For managing many small, independent tasks, consider using a thread pool. Crates like
rayon
(covered later) provide an implicit global thread pool, while others likethreadpool
allow explicit pool creation and management.
22.4.2 Configuring Threads with Builder
The std::thread::Builder
allows customizing thread properties like name and stack size before spawning.
use std::thread; use std::time::Duration; fn main() { let builder = thread::Builder::new() .name("worker-alpha".into()) // Set a descriptive thread name .stack_size(32 * 1024); // Request a 32 KiB stack (OS may enforce minimum/adjust) // Use builder.spawn instead of thread::spawn let handle = builder.spawn(|| { let current_thread = thread::current(); println!("Thread {:?} starting work.", current_thread.name()); // Perform work... thread::sleep(Duration::from_millis(100)); println!("Thread {:?} finished.", current_thread.name()); 42 // Return a value }).expect("Failed to spawn thread"); // Builder::spawn can fail (e.g., stack size too small) let result = handle.join().expect("Worker thread panicked"); println!("Worker thread returned: {}", result); }
Setting thread names is very helpful for debugging and monitoring concurrent applications, as tools like htop
, debuggers (GDB, LLDB), and profilers can display these names. Adjusting stack size is less common but might be needed for threads with deep recursion or large stack-allocated data structures. Use custom stack sizes judiciously, as the default is usually adequate and overallocating wastes memory.