10.2 Creating Threads

Creating Threads
Section titled “Creating Threads”Rust’s standard library provides the std::thread module for working with threads. The primary function for creating a new thread is thread::spawn().
Spawning a New Thread
Section titled “Spawning a New Thread”The thread::spawn() function takes a closure (an anonymous function — remember C++ lambda functions) as an argument. This closure (|| { ... }) contains the code that the new thread will execute.
Example: Basic Thread Creation
use std::thread;use std::time::Duration;
fn main() { // Spawn a new thread thread::spawn(|| { // this is the closure start for i in 1..5 { println!("Spawned thread: {}", i); thread::sleep(Duration::from_millis(1)); // Pause for a short duration } }); // end of closure
// Main thread continues execution for i in 1..3 { println!("Main thread: {}", i); thread::sleep(Duration::from_millis(1)); // Pause for a short duration }}Here’s a brief explanation of this code example:
use std::thread;: This line imports thethreadmodule from Rust’s standard library, which provides functionality for working with threads.use std::time::Duration;: This line imports theDurationtype from thetimemodule, used for specifying sleep durations.thread::spawn(|| { ... });:- This is the core of the example.
thread::spawn()creates a new, separate thread of execution. - The
|| { ... }is a closure (an anonymous function) that contains the code to be executed by this new thread. - Inside the spawned thread’s closure, a loop prints “Spawned thread: [number]” from 1 to 4, with a 1-millisecond pause (
thread::sleep) between each print.
- This is the core of the example.
- Main Thread Loop (
for i in 1..3 { ... }):- After spawning the new thread, the
mainfunction (which runs on the main thread of the program) continues its own execution. - It prints “Main thread: [number]” from 1 to 2, also with a 1-millisecond pause between each print.
- After spawning the new thread, the
When you run this code, the output might vary each time because the operating system schedules threads in an unpredictable manner.
Main thread: 1Spawned thread: 1Spawned thread: 2Main thread: 2Spawned thread: 3Because the two loops (one in the spawned thread, one in the main thread) run concurrently (at the same time), their output will be interleaved. You’ll likely see interleaved output from both the “Main thread” and the “Spawned thread.” as follows:
Main thread: 1Spawned thread: 1Main thread: 2Spawned thread: 2A crucial detail not shown in this specific example is that the main function doesn’t wait for the spawned thread to finish (you will notice this in the second output). If the main thread completes its execution before the spawned thread is done, the spawned thread will be terminated prematurely. **For real-world applications, you’d typically use join() **on the handle returned by thread::spawn() to ensure the main thread waits for the spawned thread to complete its work.
Ensuring Thread Completion with JoinHandle
Section titled “Ensuring Thread Completion with JoinHandle”By default, if the main thread finishes its execution, the entire program exits, terminating any spawned threads regardless of whether they have completed their tasks. To ensure a spawned thread finishes its work, thread::spawn() returns a JoinHandle. You can call the join() method on this handle, which will block the current thread (e.g., the main thread) until the spawned thread completes.
Example: Using JoinHandle
use std::thread;use std::time::Duration;
fn main() { let join_handle = thread::spawn(|| { for i in 1..5 { println!("Spawned thread: {}", i); thread::sleep(Duration::from_millis(1)); } });
for i in 1..3 { println!("Main thread: {}", i); thread::sleep(Duration::from_millis(1)); }
// Wait for the spawned thread to complete join_handle.join().unwrap(); // unwrap() is used here for simplicity println!("Spawned thread has finished.");}This modified code fixes the problem of the spawned thread potentially being terminated prematurely by the main thread. Here’s how:
let join_handle = thread::spawn(|| { ... });:- When
thread::spawn()is called, it returns aJoinHandle. Thisjoin_handleis a token or a “ticket” that represents the execution of the newly created thread.
- When
join_handle.join().unwrap();:- This is the crucial line. By calling
.join()on thejoin_handle, the main thread is instructed to pause its own execution and wait until the spawned thread (represented byjoin_handle) has completed all its work. - Once the spawned thread finishes its loop and the closure returns, the
join()call will complete, and the main thread will resume. .unwrap()is used here to handle theResulttype returned byjoin(). If the spawned thread panics,join()would return anErr. For simplicity in this example,unwrap()just extracts theOkvalue or panics if there’s an error.
- This is the crucial line. By calling
In essence, the join_handle.join() call creates a synchronisation point, guaranteeing that the main thread will not exit until the spawned thread has finished its execution, thus preventing the spawned thread from being cut short.
Unlike the previous example, this gives a consistent output each time it is executed:
Main thread: 1Spawned thread: 1Spawned thread: 2Main thread: 2Spawned thread: 3Spawned thread: 4Spawned thread has finished.Scoped Threads
Section titled “Scoped Threads”Standard thread::spawn requires that any data moved into the thread has a 'static lifetime (it must own its data or point to global data). This is because the compiler cannot know for sure how long the spawned thread will run, so it must ensure the data stays valid indefinitely. This often forces the use of Arc even for simple local tasks.
Scoped threads (thread::scope) solve this by creating a scope where all spawned threads are guaranteed to be joined before the scope ends. This allows threads to borrow data from the parent thread’s stack safely, eliminating the need for reference counting.
use std::thread;
fn main() { let mut container = vec![1, 2, 3]; let mut x = 0;
thread::scope(|s| { // Spawn a thread that borrows 'container' s.spawn(|| { println!("Borrowing container: {:?}", container); });
// Spawn another thread that modifies 'x' s.spawn(|| { x += 1; // This is only possible because of scoping! }); });
// Outside the scope, all threads are guaranteed to have finished. println!("Final x: {}, container: {:?}", x, container);}Scoped threads are highly efficient for edge applications where you want to perform a quick parallel computation on local data without the overhead of heap-allocating Arc containers.
Passing Data to Threads with move Closures
Section titled “Passing Data to Threads with move Closures”When a spawned thread needs to use data from its parent thread’s scope, Rust’s ownership and borrowing rules come into play. If the spawned thread’s closure borrows data that might outlive the parent function, the compiler will prevent it. To safely transfer ownership of variables into a new thread, you use the move keyword with the closure.
Example: move Closure for Ownership Transfer
use std::thread;
fn main() { let greeting = String::from("Hello from the main thread!");
// Without move, this would cause a compile-time error: // "closure may outlive the current function, but it borrows greeting" let handle = thread::spawn(move || { // move transfers ownership of greeting to the closure println!("{}", greeting); // greeting is now owned by the spawned thread's closure });
// println!("{}", greeting); // This line would now cause a compile-time error: // "borrow of moved value: greeting" handle.join().unwrap();}By using move, the greeting String is moved into the closure, and thus into the new thread. This ensures that the spawned thread has exclusive ownership of the data it needs, preventing potential use-after-free bugs if the original greeting variable were to go out of scope before the thread finished. This code will give the output:
Hello from the main thread!If you wished to return greeting back from the closure to the code so that the code above worked, you would need to do something like:
use std::thread;
fn main() { let greeting = String::from("Hello from the main thread!");
// Without move, this would cause a compile-time error: // "closure may outlive the current function, but it borrows greeting" let handle = thread::spawn(move || { println!("{}", greeting); greeting // return ownership of the String (no semicolon) });
let returned_greeting = handle.join().unwrap(); println!("{}", returned_greeting); // This now works correctly as ownership restored}This gives the expected output where the message is displayed once in the thread and once again in main():
Hello from the main thread!Hello from the main thread!🧩Knowledge Check
Section titled “🧩Knowledge Check”Match the Thread Concepts
What happens to a spawned thread if the main thread returns from main() before join() is called on the thread's JoinHandle?
A function creates a local Vec<i32> and wants to spawn a thread that reads from it, then use the Vec again after the thread finishes. Which approach avoids heap-allocating an Arc?
© 2026 Derek Molloy, Dublin City University. All rights reserved.