Rust threads
last modified November 13, 2026
In this article we show how to work with threads in Rust.
A thread is an independent path of execution within a process. Threads of one process share its memory, so they can work on the same data in parallel. This makes programs faster on multi-core processors, but it also brings the classic problem of concurrent programming: a data race, when two threads access the same memory at the same time and at least one of them writes.
Rust prevents data races at compile time. The ownership rules and two
marker traits, Send (a value can be moved to another thread)
and Sync (a value can be shared between threads by reference),
let the compiler reject unsafe sharing. Rust's standard library uses
native operating system threads; each Rust thread is one OS thread.
The examples were tested with Rust 1.97 and the 2024 edition. They are run as
Cargo projects with cargo run -q. Several examples print
output in an order that changes from run to run.
Spawning a thread
The thread::spawn function starts a new thread that runs the
given closure.
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
for i in 1..=3 {
println!("worker: step {i}");
thread::sleep(Duration::from_millis(10));
}
});
for i in 1..=3 {
println!("main: step {i}");
thread::sleep(Duration::from_millis(10));
}
handle.join().unwrap();
println!("both threads finished");
}
The new thread and the main thread both print three lines.
let handle = thread::spawn(|| {
for i in 1..=3 {
println!("worker: step {i}");
thread::sleep(Duration::from_millis(10));
}
});
The spawn function returns at once with a
JoinHandle. From then on, the two threads run concurrently.
The thread::sleep calls pause the current thread and give the
other one a chance to run.
handle.join().unwrap();
The join method blocks until the thread finishes. It returns a
Result, which is Err if the thread panicked. The
operating system scheduler decides when each thread runs, so the lines may
come in a different order on another run.
$ cargo run -q main: step 1 worker: step 1 main: step 2 worker: step 2 worker: step 3 main: step 3 both threads finished $ cargo run -q main: step 1 worker: step 1 main: step 2 worker: step 2 main: step 3 worker: step 3 both threads finished
The main thread does not wait
When the main thread ends, the whole process ends, including all threads that are still running.
use std::thread;
use std::time::Duration;
fn main() {
thread::spawn(|| {
for i in 1..=5 {
println!("worker: step {i}");
thread::sleep(Duration::from_millis(10));
}
});
thread::sleep(Duration::from_millis(25));
println!("main is done");
}
The worker needs about 50 milliseconds for its five steps, but
main sleeps for 25 milliseconds only and does not join the
thread. The remaining steps never run. The handle returned by
spawn is simply dropped, which detaches the thread.
$ cargo run -q worker: step 1 worker: step 2 worker: step 3 main is done
Returning a value
The closure's return value is passed to the thread that calls
join.
use std::thread;
fn count_primes(from: u64, to: u64) -> usize {
(from..to)
.filter(|&n| n > 1 && (2..).take_while(|d| d * d <= n).all(|d| !n.is_multiple_of(d)))
.count()
}
fn main() {
let low = thread::spawn(|| count_primes(0, 50_000));
let high = thread::spawn(|| count_primes(50_000, 100_000));
let a = low.join().unwrap();
let b = high.join().unwrap();
println!("primes below 50,000: {a}");
println!("primes from 50,000 to 100,000: {b}");
println!("total: {}", a + b);
}
Two threads count primes in two ranges.
let low = thread::spawn(|| count_primes(0, 50_000));
The type of low is JoinHandle<usize>,
inferred from the closure.
let a = low.join().unwrap(); let b = high.join().unwrap();
The join method returns Ok(usize) with the
closure's result. Both threads run in parallel, while the main thread waits
for the first one and then for the second.
$ cargo run -q primes below 50,000: 5133 primes from 50,000 to 100,000: 4459 total: 9592
Borrowing in a thread
A thread created with spawn may run longer than the function
that created it. Its closure therefore must not borrow local variables.
use std::thread;
fn main() {
let words = vec!["sky", "cloud", "rain"];
let handle = thread::spawn(|| {
println!("{} words", words.len());
});
handle.join().unwrap();
}
The closure uses words by reference. The compiler cannot know
whether the thread finishes before main drops the vector, so
it reports error E0373. The spawn function requires a closure
that is 'static, which means it owns all the data it uses. The
help text suggests the fix.
$ cargo run -q
error[E0373]: closure may outlive the current function, but it borrows `words`, which is owned by the current function
--> src/main.rs:6:32
|
6 | let handle = thread::spawn(|| {
| ^^ may outlive borrowed value `words`
7 | println!("{} words", words.len());
| ----- `words` is borrowed here
|
note: function requires argument type to outlive `'static`
--> src/main.rs:6:18
|
6 | let handle = thread::spawn(|| {
| __________________^
7 | | println!("{} words", words.len());
8 | | });
| |______^
help: to force the closure to take ownership of `words` (and any other referenced variables), use the `move` keyword
|
6 | let handle = thread::spawn(move || {
| ++++
For more information about this error, try `rustc --explain E0373`.
Moving data into a thread
The move keyword makes the closure take ownership of the
captured variables.
use std::thread;
fn main() {
let words = vec!["sky", "cloud", "rain"];
let limit = 4;
let handle = thread::spawn(move || {
let short: Vec<_> = words.into_iter().filter(|w| w.len() <= limit).collect();
short
});
let short = handle.join().unwrap();
println!("short words: {short:?}");
println!("limit is still usable: {limit}");
}
The vector is moved into the thread. After the move, main can
no longer use words; the thread owns it and consumes it with
into_iter.
let short = handle.join().unwrap();
println!("short words: {short:?}");
println!("limit is still usable: {limit}");
The result is handed back through join. The
limit variable is an integer, which implements
Copy; the closure moved a copy of it, so the original remains
usable.
$ cargo run -q short words: ["sky", "rain"] limit is still usable: 4
Many threads
Threads are often spawned in a loop and their handles collected in a vector.
use std::thread;
fn main() {
let handles: Vec<_> = (1..=5)
.map(|id| {
thread::spawn(move || {
let sum: u64 = (1..=id * 1000).sum();
println!("thread {id} computed {sum}");
sum
})
})
.collect();
let total: u64 = handles.into_iter().map(|h| h.join().unwrap()).sum();
println!("total: {total}");
}
Five threads compute sums of different lengths. The move
keyword gives each closure its own copy of id.
let total: u64 = handles.into_iter().map(|h| h.join().unwrap()).sum();
We join the threads in the order they were created and add up their results. The total is always the same, but the order of the printed lines is not, as the two runs show.
$ cargo run -q thread 1 computed 500500 thread 2 computed 2001000 thread 3 computed 4501500 thread 4 computed 8002000 thread 5 computed 12502500 total: 27507500 $ cargo run -q thread 1 computed 500500 thread 2 computed 2001000 thread 4 computed 8002000 thread 3 computed 4501500 thread 5 computed 12502500 total: 27507500
Thread names and the Builder
The thread::Builder type configures a thread before it is
spawned: its name and its stack size.
use std::thread;
fn main() {
let main_thread = thread::current();
println!("{:?} {:?}", main_thread.name(), main_thread.id());
let handle = thread::Builder::new()
.name("loader".to_string())
.stack_size(64 * 1024)
.spawn(|| {
let me = thread::current();
println!("{:?} {:?}", me.name(), me.id());
})
.unwrap();
handle.join().unwrap();
let unnamed = thread::spawn(|| thread::current().name().map(String::from));
println!("unnamed thread: {:?}", unnamed.join().unwrap());
println!("available parallelism: {}", thread::available_parallelism().unwrap());
}
The thread::current function returns a handle to the running
thread. Each thread has a unique ThreadId; the name is
optional.
let handle = thread::Builder::new()
.name("loader".to_string())
.stack_size(64 * 1024)
.spawn(|| {
Unlike thread::spawn, the builder's spawn returns
a Result, because the operating system may fail to create the
thread. Names appear in panic messages and in debuggers. Threads created
with thread::spawn have no name.
thread::available_parallelism().unwrap()
The available_parallelism function estimates how many threads
can run in parallel, usually the number of logical CPU cores.
$ cargo run -q
Some("main") ThreadId(1)
Some("loader") ThreadId(2)
unnamed thread: None
available parallelism: 8
Panics in threads
A panic in a spawned thread ends only that thread. The parent learns about
it from join.
use std::thread;
fn parse_all(items: Vec<&'static str>) -> Vec<i32> {
items.iter().map(|s| s.parse().unwrap()).collect()
}
fn main() {
let good = thread::spawn(|| parse_all(vec!["1", "2", "3"]));
let bad = thread::spawn(|| parse_all(vec!["4", "five", "6"]));
println!("good: {:?}", good.join());
match bad.join() {
Ok(v) => println!("bad: {v:?}"),
Err(_) => println!("bad: the thread panicked"),
}
println!("main continues");
}
The second thread fails to parse "five" and panics. The panic
message is printed on standard error and names the thread, here
<unnamed>.
match bad.join() {
Ok(v) => println!("bad: {v:?}"),
Err(_) => println!("bad: the thread panicked"),
}
The join method returns Err with the panic
payload. The main thread handles the error and continues. Calling
unwrap on the result would propagate the panic to the main
thread instead.
$ cargo run -q
thread '<unnamed>' (120644) panicked at src/main.rs:4:36:
called `Result::unwrap()` on an `Err` value: ParseIntError { kind: InvalidDigit }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
good: Ok([1, 2, 3])
bad: the thread panicked
main continues
Scoped threads
The thread::scope function creates a scope for threads that are
guaranteed to finish before the scope ends. Such threads can borrow local
variables, so no move and no Arc is needed.
use std::thread;
fn main() {
let numbers: Vec<u64> = (1..=1_000_000).collect();
let mut stats = Vec::new();
thread::scope(|s| {
let evens = s.spawn(|| numbers.iter().filter(|&&n| n % 2 == 0).count());
let max = s.spawn(|| numbers.iter().max().copied());
stats.push(("evens", evens.join().unwrap() as u64));
stats.push(("max", max.join().unwrap().unwrap_or(0)));
});
println!("numbers: {}", numbers.len());
for (name, value) in &stats {
println!("{name}: {value}");
}
}
Two threads read the same vector of a million numbers at the same time.
thread::scope(|s| {
let evens = s.spawn(|| numbers.iter().filter(|&&n| n % 2 == 0).count());
let max = s.spawn(|| numbers.iter().max().copied());
Threads are spawned with the scope's spawn method. Both
closures borrow numbers by a shared reference, which is
allowed for any number of threads. The closures also need no
'static bound.
stats.push(("evens", evens.join().unwrap() as u64));
The main thread can mutate stats inside the scope, because no
spawned thread uses it. At the end of the scope, all threads that were not
joined are joined automatically, and the borrows end. After the scope,
numbers can be used again.
$ cargo run -q numbers: 1000000 evens: 500000 max: 1000000
Splitting work into chunks
A common pattern is to split a slice into chunks and process each chunk in its own thread.
use std::thread;
use std::time::Instant;
fn is_prime(n: u64) -> bool {
n > 1 && (2..).take_while(|d| d * d <= n).all(|d| !n.is_multiple_of(d))
}
fn count_parallel(numbers: &[u64], threads: usize) -> usize {
let chunk = numbers.len().div_ceil(threads);
thread::scope(|s| {
let handles: Vec<_> = numbers
.chunks(chunk)
.map(|part| s.spawn(|| part.iter().filter(|&&n| is_prime(n)).count()))
.collect();
handles.into_iter().map(|h| h.join().unwrap()).sum()
})
}
fn main() {
let numbers: Vec<u64> = (0..3_000_000).collect();
for threads in [1, 2, 4, 8] {
let start = Instant::now();
let count = count_parallel(&numbers, threads);
println!("{threads} threads: {count} primes in {:.2?}", start.elapsed());
}
}
The program counts primes below three million with one, two, four, and eight threads.
let chunk = numbers.len().div_ceil(threads);
The chunk size is rounded up, so the chunks cover the whole slice.
numbers
.chunks(chunk)
.map(|part| s.spawn(|| part.iter().filter(|&&n| is_prime(n)).count()))
.collect();
Each scoped thread receives a sub-slice. The handles are collected first,
so that all threads start before the first join; joining
inside the same map would run them one after another.
We run the optimized build. The result is the same for every thread count; the time drops with more threads, but the gain shrinks once the threads outnumber the four physical cores of the test machine; its eight hardware threads share those cores. The chunks are also uneven: larger numbers take longer to test, so the thread with the last chunk finishes last.
$ cargo run -q --release 1 threads: 216816 primes in 475.20ms 2 threads: 216816 primes in 302.03ms 4 threads: 216816 primes in 185.17ms 8 threads: 216816 primes in 165.37ms
Shared state with Mutex
To change shared data from several threads, the data is protected by a
Mutex (mutual exclusion lock). Only one thread at a time can
hold the lock. With thread::spawn, the mutex is shared through
an Arc, an atomically reference-counted pointer.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..8 {
let counter = Arc::clone(&counter);
handles.push(thread::spawn(move || {
for _ in 0..10_000 {
*counter.lock().unwrap() += 1;
}
}));
}
for handle in handles {
handle.join().unwrap();
}
println!("counter: {}", *counter.lock().unwrap());
}
Eight threads increment one counter ten thousand times each.
let counter = Arc::clone(&counter);
Each thread gets its own Arc pointing to the same mutex. The
Rc type would not compile here, because its reference count is
not thread-safe; see Rust smart
pointers.
*counter.lock().unwrap() += 1;
The lock method blocks until the lock is free and returns a
MutexGuard, which dereferences to the protected value. The lock
is released when the guard is dropped, here at the end of the statement.
The data can be reached only through the guard, so it cannot be accessed
without locking. Without the mutex, the program would not compile.
$ cargo run -q counter: 80000
Mutex with scoped threads
With scoped threads, a mutex can be borrowed; no Arc is
needed.
use std::collections::HashMap;
use std::sync::Mutex;
use std::thread;
fn main() {
let text = "the sky is blue the grass is green the sun is up";
let words: Vec<&str> = text.split_whitespace().collect();
let freq = Mutex::new(HashMap::new());
thread::scope(|s| {
for part in words.chunks(4) {
let freq = &freq;
s.spawn(move || {
for word in part {
*freq.lock().unwrap().entry(*word).or_insert(0) += 1;
}
});
}
});
let freq = freq.into_inner().unwrap();
let mut pairs: Vec<_> = freq.into_iter().filter(|&(_, n)| n > 1).collect();
pairs.sort();
println!("{pairs:?}");
}
Three threads count words in parts of a text and record the counts in a shared map.
let freq = &freq;
s.spawn(move || {
The closure must take part by value, because the chunk
variable belongs to the loop iteration, so we use move. To
move only a reference to the mutex, not the mutex itself, we shadow
freq with a reference before the closure.
let freq = freq.into_inner().unwrap();
After the scope, no thread uses the mutex any more. The
into_inner method consumes the mutex and returns the map
without locking.
$ cargo run -q
[("is", 3), ("the", 3)]
RwLock
A RwLock (read-write lock) allows any number of readers or one
writer at a time. It suits data that is read often and changed rarely.
use std::sync::RwLock;
use std::thread;
use std::time::Duration;
fn main() {
let config = RwLock::new(String::from("v1"));
thread::scope(|s| {
for id in 1..=3 {
let config = &config;
s.spawn(move || {
for _ in 0..3 {
let value = config.read().unwrap();
println!("reader {id} sees {value}");
drop(value);
thread::sleep(Duration::from_millis(15));
}
});
}
s.spawn(|| {
thread::sleep(Duration::from_millis(20));
*config.write().unwrap() = String::from("v2");
println!("writer updated config");
});
});
println!("final: {}", config.read().unwrap());
}
Three readers check a configuration value while a writer updates it once.
let value = config.read().unwrap();
println!("reader {id} sees {value}");
drop(value);
The read method returns a read guard. Several readers can hold
read guards at once. We drop the guard before sleeping, so the writer is not
blocked longer than necessary.
*config.write().unwrap() = String::from("v2");
The write method waits until all read guards are released and
then gives exclusive access.
$ cargo run -q reader 1 sees v1 reader 2 sees v1 reader 3 sees v1 reader 1 sees v1 reader 2 sees v1 reader 3 sees v1 writer updated config reader 1 sees v2 reader 2 sees v2 reader 3 sees v2 final: v2
Atomic types
For simple counters and flags, atomic types are faster than a mutex. They are updated with special processor instructions, without locking.
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::thread;
static REQUESTS: AtomicUsize = AtomicUsize::new(0);
fn main() {
let found = AtomicBool::new(false);
thread::scope(|s| {
for id in 0..4 {
let found = &found;
s.spawn(move || {
for n in (id..1_000).step_by(4) {
REQUESTS.fetch_add(1, Ordering::Relaxed);
if n == 777 {
found.store(true, Ordering::Relaxed);
}
}
});
}
});
println!("requests: {}", REQUESTS.load(Ordering::Relaxed));
println!("found 777: {}", found.load(Ordering::Relaxed));
}
Four threads go through the numbers below a thousand, each taking every fourth number. They count the processed numbers and set a flag when they find 777.
static REQUESTS: AtomicUsize = AtomicUsize::new(0);
Atomic types can be modified through a shared reference, so they can be
used in a plain static without unsafe.
REQUESTS.fetch_add(1, Ordering::Relaxed);
The fetch_add method adds a value as one indivisible
operation. The Ordering argument specifies how the operation is
ordered relative to other memory accesses. Relaxed guarantees
only the atomicity of the operation itself, which is enough for an
independent counter. When an atomic flag signals that other data is ready,
Release on the store and Acquire on the load are
needed.
$ cargo run -q requests: 1000 found 777: true
Lock poisoning
If a thread panics while holding a mutex, the data may be left in an inconsistent state. The mutex is then marked as poisoned.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let balance = Arc::new(Mutex::new(100));
let shared = Arc::clone(&balance);
let result = thread::spawn(move || {
let mut guard = shared.lock().unwrap();
*guard -= 150;
if *guard < 0 {
panic!("negative balance: {}", *guard);
}
})
.join();
println!("thread finished with error: {}", result.is_err());
match balance.lock() {
Ok(value) => println!("balance: {value}"),
Err(poisoned) => {
println!("mutex poisoned, last value: {}", poisoned.into_inner());
}
}
}
The thread subtracts from the balance, finds it negative, and panics while the guard is still alive.
match balance.lock() {
Ok(value) => println!("balance: {value}"),
Err(poisoned) => {
println!("mutex poisoned, last value: {}", poisoned.into_inner());
}
}
Every later lock call returns Err. This is why
lock is so often followed by unwrap: by default,
a panic in one thread spreads to the others that use the same data. The
PoisonError still gives access to the data through
into_inner, if the program can repair or ignore the state.
$ cargo run -q thread '<unnamed>' (121522) panicked at src/main.rs:12:13: negative balance: -50 note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace thread finished with error: true mutex poisoned, last value: -50
Thread-local storage
The thread_local! macro declares a static variable of which
every thread has its own copy.
use std::cell::Cell;
use std::thread;
thread_local! {
static CALLS: Cell<u32> = const { Cell::new(0) };
}
fn work() {
CALLS.with(|c| c.set(c.get() + 1));
}
fn main() {
let handles: Vec<_> = (1..=3)
.map(|n| {
thread::spawn(move || {
for _ in 0..n * 10 {
work();
}
CALLS.with(|c| c.get())
})
})
.collect();
for (i, h) in handles.into_iter().enumerate() {
println!("thread {}: {} calls", i + 1, h.join().unwrap());
}
work();
println!("main: {} calls", CALLS.with(|c| c.get()));
}
The work function counts its calls per thread.
thread_local! {
static CALLS: Cell<u32> = const { Cell::new(0) };
}
Each thread starts with its own counter set to zero. The value is reached
through the with method. Since no other thread can see it, a
Cell is enough; no lock or atomic is needed. The
const block allows a more efficient initialization.
$ cargo run -q thread 1: 10 calls thread 2: 20 calls thread 3: 30 calls main: 1 calls
One-time initialization
The OnceLock type holds a value that is initialized at most
once, even when several threads try at the same time.
use std::sync::OnceLock;
use std::thread;
fn config() -> &'static Vec<String> {
static CONFIG: OnceLock<Vec<String>> = OnceLock::new();
CONFIG.get_or_init(|| {
println!("{:?} loads the config", thread::current().name().unwrap_or("?"));
vec!["debug=false".to_string(), "port=8080".to_string()]
})
}
fn main() {
thread::scope(|s| {
for name in ["a", "b", "c"] {
thread::Builder::new()
.name(name.to_string())
.spawn_scoped(s, || {
let cfg = config();
assert_eq!(cfg.len(), 2);
})
.unwrap();
}
});
println!("{:?}", config());
}
Three named threads call config at the same time.
CONFIG.get_or_init(|| {
println!("{:?} loads the config", thread::current().name().unwrap_or("?"));
vec!["debug=false".to_string(), "port=8080".to_string()]
})
The get_or_init method runs the closure in the first thread
that gets there; the others wait and then receive a reference to the same
value. The message is printed only once, though which thread loads the
configuration varies. The spawn_scoped method of the builder
creates a named scoped thread.
$ cargo run -q "a" loads the config ["debug=false", "port=8080"]
Source
The Rust Programming Language: Fearless Concurrency
std::thread module documentation
std::sync module documentation
In this article we have worked with threads in Rust. Passing messages between threads is covered in Rust channels, closures in Rust closures.
Author
List all Rust tutorials.