Using Threads to Run Code Simultaneously

How to create and manage OS threads in Rust with spawn, join, move closures, and scoped threads, while understanding ownership guarantees that eliminate data races at compile time.

Most programs execute one instruction after another in a single sequence. That works fine until you need to handle a file download while the user interface stays responsive, or serve hundreds of web requests that could run independently. The mechanism that lets a program split into separate, simultaneously executing paths is a thread. This section covers how Rust creates threads, how to wait for them to finish, and how the ownership system makes sharing data between threads safe at compile time.

What a Thread Actually Is

Every running program lives inside an operating system process — a container that holds the program's memory, file handles, and at least one thread of execution. A thread is the smallest sequence of programmed instructions that the OS scheduler can manage independently. Think of a process as a workshop and threads as workers inside it: all workers share the same tools and materials (memory), but each one follows its own task list.

Threads are useful for two distinct reasons. First, they allow parallelism: on a machine with multiple CPU cores, two threads can genuinely run at the same instant, each on its own core, completing work faster. Second, they enable concurrency: even on a single core, threads can interleave their work so that one thread handles input while another runs a computation, giving the illusion of simultaneous progress. In Rust, threads are always OS-level threads — real schedulable entities managed by the kernel.

Threads vs. Async:

Rust also supports asynchronous programming with async/await, which handles many I/O-bound tasks without spawning a new OS thread for each one. This chapter focuses on OS threads. The async model is covered later in the book.

The 1:1 Threading Model

Programming languages can offer threads in different ways. Some languages implement green threads — lightweight threads managed entirely by the language runtime, multiplexed onto a smaller number of OS threads. Rust's standard library takes a different approach: it uses a 1:1 model, where every std::thread::Thread directly corresponds to one operating system thread.

This design choice means you pay the full cost of OS thread creation and context switching, but you gain direct control over scheduling and the ability to use platform-specific thread features. For many workloads, spawning thousands of OS threads is impractical; in those cases, async or third-party crates like rayon for data parallelism become better tools. But for a moderate number of truly independent CPU-bound tasks, 1:1 threads give you straightforward, predictable concurrency.

Other crates implement M:N threading (mapping many language-level threads onto a smaller number of OS threads) or work-stealing thread pools. You choose the model that fits your problem. Rust does not force a single concurrency paradigm.

Creating a Thread with thread::spawn

A new thread is born when you call std::thread::spawn and hand it a closure containing the code to execute. The closure runs in the new thread while the calling thread continues forward immediately — spawn does not block. The function signature asks for a closure that is FnOnce() -> T + Send + 'static, meaning it can capture variables but must be safe to transfer to another thread and must not borrow data that might not live long enough.

use std::thread;
use std::time::Duration;
fn main() {
    thread::spawn(|| {
        for i in 1..10 {
            println!("spawned: {i}");
            thread::sleep(Duration::from_millis(1));
        }
    });
    for i in 1..5 {
        println!("main: {i}");
        thread::sleep(Duration::from_millis(1));
    }
}

If you run this program a few times, the output will vary. On one run, you might see main: 1 first; on another, the spawned thread might print first. The operating system decides which thread runs at any instant. The program also usually prints only a few lines from the spawned thread — maybe up to 5 — before the main thread finishes and the entire process exits. When the main thread of a Rust program terminates, all spawned threads are killed, regardless of whether they completed their work.

This behavior is both a simplicity feature and a source of bugs. It means you cannot rely on a spawned thread finishing unless you explicitly coordinate.

Main Thread Exit Kills Spawned Threads:

Forgetting that the process dies with the main thread is one of the most common beginner surprises. If you need a spawned thread to finish its job, always use a JoinHandle or a scoped thread.

Waiting for a Thread with JoinHandle

thread::spawn returns a JoinHandle<T>. This handle owns the thread: while it exists, the thread can be joined. Calling join() on it blocks the current thread until the associated spawned thread finishes. It also returns the value produced by the closure, wrapped in a Result that indicates whether the thread panicked.

use std::thread;
use std::time::Duration;
fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("spawned: {i}");
            thread::sleep(Duration::from_millis(1));
        }
    });
    for i in 1..5 {
        println!("main: {i}");
        thread::sleep(Duration::from_millis(1));
    }
    handle.join().unwrap();
}

Now the spawned thread will always print all nine numbers. The main thread runs its loop, then blocks on join() until the spawned thread finishes. The two threads still interleave their output — the order is nondeterministic — but the spawned thread is guaranteed to complete.

If you move the join() call before the main thread's for loop, the main thread will wait for the spawned thread to finish first, then run its own loop with no interleaving at all. The placement of join() completely controls parallelism: call it early to serialize, call it late to maximize overlap.

JoinHandle::join returns Result<T, Box<dyn Any + Send>>. If the spawned thread completed normally, you get Ok(value). If it panicked, you get Err containing the panic payload. This is how errors propagate across thread boundaries. We'll return to panics shortly.

Dropping a JoinHandle Detaches the Thread:

If you drop a JoinHandle without calling join(), the spawned thread continues running in the background, but you lose the ability to wait for it or catch its result. The thread becomes detached and will be killed when the process exits. This is sometimes intentional, but accidental drops are a common source of unfinished work.

Ownership and Threads: Why move Closures Are Necessary

A thread created with spawn might outlive the scope that created it. The closure you pass must therefore be 'static — it cannot borrow any data that might disappear first. The following code does not compile:

use std::thread;
fn main() {
    let v = vec![1, 2, 3];
    let handle = thread::spawn(|| {
        println!("Here's a vector: {v:?}");
    });
    drop(v); // main thread drops the vector
    handle.join().unwrap();
}

The compiler rejects this because the closure borrows v, but the main thread can drop v before (or while) the spawned thread reads it. Rust's error message points directly to the solution:

error[E0373]: closure may outlive the current function, but it borrows `v`
...
help: to force the closure to take ownership of `v` (and any other referenced variables),
      use the `move` keyword

Adding move before the closure forces it to take ownership of every variable it captures. After the move, the main thread can no longer use v — the compiler will reject the drop(v) line — so the spawned thread is the sole owner and the vector remains valid for its entire lifetime.

let handle = thread::spawn(move || {
    println!("Here's a vector: {v:?}");
});

This is Rust's ownership system preventing a use-after-free at compile time. The same mechanism that stops data races in single-threaded code — "either one mutable reference or many immutable references" — also stops threads from accessing data that could be destroyed out from under them.

Ownership Guarantees Apply Across Threads:

If a value's type implements Send, you can transfer ownership to another thread with move. If it also implements Sync, you can share it by reference. These traits, covered later in this chapter, are automatically derived for most types, so Rust enforces thread safety without manual annotations in most cases.

Scoped Threads: Borrowing Without move or Arc

Sometimes you want to spawn several threads that all borrow data from the current function's stack, with the guarantee that all threads finish before the function returns. This pattern is impossible with raw spawn because 'static lifetimes can't be satisfied by stack data. Rust 1.63 introduced std::thread::scope for exactly this situation.

thread::scope takes a closure that receives a Scope object. Inside that closure, you call scope.spawn() to create threads that can borrow local variables. The scope call blocks until every spawned thread has finished, so the borrows are guaranteed to be valid.

use std::thread;
fn main() {
    let numbers = vec![1, 2, 3, 4, 5, 6, 7, 8];
    thread::scope(|s| {
        for chunk in numbers.chunks(3) {
            s.spawn(move |_| {
                let sum: i32 = chunk.iter().sum();
                println!("sum of {chunk:?} = {sum}");
            });
        }
    });
    // numbers is still alive here; all threads have joined
    println!("All chunks processed.");
}

In this example, numbers is borrowed by the spawned closures. Each closure captures chunk (a reference into numbers) with move to make the closure own the reference itself. The scope call ensures all spawned closures finish before numbers goes out of scope. No Arc, no manual join, no lifetime gymnastics.

Scoped threads are the idiomatic way to parallelize a computation that borrows data from the calling function. If any spawned thread panics, scope will resume unwinding after the other threads finish, so resources are not left in an inconsistent state.

Scoped Threads vs. External Crates:

Before std::thread::scope was stabilized, the crossbeam crate provided similar functionality. If you are on an older Rust edition, crossbeam::scope remains a well-tested alternative. For modern Rust, std::thread::scope is preferred because it is part of the standard library.

Handling Panics from Threads

A spawned thread might panic. The JoinHandle captures this, so the calling thread can decide how to react. The join() method returns Result<T, Box<dyn Any + Send>>. A successful thread gives Ok(result), a panicked thread gives Err(payload).

use std::thread;
fn main() {
    let handle = thread::spawn(|| {
        panic!("something went wrong in the spawned thread");
    });
    match handle.join() {
        Ok(()) => println!("Thread finished cleanly"),
        Err(e) => {
            // Attempt to downcast the panic payload to a string
            if let Some(msg) = e.downcast_ref::<&str>() {
                println!("Thread panicked with: {msg}");
            } else {
                println!("Thread panicked with an unknown payload");
            }
        }
    }
}

By default, a panic in a spawned thread does not kill the main thread — it is contained inside the JoinHandle. This is a sharp contrast to panics on the main thread, which abort the process (or unwind, depending on panic settings). You should always decide whether to propagate or log panics from spawned threads, because silently ignoring them hides bugs.

Unwrap on JoinHandle Ignores Panic Information:

Calling handle.join().unwrap() will propagate the panic to the calling thread, which can be what you want. But in long-running servers, you might prefer to log the error and restart the task rather than crash the whole process. Match on the result to choose your strategy.

Common Mistakes and How Rust Catches Them

Forgetting to join and expecting the thread to finish. The main thread exiting kills all other threads. If you drop the JoinHandle without calling join(), the spawned thread becomes detached and may be killed before it completes. Rust does not warn about this — it's a logical error, not a memory safety violation — so you must be deliberate.

Assuming threads execute in order. The OS scheduler decides which thread runs when. Even if you spawn thread A before thread B, B might run first. Any assumption about ordering must be enforced with synchronization primitives like mutexes or channels, not by spawn order.

Capturing references without move or scope. The closure passed to thread::spawn must satisfy a 'static bound. If you try to borrow a local variable without move, the compiler will stop you. The error message points you toward either move (for owned transfer) or thread::scope (for borrowing with guaranteed join).

Using non-Send types across threads. Types like Rc<T> or RefCell<T> are not thread-safe and do not implement Send. If you try to move an Rc into a spawned thread, you get a compile-time error. This prevents you from accidentally sharing non-thread-safe reference counts across threads. The solution is to use Arc<T> and Mutex<T> or RwLock<T> as appropriate.

Dropping a mutex guard in a panic-unsafe way. While not specific to spawn, threads often work with locks. If a thread holding a lock panics, the lock is poisoned. The Mutex in std marks itself as poisoned, and subsequent lock() calls return a PoisonError. This forces you to explicitly handle the situation rather than silently continuing with potentially corrupted data.

Unjoined Threads in Loops:

Spawning threads inside a loop without collecting their JoinHandles leaves them detached and may exhaust system resources. If you cannot use scoped threads, store handles in a vector and join them after the loop.

The Mental Model for Beginners

If threads feel abstract, try this: a thread is a separate sheet of instructions that the computer can execute at the same time as your main sheet. All sheets can read the same whiteboard (memory), but if two sheets try to write on the same spot simultaneously, the result is messy. Rust's rules ensure that either only one sheet can write to a spot at a time, or all sheets can read but none can write. When you spawn a thread, you hand it a copy of the variables it needs (via move) or you prove that the original will stay around long enough (via scope). The compiler checks these rules before the program ever runs, so you find out about mistakes while you are writing code, not after the program is deployed.

Summary

Rust's threads are real OS threads, created with thread::spawn and controlled with JoinHandle. The ownership system extends its single-thread guarantees across thread boundaries: move closures transfer ownership, Send and Sync traits prevent unsafe sharing, and thread::scope allows borrowing with a guaranteed join. Thread panics are captured and can be handled without crashing the main process.