Threads with std::thread
Every modern computer has multiple CPU cores, and Rust’s standard library gives you direct access to real operating-system threads through the std::thread module. Spawning a thread lets a program run two or more pieces of code truly in parallel, which can dramatically speed up CPU-bound work like image processing, data crunching, or simulations. What makes Rust’s threading model special is that the same ownership and borrowing rules that prevent memory bugs in single-threaded code also prevent data races between threads — and the compiler checks this before the program ever runs.
Overview: How Threads Work in Rust
A thread is an independent sequence of instructions that the operating system can schedule to run on a CPU core alongside other threads. When you call thread::spawn, Rust asks the OS to create a brand-new native thread (a pthread on Linux/macOS, or a Windows thread on Windows) with its own stack, and hands it a closure to run. That new thread starts executing immediately and independently of the thread that spawned it: there is no guarantee about which one finishes first, or which one runs which line at any given moment.
This independence is exactly what makes threads powerful (real parallelism) and exactly what makes them dangerous in most languages: if two threads read and write the same piece of memory at the same time without coordination, you get a data race — a bug that can corrupt data, crash the program, or behave differently every time you run it. Rust’s answer is to reuse the same tool it already uses to prevent use-after-free and dangling pointers: ownership.
Think of a value’s owner as the one piece of code allowed to use it. When you spawn a thread with a closure marked move, you are not copying a reference to your data into that thread — you are transferring ownership of the data itself, exactly the way ownership transfers when you assign one variable to another. Once the spawned thread owns the data, the code that called spawn can no longer touch it; the compiler enforces this the same way it enforces any other move. Because only one piece of code can ever own a value at a time, two threads can never simultaneously mutate the same data through ownership alone — the compiler will not let you write that program.
This is why thread::spawn has a strict requirement: the closure you pass to it must be 'static (it cannot borrow any data that might not outlive the thread) and must implement Send (a marker trait meaning “safe to transfer to another thread”). Almost every common type is Send; the few that are not, such as Rc<T> (which uses a non-atomic reference count), are exactly the types that would be unsafe to share across threads, and the compiler rejects them at the call site instead of letting the bug surface later.
When multiple threads need to share the same data rather than one thread owning it exclusively, ownership alone is not enough — you need shared ownership plus synchronized access. That is what Arc<T> (an atomically reference-counted pointer) and Mutex<T> (a mutual-exclusion lock) are for, and you will see both later in this lesson.
Syntax
The core API you will use for almost every thread is small:
thread::spawn(closure) -> JoinHandle<T>
where closure: FnOnce() -> T + Send + 'static,
T: Send + 'static
handle.join() -> Result<T, Box<dyn Any + Send>>
| Part | Meaning |
|---|---|
thread::spawn |
Creates a new OS thread and immediately begins running the given closure on it. |
move || { ... } |
The closure passed to spawn; usually marked move so it takes ownership of any variables it uses instead of borrowing them. |
JoinHandle<T> |
An owned handle to the spawned thread, returned immediately — spawning does not block. T is whatever type the closure returns. |
handle.join() |
Blocks the calling thread until the spawned thread finishes, then returns its result as a Result<T, ...>. |
Err from join() |
Returned only if the spawned thread panicked; the error payload is the value passed to panic!. |
Examples
Example 1: Spawning a Single Thread
The simplest use of std::thread is to run one closure on a new thread and wait for its result with .join().
use std::thread;
fn main() {
let handle = thread::spawn(|| {
let mut sum = 0;
for i in 1..=5 {
sum += i;
}
sum
});
let result = handle.join().unwrap();
println!("Sum computed in a thread: {}", result);
}
Output:
Sum computed in a thread: 15
The closure passed to thread::spawn runs on its own OS thread and computes the sum of 1 through 5. Because the closure does not capture any outside variable, it does not need move. handle.join().unwrap() blocks the main thread until the spawned thread finishes and then unwraps its return value — here it is safe to unwrap() because a thread that just adds integers cannot panic.
Example 2: Moving Data Into a Thread
Most useful threads need to work with data created outside the closure. Marking the closure move transfers ownership of that data into the thread.
use std::thread;
fn main() {
let data = String::from("hello from main");
let handle = thread::spawn(move || {
format!("thread received: {}", data)
});
let message = handle.join().unwrap();
println!("{}", message);
}
Output:
thread received: hello from main
Here data is a String, which does not implement Copy. Without move, the closure would try to borrow data, and the compiler would reject the program because a borrow cannot outlive the function it borrows from, but a spawned thread might. With move, ownership of data transfers into the closure — from that point on, main can no longer use data at all.
Example 3: Spawning Multiple Threads and Collecting Results
A common pattern is to spawn several threads, store their JoinHandles, and then join every one of them in order.
use std::thread;
fn main() {
let mut handles = Vec::new();
for i in 0..5 {
let handle = thread::spawn(move || i * i);
handles.push(handle);
}
let mut results = Vec::new();
for handle in handles {
results.push(handle.join().unwrap());
}
println!("Squares: {:?}", results);
}
Output:
Squares: [0, 1, 4, 9, 16]
Five threads are spawned in a loop, each capturing its own copy of i (an i32, which implements Copy, so move copies it rather than transferring a shared owner). The actual order in which the five threads finish running is not guaranteed by the OS. The output order is still deterministic here, though, because the code joins the handles in the same order they were pushed — handles[0] is always joined before handles[1], regardless of which thread the scheduler happened to finish first.
Example 4: Sharing State Safely with Arc and Mutex
When multiple threads need to mutate the same piece of data, not each get their own copy, ownership alone cannot express that. Arc<T> gives multiple threads shared ownership through atomic reference counting, and Mutex<T> ensures only one thread can access the data at a time.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = Vec::new();
for _ in 0..10 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Final count: {}", *counter.lock().unwrap());
}
Output:
Final count: 10
Arc::clone increments an atomic counter and hands out a new handle to the same underlying Mutex<i32>; it does not deep-copy the integer. Each of the ten threads locks the mutex, which blocks if another thread currently holds the lock, increments the value, and then releases the lock automatically when num goes out of scope at the end of the closure. Joining every handle before reading the final value guarantees all ten increments have happened.
How It Works Step by Step
Walking through Example 2 in detail shows exactly what the compiler and runtime do:
datais created on the main thread’s stack and owned bymain.thread::spawn(move || ...)is called. Because the closure ismove, the compiler transfers ownership ofdatainto the closure’s captured environment. This is a compile-time bookkeeping change, not a runtime copy — for aString, only the pointer, length, and capacity move, not the heap bytes.- The compiler checks that the closure and everything it captured are
Sendand'static.StringisSendand owns its data outright, so it passes. - At runtime,
thread::spawnasks the OS to create a new thread, hands it the closure (including the owneddata), and returns aJoinHandle<String>immediately —spawnitself does not wait for the new thread to do anything. - The new thread runs the closure body independently, builds a
Stringwithformat!, and thatStringbecomes the closure’s return value. handle.join()blocks the main thread until the spawned thread finishes, then moves the closure’s return value out to the caller wrapped inOk..unwrap()extracts theStringfrom theOk, panicking only if the spawned thread had panicked instead of returning normally.
Notice that at no point does any data exist in two places that two different threads could mutate simultaneously — either main owns data, or the spawned thread does, never both.
Common Mistakes
Mistake 1: Forgetting move, Borrowing Instead of Owning
Without move, a closure captures variables by reference. Since a spawned thread can outlive the function that created it, the compiler refuses to let the closure hold a reference into that function’s stack.
use std::thread;
fn main() {
let data = vec![1, 2, 3];
let handle = thread::spawn(|| {
println!("{:?}", data);
});
handle.join().unwrap();
}
This fails to compile with an error along the lines of “closure may outlive the current function, but it borrows data, which is owned by the current function.” The fix is to add move, which transfers ownership of data into the closure instead of borrowing it:
use std::thread;
fn main() {
let data = vec![1, 2, 3];
let handle = thread::spawn(move || {
println!("{:?}", data);
});
handle.join().unwrap();
}
Output:
[1, 2, 3]
Mistake 2: Trying to Mutate Shared State Without Arc and Mutex
A plain mutable variable cannot be safely captured by multiple spawned closures: the borrow checker will not allow several threads to each hold a mutable reference to it, and a bare reference cannot satisfy the 'static bound anyway.
use std::thread;
fn main() {
let mut counter = 0;
let mut handles = Vec::new();
for _ in 0..5 {
let handle = thread::spawn(|| {
counter += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Final: {}", counter);
}
This is rejected because the closure borrows counter mutably, but that borrow cannot satisfy the 'static bound that thread::spawn requires. The fix is the pattern from Example 4: wrap the data in Mutex for safe interior mutability and in Arc so every thread can share ownership of the same Mutex.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = Vec::new();
for _ in 0..5 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Final: {}", *counter.lock().unwrap());
}
Output:
Final: 5
Best Practices
- Always call
.join()on everyJoinHandleyou care about; a spawned thread that is never joined keeps running detached, and ifmainexits first the whole process (and any unfinished threads) is torn down. - Prefer matching on the
Resultfromjoin(), or at least documenting why.unwrap()is safe, instead of blindly unwrapping in production code — a panic inside a spawned thread does not crash the whole process, it only surfaces when you join and unwrap it. - Reach for
Arc<Mutex<T>>only when threads genuinely need to share mutable ownership of the same data; if each thread can work on its own independent copy or communicate through a channel, that is usually simpler and less prone to contention. - Keep the code inside a
lock()as short as possible — the longer a thread holds aMutex, the longer every other thread waiting onlock()stays blocked. - For many short-lived, CPU-bound tasks, consider a thread pool (such as the widely used
rayoncrate) instead of spawning a raw OS thread per task; native threads are relatively heavyweight, each getting its own multi-megabyte stack by default. - Use
thread::Builderwhen you need to name a thread or set a custom stack size; named threads make panics and debugger output far easier to read. - Move only what the closure actually needs; capturing a whole struct when the thread only reads one field means cloning or restructuring data unnecessarily.
Practice Exercises
- Write a program that spawns three threads. Each thread should compute the cube of a different number (2, 3, and 4) and return it. Collect the three
JoinHandles, join them in order, and print the three cubes as a vector. Expected output:[8, 27, 64]. - Using
Arc<Mutex<Vec<i32>>>, spawn four threads where each thread pushes its own loop index (0 through 3) into the shared vector. After joining all four threads, print the length of the vector. Hint: the order of the pushed values may vary between runs, but the length should always be 4. - Take the broken snippet from “Mistake 1” in this lesson and, without looking at the corrected version, figure out and apply the one-word fix that makes it compile.
Summary
std::thread::spawncreates a real OS thread and runs a closure on it, returning aJoinHandle<T>immediately without blocking.- The closure passed to
spawnmust be'staticandSend; marking itmovetransfers ownership of any captured variables into the thread instead of borrowing them. handle.join()blocks until the thread finishes and returns aResult<T, ...>, withErronly if the thread panicked.- Because Rust’s ownership rules prevent a value from being mutated from two places at once, most data races are caught at compile time rather than discovered in production.
Arc<T>provides shared ownership across threads through atomic reference counting;Mutex<T>provides safe, exclusive, runtime-checked access to the data it wraps.- The combination
Arc<Mutex<T>>is the standard pattern for letting multiple threads read and mutate the same value. - Always join the threads you spawn, and prefer short critical sections and purpose-built concurrency tools (channels, thread pools) over broad shared mutable state.
