How to create threads in Rust

Spawn a new thread in Rust using std::thread::spawn and wait for it to finish with join.

Spawning threads in Rust

You are building a tool that processes a large file while keeping the main loop responsive. In Python, you might fire off a background task and hope it doesn't crash the interpreter. In JavaScript, you rely on the event loop and web workers. Rust gives you something different. You get true operating system threads, but the compiler forces you to declare exactly how data moves between them. If you try to share a mutable reference across threads, the code won't compile. If you get the ownership right, you get parallelism without race conditions.

Threads as separate workers

A thread is a line of execution. Your program starts with one thread, the main thread. When you spawn a new thread, the operating system creates another worker. Both workers run concurrently, potentially on different CPU cores. They share the same memory space, but Rust's type system prevents them from stepping on each other's toes.

Think of a restaurant kitchen. The main thread is the head chef taking orders and plating food. You spawn a thread to chop vegetables. The head chef does not stop working while the vegetable chopper works. Both happen at the same time. The head chef might need to wait for the vegetables before plating, but the chopping runs in parallel. If the head chef leaves the kitchen, the shift ends. The vegetable chopper stops too.

Minimal example

The std::thread module provides spawn. This function takes a closure and runs it in a new thread. It returns a JoinHandle. The handle is your receipt. It proves the thread exists and lets you wait for it to finish.

use std::thread;
use std::time::Duration;

fn main() {
    // Spawn creates a new OS thread and runs the closure.
    // It returns immediately with a JoinHandle.
    let handle = thread::spawn(|| {
        for i in 1..5 {
            println!("Spawned thread: {}", i);
            // Sleep simulates work.
            thread::sleep(Duration::from_millis(10));
        }
    });

    // Main thread continues immediately.
    // It does not wait for the spawned thread.
    for i in 1..5 {
        println!("Main thread: {}", i);
        thread::sleep(Duration::from_millis(10));
    }

    // join blocks until the spawned thread finishes.
    // unwrap panics if the thread panicked.
    handle.join().unwrap();
}

The output interleaves. The main thread and the spawned thread run at the same time. The order of lines depends on the OS scheduler. You cannot predict which thread prints first.

The main thread is the boss. If main exits before the spawned thread finishes, the whole process dies. The spawned thread gets killed mid-execution. Call join if you need the work done before the program ends.

Moving data into threads

Threads cannot borrow data from the main thread by default. The compiler does not know if the thread will finish before the data is dropped. If the thread outlives the data, you get a dangling pointer. Rust prevents this by requiring ownership transfer.

Use the move keyword on the closure. This forces the closure to take ownership of any variables it captures. The data moves into the thread. The main thread can no longer use the data.

use std::thread;

/// Process a vector of numbers in a separate thread.
fn process_data(data: Vec<i32>) {
    // move transfers ownership of data into the closure.
    let handle = thread::spawn(move || {
        // data is owned by this thread now.
        let sum: i32 = data.iter().sum();
        println!("Sum is {}", sum);
    });

    handle.join().unwrap();
}

fn main() {
    let numbers = vec![1, 2, 3, 4, 5];
    process_data(numbers);
    // numbers is moved into process_data.
    // It cannot be used here.
}

The move keyword is the standard convention for thread spawning. It makes the data flow explicit. The compiler checks that all captured variables are moved. If a variable is not Send, the compiler rejects the code. Send is a trait that marks types as safe to transfer across threads. Most types are Send. References are not Send unless they point to thread-safe data.

Use move to transfer ownership into the thread. The compiler forces you to think about who owns what.

Borrowing with scopes

Sometimes you need to borrow data from the stack, not move it. You have a large array and you want to process chunks in parallel, then use the result. Moving the array would require cloning or allocating, which is expensive. Rust 1.63 introduced thread::scope to solve this.

A scope guarantees that all spawned threads finish before the scope ends. The compiler knows the threads cannot outlive the stack frame. This allows borrowing.

use std::thread;

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

    // scope ensures all threads finish before the block ends.
    thread::scope(|s| {
        // Spawn a thread that borrows data mutably.
        s.spawn(|| {
            data.push(4);
            println!("Added 4. Data: {:?}", data);
        });

        // Spawn another thread that borrows data immutably.
        s.spawn(|| {
            println!("Data length: {}", data.len());
        });
    });

    // All threads have finished.
    // data is still alive and valid.
    println!("Final data: {:?}", data);
}

The closure passed to scope receives a scope object. You call spawn on the scope, not on thread. The scope object tracks the threads. When the scope block ends, the compiler inserts code to join all threads. You do not need to call join manually.

Scopes let you borrow safely. The compiler guarantees the threads finish before the data disappears.

Pitfalls and compiler errors

Threads introduce concurrency bugs. Rust's type system catches many of them at compile time. You will encounter specific errors when you try to break the rules.

If you try to share a reference across threads without scope, the compiler rejects you with E0597 (borrowed value does not live long enough). The compiler sees that the thread might outlive the data. You must move the data or use a scope.

If you try to send a type that is not Send across threads, you get a trait bound error. Rc<T> is not Send. It uses non-atomic reference counting. Two threads incrementing the counter at the same time can corrupt the count. Use Arc<T> instead. Arc stands for atomic reference counted. It uses atomic operations to update the counter safely.

use std::rc::Rc;
use std::thread;

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

    // This fails to compile.
    // Rc is not Send.
    let handle = thread::spawn(move || {
        println!("{:?}", data);
    });
}

The compiler error mentions Rc does not implement Send. Replace Rc with Arc to fix this.

use std::sync::Arc;
use std::thread;

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

    // Arc is Send. This compiles.
    let handle = thread::spawn(move || {
        println!("{:?}", data);
    });

    handle.join().unwrap();
}

Another pitfall is panics. If a spawned thread panics, the panic does not propagate to the main thread automatically. The join call returns an error. You must handle the error. If you drop the JoinHandle without joining, the thread might keep running, or it might get detached. The compiler warns if you drop the handle, but it is a warning, not an error. Best practice is to unwrap or handle the error explicitly.

The compiler rejects shared references across threads. Transfer ownership or use thread-safe wrappers.

Convention asides

The Rust community follows a few conventions around threads. Keep unsafe blocks out of thread code unless you are implementing a low-level abstraction. Use safe abstractions like Mutex and Arc.

When you clone an Arc, use Arc::clone(&data) instead of data.clone(). Both compile and both work. The explicit form signals to readers that you are cloning the pointer, not the data. data.clone() looks like a deep clone but isn't.

Use thread::scope for short-lived threads that need stack data. It is cleaner than spawn when you don't need to transfer ownership. The compiler handles the joining for you.

Treat the move keyword as a contract. It tells readers that the closure owns its data. If you see a move closure, you know the data is transferred.

Decision matrix

Choosing the right concurrency primitive depends on your data and your workload.

Use thread::spawn when you have a CPU-bound task and can move all required data into the closure. Use thread::scope when you need to borrow data from the current stack frame and want the compiler to guarantee the thread finishes before the scope ends. Use an async runtime like Tokio when you are handling many I/O-bound tasks, such as network requests, where threads would be too heavy. Use std::process::Command when you need to execute an external binary, not a Rust function.

Trust the borrow checker. It usually has a point.

Where to go next