How to Create Threads in Rust with std

:thread

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

The kitchen counter problem

You are building a tool that downloads a list of URLs. The first request takes two seconds. The second takes two seconds. If you run them one after another, a list of ten URLs takes twenty seconds. You want them to run at the same time. You need a thread. Rust gives you std::thread::spawn to hand off work to the operating system so it can run in parallel with your main code.

Think of your program as a kitchen. The main thread is the head chef. spawn is hiring a sous-chef. You hand the sous-chef a recipe (the closure) and the ingredients (the data). The sous-chef works independently on a separate station. The head chef can keep doing other work, or wait for the sous-chef to finish. The key difference in Rust is the handoff. You cannot just point the sous-chef to a notebook on the counter and say "use that." You have to give them a copy of the notebook, or ensure the notebook stays on the counter forever. Rust enforces this at compile time to prevent the sous-chef from reading a notebook that the head chef already threw away.

How spawn works

std::thread::spawn creates a new operating system thread. It takes a closure and returns a JoinHandle. The closure runs on the new thread. The JoinHandle 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 inside it.
    // The closure captures nothing here, so it is safe to move.
    let handle = thread::spawn(|| {
        for i in 1..=5 {
            println!("Thread is counting: {i}");
            // Sleep to simulate work and let the OS schedule other threads.
            thread::sleep(Duration::from_millis(100));
        }
    });

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

When you call spawn, the runtime asks the OS for a new thread of execution. The OS allocates a stack for that thread. Your closure runs on that stack. spawn returns immediately with the handle. If you drop the handle without calling join, the thread keeps running in the background. If main returns, the process terminates, and the background thread gets killed mid-step.

Call join if you need the result. If you do not care when the thread finishes, you can drop the handle, but the main thread might quit first.

Moving data into threads

The most common wall is the 'static requirement. Threads can run after main returns. Rust assumes any thread might live forever unless you prove otherwise. This means the closure must be 'static. It cannot hold references to local variables. Local variables are dropped when the function ends. If the thread holds a reference to a dropped variable, you get a dangling pointer. Rust prevents this by requiring the closure to own everything it uses.

use std::thread;

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

    // Move ownership of `data` into the thread.
    // The closure takes ownership because the thread might outlive main.
    let handle = thread::spawn(move || {
        // `data` is owned here. We can use it freely.
        let sum: i32 = data.iter().sum();
        println!("Sum calculated in thread: {sum}");
        // Return the result so main can use it.
        sum
    });

    // Wait for the thread and get the result.
    match handle.join() {
        Ok(result) => println!("Main thread got result: {result}"),
        Err(_) => eprintln!("Thread panicked!"),
    }
}

The move keyword forces the closure to take ownership of captured variables. Without move, the closure tries to capture by reference. The compiler rejects this with E0597: data does not live long enough. The compiler knows data will be dropped at the end of main, but the thread might still be running. Adding move transfers ownership of data to the thread. The thread becomes the owner. When the thread finishes, data is dropped.

If you try to use data after spawning the thread, the compiler rejects you with E0382: use of moved value. You moved data into the thread. It is gone from main.

Use move to satisfy the compiler. If you cannot move the data, you cannot send it to the thread.

Handling panics and results

Threads can panic. A panic in a spawned thread does not crash the entire process. It stops that thread. The join call returns a Result. Ok contains the return value of the closure. Err contains the panic payload.

use std::thread;

/// Processes a chunk of data and returns the count of items.
fn process_chunk(data: Vec<i32>) -> usize {
    data.len()
}

fn main() {
    let data = vec![10, 20, 30];

    let handle = thread::spawn(move || {
        // Simulate a failure condition.
        if data.is_empty() {
            panic!("Data is empty!");
        }
        process_chunk(data)
    });

    // join returns Result<usize, Box<dyn Any + Send>>.
    // Handle both success and panic cases.
    match handle.join() {
        Ok(count) => println!("Processed {count} items"),
        Err(e) => eprintln!("Thread panicked: {e:?}"),
    }
}

The Err variant holds a Box<dyn Any + Send>. You can downcast it to get the original panic value, but usually, logging the error is enough. In production code, always handle the Err case. A thread panic is a signal that something went wrong. Ignoring it hides bugs.

Always handle the Err case in production code. A thread panic is not a crash; it is a signal.

Detached threads

Sometimes you want a thread to run in the background without waiting. You can drop the JoinHandle to detach the thread. The thread continues running until it finishes or the process exits.

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

fn main() {
    // Spawn a background thread.
    let _handle = thread::spawn(|| {
        loop {
            println!("Background task running...");
            thread::sleep(Duration::from_secs(1));
        }
    });

    // Drop the handle immediately.
    // The thread is now detached.
    println!("Main thread continues...");
    thread::sleep(Duration::from_millis(500));
    // When main returns, the process exits.
    // The detached thread is killed instantly.
}

Detached threads are risky. If the main thread exits, the detached thread is killed. Any cleanup code in the detached thread never runs. Use detached threads only for background tasks that can survive abrupt termination.

Detached threads are risky. Use them only for background tasks that can survive abrupt termination.

Decision matrix

Use std::thread::spawn when you need true parallelism and can isolate the work into a closure that owns its data. Use std::thread::spawn for fire-and-forget tasks where you drop the handle and accept the thread might be killed if the process exits. Reach for move closures when the thread needs data from the surrounding scope; the compiler will force you to transfer ownership. Pick Arc and Mutex when multiple threads need to share and mutate the same data, though prefer message passing first. Avoid std::thread for CPU-bound work in a web server; use a thread pool like tokio or rayon instead.

Threads are heavy. Do not spawn a thread for every small task. Batch work or reuse threads.

Where to go next