RustThreads

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.

RUST
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!
Note
The exact order of the two print statements may vary between runs — the OS scheduler decides which thread gets CPU time first. That non-determinism is the essence of concurrency.
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>>.

RUST
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
Tip
If you drop a 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.

RUST
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.

RUST
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.
Warning
A panic in the main thread does terminate the whole process, including all spawned threads. Always join your threads before main exits so they have a chance to clean up.
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.

RUST
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.

RUST
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.

RUST
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]
Tip
Prefer scoped threads over 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 (use Arc<T> instead)

  • RefCell<T> — not Sync (use Mutex<T> instead)

  • Cell<T> — not Sync

  • Raw pointers *const T / *mut T — neither Send nor Sync by default

RUST
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();
Note
If you try to send a non-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.

RUST
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.

RUST
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

par_iter(), par_sort()

tokio (crate)

I/O-bound async tasks

tokio::spawn(), async/await

std::thread

Simple, low-level OS threads

thread::spawn()

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_blocking for CPU-heavy tasks
Success
Rust's thread model is simple and direct: spawn a thread, pass data by ownership or through synchronisation primitives, join when you are done. The type system ensures you never share data in an unsafe way, so you can write concurrent code with the same confidence as single-threaded code.