Threads in Rust
Rust's standard library exposes OS threads through std::thread. Each thread
gets its own stack and runs truly in parallel on multi-core hardware. Rust's
ownership and type system enforce the rules that make concurrent code safe at
compile time — no data races, no use-after-free across threads.
Spawning a Thread
thread::spawn creates a new OS thread. It takes a closure, executes it on a
fresh thread, and returns a JoinHandle you can use to wait for the thread to
finish.
use std::thread;
fn main() {
let handle = thread::spawn(|| {
println!("Hello from a new thread!");
});
println!("Hello from the main thread!");
// Wait for the spawned thread to finish
handle.join().unwrap();
}Hello from the main thread! Hello from a new thread!
JoinHandle and join()
thread::spawn returns a JoinHandle<T> where T is the type returned by the
closure. Calling .join() on the handle blocks the calling thread until the
spawned thread finishes, then returns Result<T, Box<dyn Any>>.
use std::thread;
fn main() {
let handle = thread::spawn(|| {
let mut sum = 0u64;
for i in 1..=1_000_000 {
sum += i;
}
sum // the closure's return value
});
// Do other work here while the thread runs...
let result = handle.join().unwrap(); // wait and collect the value
println!("Sum: {}", result);
}Sum: 500000500000
JoinHandle without calling join(), the thread is detached — it keeps running but you can no longer wait for it or retrieve its result. In most programs, always join threads you spawn.move Closures
The closure passed to thread::spawn must be 'static — it must not borrow
anything from the current stack frame, because the current stack frame might be
gone by the time the thread uses the data. The move keyword forces the closure
to take ownership of every captured variable, satisfying this requirement.
use std::thread;
fn main() {
let message = String::from("Hello from main!");
// Without 'move' this would not compile — message might not live long enough
let handle = thread::spawn(move || {
println!("{}", message); // message is now owned by this closure
});
// println!("{}", message); // ERROR — message was moved
handle.join().unwrap();
}Hello from main!
If you need to share data without transferring ownership, wrap the value in an
Arc and move a clone into each thread — see the Arc<T> and Mutex<T> page.
Thread Panics
A panic inside a spawned thread does not crash the whole program — it only
terminates that thread. The panic is propagated to the caller of .join() as
an Err value, which you can inspect or ignore.
use std::thread;
fn main() {
let handle = thread::spawn(|| {
panic!("something went wrong in the thread!");
});
match handle.join() {
Ok(_) => println!("Thread finished normally"),
Err(e) => println!("Thread panicked: {:?}", e),
}
println!("Main thread continues after spawned thread's panic.");
}Thread panicked: Any { .. }
Main thread continues after spawned thread's panic.Sleeping and Thread Identity
thread::sleep pauses the current thread for at least the specified duration.
thread::current().id() returns a unique identifier for the running thread —
useful for logging and debugging.
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
println!("Thread id: {:?}", thread::current().id());
thread::sleep(Duration::from_millis(100));
println!("Thread woke up after 100 ms");
});
println!("Main thread id: {:?}", thread::current().id());
handle.join().unwrap();
}Main thread id: ThreadId(1) Thread id: ThreadId(2) Thread woke up after 100 ms
Querying Available Parallelism
thread::available_parallelism() returns the number of logical CPU cores the
runtime can use. This is useful for deciding how many worker threads to create in
a thread pool.
use std::thread;
fn main() {
match thread::available_parallelism() {
Ok(n) => println!("Logical CPU cores: {}", n),
Err(e) => println!("Could not determine parallelism: {}", e),
}
}Logical CPU cores: 8
Scoped Threads
Normal thread::spawn requires 'static closures. Scoped threads,
introduced via thread::scope, let you borrow data from the enclosing scope
because the scope function guarantees all threads finish before it returns.
use std::thread;
fn main() {
let data = vec![1, 2, 3, 4, 5];
thread::scope(|s| {
s.spawn(|| {
// Can borrow 'data' directly — no Arc or move needed
println!("sum: {}", data.iter().sum::<i32>());
});
s.spawn(|| {
println!("len: {}", data.len());
});
// Both threads are joined automatically when the scope closure returns
});
// data is still accessible here
println!("original: {:?}", data);
}sum: 15 len: 5 original: [1, 2, 3, 4, 5]
Arc whenever the threads are contained within a single function — they are simpler, avoid heap allocation, and the borrow checker still enforces all the safety rules.The Send and Sync Marker Traits
Two special marker traits govern thread safety in Rust:
Trait | Meaning | Automatically implemented when |
|---|---|---|
Send | The type can be transferred to another thread | All fields are Send |
Sync | &T can be shared across threads (T is safe to read concurrently) | All fields are Sync |
Most standard library types are both Send and Sync. The notable exceptions:
Rc<T>— not Send, not Sync (useArc<T>instead)RefCell<T>— not Sync (useMutex<T>instead)Cell<T>— not SyncRaw pointers
*const T/*mut T— neither Send nor Sync by default
use std::sync::Arc;
use std::thread;
// Rc is NOT Send — this would be a compile error:
// let rc = std::rc::Rc::new(5);
// thread::spawn(move || println!("{}", rc)); // ERROR
// Arc IS Send — this compiles fine:
let arc = Arc::new(5);
let arc_clone = Arc::clone(&arc);
thread::spawn(move || println!("{}", arc_clone)).join().unwrap();Send type across a thread boundary, the compiler produces a clear error message pointing at the offending type. This is one of Rust's most valuable compile-time guarantees.Named Threads
Threads can be given a name via the builder API. Named threads appear in panic messages and debugger output, which makes diagnosing failures much easier.
use std::thread;
fn main() {
let handle = thread::Builder::new()
.name(String::from("worker-1"))
.spawn(|| {
println!("Running on thread: {}", thread::current().name().unwrap());
})
.expect("Failed to spawn thread");
handle.join().unwrap();
}Running on thread: worker-1
Multiple Threads Working Together
Here is a practical example: splitting a large computation across several threads and combining the results.
use std::thread;
fn parallel_sum(data: Vec<i64>, num_threads: usize) -> i64 {
let chunk_size = (data.len() + num_threads - 1) / num_threads;
let chunks: Vec<Vec<i64>> = data.chunks(chunk_size).map(|c| c.to_vec()).collect();
let handles: Vec<_> = chunks
.into_iter()
.map(|chunk| thread::spawn(move || chunk.iter().sum::<i64>()))
.collect();
handles.into_iter().map(|h| h.join().unwrap()).sum()
}
fn main() {
let data: Vec<i64> = (1..=1_000_000).collect();
let total = parallel_sum(data, 4);
println!("Total: {}", total); // 500000500000
}Total: 500000500000
Thread Pools and Async Alternatives
Creating an OS thread for every small task is expensive. For workloads that spawn many short-lived tasks, a thread pool reuses a fixed set of threads.
Library | Best for | Key API |
|---|---|---|
rayon (crate) | CPU-bound data parallelism |
|
tokio (crate) | I/O-bound async tasks |
|
std::thread | Simple, low-level OS threads |
|
crossbeam (crate) | Advanced concurrency primitives | Scoped threads, channels, deques |
A rough decision guide:
- CPU-bound work (number crunching, compression, parsing large files) → OS threads or rayon
- I/O-bound work (network requests, database queries, file reads) → async/await with tokio or async-std
- Mixed workloads → tokio with
spawn_blockingfor CPU-heavy tasks