Threads and Fearless Concurrency

Rust's boldest promise is "fearless concurrency" — the claim that you can write multithreaded code and have the compiler guarantee, at compile time, that you have no data races. Coming from languages where concurrency bugs are a dark art of subtle, intermittent horror, this sounds too good to be true. It isn't: the same ownership and borrowing rules that give Rust memory safety extend naturally to threads. This module explores concurrency, starting with the basics — spawning threads and moving data into them.

This post opens Module 3 of Rust from the Ground Up — concurrency, async, testing, and macros. We start with threads: spawning them with std::thread, joining them, and the crucial role of move closures in getting data safely into threads. Rust’s ownership system, which you learned in Module 1, turns out to be exactly what makes concurrency safe — the compiler prevents data races using the same rules that prevent memory bugs. This is “fearless concurrency,” and it starts here.

Spawning threads

Rust’s standard library provides threads via std::thread. You spawn a thread with thread::spawn, passing a closure with the code to run:

use std::thread;

fn main() {
    // Spawn a new thread that runs the closure.
    thread::spawn(|| {
        for i in 1..5 {
            println!("hi number {i} from the spawned thread");
        }
    });

    for i in 1..3 {
        println!("hi number {i} from the main thread");
    }
}

Spawning a thread is simple — thread::spawn with a closure — but the spawned thread runs independently and won’t necessarily finish before main exits. Controlling that requires joining.

Joining threads

thread::spawn returns a JoinHandle. Calling .join() on it waits for that thread to finish:

use std::thread;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..5 {
            println!("hi number {i} from the spawned thread");
        }
    });

    for i in 1..3 {
        println!("hi number {i} from the main thread");
    }

    // Wait for the spawned thread to finish before continuing.
    handle.join().unwrap();
}

join gives you control over thread completion — wait for a thread to finish (and observe whether it panicked) — which is essential for coordinating threads. But the more interesting question is getting data into a thread safely, which is where ownership meets concurrency.

move closures: getting data into threads

Threads usually need data. To use data from the surrounding scope in a thread, the closure must take ownership of it with the move keyword — and understanding why reveals how ownership makes threads safe:

use std::thread;

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

    // `move` transfers ownership of `v` into the thread's closure.
    let handle = thread::spawn(move || {
        println!("Here's a vector: {v:?}");
    });

    handle.join().unwrap();
}

move closures transfer ownership of data into a thread, which the compiler requires to prevent dangling references — and this same ownership transfer prevents data races, because data moved into a thread can’t be simultaneously accessed elsewhere. This is the foundation of fearless concurrency: Rust’s ownership rules make threads safe at compile time. But sometimes you genuinely need to share data between threads — which needs the tools of the next post.

Key takeaways

Further reading

Sources & References