Skip to content

Search is only available in production builds. Try building and previewing the site to test it out locally.

10.2 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().

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:

  1. use std::thread;: This line imports the thread module from Rust’s standard library, which provides functionality for working with threads.
  2. use std::time::Duration;: This line imports the Duration type from the time module, used for specifying sleep durations.
  3. 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.
  4. Main Thread Loop (for i in 1..3 { ... }):
    • After spawning the new thread, the main function (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.

When you run this code, the output might vary each time because the operating system schedules threads in an unpredictable manner.

Main thread: 1
Spawned thread: 1
Spawned thread: 2
Main thread: 2
Spawned thread: 3

Because 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: 1
Spawned thread: 1
Main thread: 2
Spawned thread: 2

A 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:

  1. let join_handle = thread::spawn(|| { ... });:
    • When thread::spawn() is called, it returns a JoinHandle. This join_handle is a token or a “ticket” that represents the execution of the newly created thread.
  2. join_handle.join().unwrap();:
    • This is the crucial line. By calling .join() on the join_handle, the main thread is instructed to pause its own execution and wait until the spawned thread (represented by join_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 the Result type returned by join(). If the spawned thread panics, join() would return an Err. For simplicity in this example, unwrap() just extracts the Ok value or panics if there’s an error.

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: 1
Spawned thread: 1
Spawned thread: 2
Main thread: 2
Spawned thread: 3
Spawned thread: 4
Spawned thread has finished.

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!
Concept Match

Match the Thread Concepts

Drag each definition into its matching concept slot, then click Submit. Tap × to return a placed card to the pool.

thread::spawn
drag a definition here…
JoinHandle
drag a definition here…
join()
drag a definition here…
move closure
drag a definition here…
thread::scope
drag a definition here…

Definition Pool

Creates a region in which spawned threads may borrow local stack data; all threads in the scope are guaranteed to finish before the scope returns.
Creates a new OS thread that runs the provided closure concurrently; returns a JoinHandle immediately without waiting for the thread to start.
Blocks the calling thread until the associated spawned thread completes, preventing premature termination and allowing its return value to be retrieved.
A closure prefixed with move that takes ownership of all captured variables, allowing them to be used safely inside a thread that may outlive the calling scope.
A value returned by thread::spawn that represents a running thread; calling join() on it blocks the current thread until that thread finishes.
Quiz
Select 0/1

What happens to a spawned thread if the main thread returns from main() before join() is called on the thread's JoinHandle?

Quiz
Select 0/1

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?