Code of the Day
AdvancedConcurrency

Threads

Spawn OS threads with std::thread, transfer ownership into them with move closures, and join results — safe parallelism without data races.

RustAdvanced11 min read
Recommended first
By the end of this lesson you will be able to:
  • Spawn threads with std::thread::spawn and hold JoinHandle values
  • Use move closures to transfer ownership into a thread
  • Join threads to collect results and propagate panics
  • Explain why thread panics are isolated by default

Rust gives you OS through std::thread. What makes Rust threads distinctive isn't the API — it's what the ownership system guarantees about them. The compiler prevents you from sharing data between threads in ways that would cause . Not at runtime: at compile time.

The Fundamentals track covers what data races are and why they're so hard to debug in other languages. In Rust, the compiler is the concurrency safety net.

Spawning a thread

std::thread::spawn takes a closure and runs it on a new OS thread. It returns a JoinHandle:

use std::thread;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..=5 {
            println!("thread: {}", i);
        }
    });

    for i in 1..=3 {
        println!("main:   {}", i);
    }

    handle.join().unwrap();
}

The output order of "thread" and "main" lines is non-deterministic — that's normal, and expected, for concurrent execution. handle.join() blocks until the spawned thread finishes.

move closures for thread safety

Threads can outlive the scope they're spawned in. If the closure borrows from the enclosing scope, the borrowed data might be dropped while the thread is still running — a dangling reference. The compiler requires move to prevent this:

fn main() {
    let data = vec![1, 2, 3];

    let handle = thread::spawn(move || {
        println!("data: {:?}", data);  // data is moved into this closure
    });

    // println!("{:?}", data);  // ERROR — data was moved into the thread
    handle.join().unwrap();
}

move transfers ownership of data into the closure. The thread now owns it. The original scope can no longer use data, which is exactly what prevents a dangling reference.

This is ownership solving a real concurrency problem. In languages without ownership, you'd need to be careful about the lifetime of data relative to the thread — and if you get it wrong, you get undefined behaviour. Rust makes getting it wrong a compile error.

JoinHandle and return values

A spawned thread can return a value, which join() delivers as a Result:

use std::thread;

fn main() {
    let handle = thread::spawn(|| {
        let mut sum = 0u64;
        for i in 1..=1_000_000 {
            sum += i;
        }
        sum
    });

    let result = handle.join().unwrap();
    println!("Sum: {}", result);  // 500000500000
}

join() returns Result<T, Box<dyn Any>>. The Ok variant holds the closure's return value; the Err variant holds the panic payload if the thread panicked.

Thread panics are isolated

If a spawned thread panics, the panic doesn't propagate to the parent — it's captured in the JoinHandle. The parent thread continues running:

let handle = thread::spawn(|| {
    panic!("something went wrong");
});

match handle.join() {
    Ok(_)  => println!("thread completed normally"),
    Err(e) => println!("thread panicked: {:?}", e),
}
// program continues — the panic was isolated

This is different from a panic in the main thread, which terminates the process. You can use this to run potentially-panicking work in isolation and handle failures gracefully.

unwrap() on join() re-panics the calling thread if the spawned thread panicked. In production code, handle the Err case explicitly — especially if you've spawned threads that interact with external resources.

Thread naming and configuration

thread::Builder gives you more control:

let builder = thread::Builder::new()
    .name(String::from("worker"))
    .stack_size(4 * 1024 * 1024);  // 4 MiB stack

let handle = builder.spawn(|| {
    println!("running as: {:?}", thread::current().name());
}).unwrap();

handle.join().unwrap();

Named threads appear in panic messages and debugger output, which makes diagnosing issues much easier in multi-threaded programs.

Check your understanding

Knowledge check

  1. 1.
    Why does the compiler require a move closure when spawning a thread that uses local variables?
  2. 2.
    What does handle.join() return when the spawned thread panicked?
  3. 3.
    The output of two threads printing values in a loop will always appear in a predictable alternating order.

Do it yourself

Spawn 4 threads, each computing the sum of a quarter of a large range. Join all four and sum their results. Compare with the single-threaded result to verify correctness.

cargo new threaded-sum
cd threaded-sum
# Edit src/main.rs — no external crates needed for std::thread
cargo run

Where to go next

Threads that each work on independent owned data are the simplest form of parallelism. Real programs often need threads to share data — which requires careful synchronisation. Next: shared state with Arc, Mutex, and RwLock.

Finished reading? Mark it complete to track your progress.

On this page