22.5 Creating Threads in Rust
22.5.1 std::thread::spawn
The standard library's std::thread::spawn
function spawns new operating system threads. It takes an FnOnce closure or function as an argument and returns a JoinHandle<T>
, representing a handle to the spawned thread:
use std::thread; use std::time::Duration; fn main() { let handle = thread::spawn(|| { for i in 1..10 { println!("Hello from the spawned thread {i}!"); thread::sleep(Duration::from_millis(1)); } }); thread::sleep(Duration::from_millis(5)); println!("Hello from the main thread!"); // Wait for the spawned thread to finish handle.join().expect("The thread being joined has panicked"); }
In this example, the main thread and the spawned thread run concurrently. Spawning new threads is sometimes called 'spawn-join concurrency' because the spawned threads are typically joined again later. Calling thread::sleep()
temporarily suspends a thread, letting other threads run. When you run this program, you will likely see about five lines of text from the spawned thread, then the main thread prints its message, and finally, the spawned thread completes its remaining lines. Exact interleavings depend on your system and can vary from run to run, as the operating system governs thread scheduling.
The JoinHandle<T>
returned by thread::spawn
can not only be used to check if the spawned thread has panicked, but also to return the closure's result (of inferred type) back to the parent thread:
use std::thread; fn main() { let arg = 100; let handle = thread::spawn(move || { let mut sum = 0; for j in 1..=arg { sum += j; } sum }); let thread_res = handle.join().expect("The thread being joined has panicked"); println!("{thread_res}"); }
Important:
- The
println!()
macro in Rust locks the standard output stream (stdout) before writing. This lock is held until the macro completes, preventing other threads from interleaving in the middle of a singleprintln!()
call. - If the main thread terminates before joining spawned threads, the process stops immediately, killing any running threads. Therefore, always call
join()
or use other synchronization methods to ensure proper thread completion. - The
join()
method blocks the caller until the associated thread finishes.
join()
returns astd::thread::Result
that’s an error if that thread panicked and can contain the optional result of the executed closure or function.
Parallel vs. Overlapping
When multiple CPU cores are available, the operating system can schedule each thread onto a separate core, allowing true parallel execution. Otherwise, threads share CPU time (time-slicing). In either case, each Rust thread directly corresponds to an OS thread that the operating system schedules preemptively.
Creating threads is not free. It involves allocating memory for the thread stack, initializing thread-local data, and interacting with the OS scheduler. If you have many short-lived tasks, this overhead can significantly impact performance. Thread pools are a common solution. Instead of creating and destroying threads for each task, a pool maintains a fixed number of worker threads that can be reused. The Rayon crate (discussed later) provides a high-performance thread pool using work stealing, where idle threads can steal tasks from busier threads, balancing the workload more effectively. In special data processing scenarios (e.g., file processing), where each data object is processed independently with a separate thread, it can be useful to limit the number of spawned threads or to reuse already spawned threads. Crates like Threadpool or Rayon provide such pools with a customizable number of threads.
Note: In Rust, a panic is safe and limited to the thread where it occurs. Thread boundaries act as a firewall, preventing a panic from automatically propagating to other threads. Instead, a panic in one thread is communicated as an error Result
to any dependent threads, allowing the program to handle the error and recover gracefully.
Final note: In the examples above, we used a move
closure that captures parameters inside the closure body. This ensures that all the child thread's parameters remain valid for the thread's lifetime and are not dropped prematurely when the parent thread finishes. For small parameters, or parameters specific to each thread, a move
is sufficient. However, there might be cases where all the spawned threads need access to some large, immutable data, such as a hash map serving as a (global) database. In this scenario, you can wrap your data in an Arc<T>
smart pointer and pass a cloned reference to each thread. Remember that Arc<T>
is the thread-safe variant of Rc<T>
, and cloning Arc<T>
simply increases its reference count without copying the underlying data. Arc<T>
keeps the shared data alive as long as at least one thread uses it, and because the data is immutable, there are no data races.
22.5.2 Thread Names and the Builder Pattern
For additional control (e.g., naming a thread or setting its stack size), you can use the thread::Builder
API. However, for most use cases, thread::spawn
is sufficient.