Concurrency in Rust

Concurrency lets a program do more than one thing at once — download a file while updating a progress bar, or crunch a big dataset across every CPU core you have. Most languages let you write concurrent code, but they can’t stop you from writing a data race: a bug where two threads read and write the same memory at the same time with no coordination. Rust is different. The same ownership and borrowing rules that prevent memory bugs in single-threaded code are checked by the compiler for multi-threaded code too, which is why this is often called Rust’s "fearless concurrency" — most data races are caught before your program ever runs, not discovered in production at 3am.

Overview: How Concurrency Works in Rust

Under the hood, std::thread::spawn creates a genuine operating-system thread — not a lightweight "green thread" managed by a language runtime. Each thread gets its own stack and is scheduled by the OS, just like a thread you’d create in C or Java. Rust threads are therefore relatively heavyweight (a few megabytes of stack by default), but they map directly onto real CPU cores and need no special async runtime just to exist.

The core problem every concurrent program faces is: what happens when two threads read and write the same piece of memory at once, with no coordination? That’s a data race, and it’s undefined behavior — the result can be corrupted data, a crash, or a bug that only appears once in a million runs. Most languages leave preventing this entirely up to your discipline. Rust instead extends its type system with two marker traits that the compiler checks automatically:

  • Send — a type is Send if a value of that type can be safely moved to another thread. Almost every type is Send. The notable exception is Rc<T>, whose reference count is a plain, non-atomic integer: two threads bumping it simultaneously could corrupt the count, so Rc<T> is deliberately not Send.
  • Sync — a type T is Sync if it’s safe for multiple threads to hold a shared reference &T to it at once (formally, T is Sync exactly when &T is Send). Types with interior mutability that isn’t thread-safe, like RefCell<T>, are not Sync.

You never implement these traits by hand for ordinary structs — the compiler derives them automatically based on whether every field is Send/Sync. This is what makes the whole system work: for every type in your program, the compiler already knows whether it’s safe to ship across a thread boundary, and thread::spawn‘s function signature simply requires its closure, and everything that closure owns, to satisfy that bound. Violate it, and your program fails to compile instead of failing unpredictably at runtime.

There’s one more piece: a thread::spawn closure must be 'static, meaning it can’t borrow anything with a shorter lifetime than "the rest of the program." That’s why thread closures almost always use the move keyword — move forces the closure to take ownership of the variables it uses instead of borrowing them, so the compiler never has to worry about the spawned thread outliving data it only points to. (Scoped threads, covered below, relax this rule for data that provably can’t be needed after the thread finishes.)

Once data genuinely needs to be read and written from more than one thread, you reach for two tools together: Arc<T> (atomic reference count) gives multiple threads shared ownership of the same heap allocation, and Mutex<T> (mutual exclusion) ensures only one thread can access the value inside at a time, by routing every access through a lock() call that blocks until the lock is free. Arc alone lets you share data but not mutate it safely; Mutex alone has no way to be owned by more than one thread. Together, Arc<Mutex<T>> is the standard pattern for "many threads, one mutable value."

Syntax

The building blocks of Rust concurrency live in std::thread, std::sync, and std::sync::mpsc:

use std::thread;

// Spawn a new OS thread; the closure must own everything it captures
let handle = thread::spawn(move || {
    // ... work done on the new thread ...
});

// Block the current thread until the spawned thread finishes
let result = handle.join().unwrap();
Item Purpose
thread::spawn(closure) Starts a new OS thread running closure; returns a JoinHandle<T> where T is the closure’s return type.
handle.join() Blocks the calling thread until the spawned thread finishes; returns Result<T, Box<dyn Any + Send>> (an Err means the thread panicked).
Arc::new(v) / Arc::clone(&a) Wraps a value for atomic, thread-safe shared ownership; clone bumps a reference count instead of copying the data.
Mutex::new(v) / m.lock() Wraps a value so only one thread can access it at a time; lock() blocks and returns a MutexGuard that unlocks automatically when dropped.
mpsc::channel() Creates a multi-producer, single-consumer queue: a Sender (tx) and Receiver (rx) pair for passing owned values between threads.
thread::scope(closure) Runs threads that may borrow local data, guaranteeing they all finish before scope returns.

Examples

Example 1: spawn a thread, do work on it, and get a value back through join().

use std::thread;

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

    let result = handle.join().unwrap();
    println!("Sum computed in thread: {}", result);
}

Output:

Sum computed in thread: 15

The closure runs on a brand-new OS thread and returns 15 as its final expression. handle.join() blocks main until that thread finishes, and .unwrap() extracts the value from the Ok result (it would panic only if the spawned thread itself panicked).

Example 2: sharing data safely with Arc<Mutex<T>> across many threads.

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();
            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Final count: {}", *counter.lock().unwrap());
}

Output:

Final count: 10

Ten threads each clone the Arc (cheap: it just bumps an atomic counter), lock the shared Mutex, increment the number inside, and let the guard unlock automatically when it goes out of scope. Because every handle is joined before the final println!, the result is always exactly 10 — the Mutex serialized the ten increments so none were lost.

Example 3: passing owned values between threads with a channel, instead of sharing memory.

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let messages = vec!["hello", "from", "the", "thread"];
        for msg in messages {
            tx.send(msg.to_string()).unwrap();
        }
    });

    for received in rx {
        println!("Got: {}", received);
    }
}

Output:

Got: hello
Got: from
Got: the
Got: thread

The spawned thread owns the sending half (tx) after move, and sends four String values in order. The main thread treats rx as an iterator: it yields each received value in the order it was sent, and the loop ends naturally once tx is dropped (when the spawned thread finishes) and no more values are coming.

Scoped Threads

Ordinary threads must be 'static, which is why the earlier examples needed move and, for shared data, Arc. Since Rust 1.63, thread::scope lets threads borrow local variables directly, because the compiler can prove every thread finishes before the scope ends:

use std::thread;

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

    thread::scope(|s| {
        s.spawn(|| {
            let sum: i32 = numbers.iter().sum();
            println!("Sum: {}", sum);
        });
        s.spawn(|| {
            let max = numbers.iter().max().unwrap();
            println!("Max: {}", max);
        });
    });

    println!("Scope finished, numbers still usable: {:?}", numbers);
}

Output:

Sum: 15
Max: 5
Scope finished, numbers still usable: [1, 2, 3, 4, 5]

Both scoped threads borrow numbers immutably at the same time, which the borrow checker allows. The order of the "Sum" and "Max" lines isn’t guaranteed, since the two threads run concurrently, but the final line is always last: thread::scope blocks until every thread spawned inside it has finished before returning, so numbers is guaranteed to still be valid afterward.

How It Works Step by Step

Tracing through the Arc<Mutex<T>> counter example shows exactly what the compiler and runtime are doing:

  • Arc::new(Mutex::new(0)) allocates a Mutex<i32> on the heap and wraps it in an Arc whose atomic reference count starts at 1.
  • Each loop iteration calls Arc::clone(&counter), which does not copy the Mutex — it atomically increments the reference count and returns a new Arc pointing at the same allocation. That clone, and only that clone, is moved into the thread’s closure.
  • counter.lock().unwrap() blocks the calling thread until it can acquire exclusive access, returning a MutexGuard<i32> — a smart pointer that dereferences to the inner value and automatically releases the lock when it’s dropped at the end of the statement.
  • *num += 1 mutates the guarded integer. Since only one thread can hold the lock at a time, the ten increments are serialized: no two threads can be inside the critical section simultaneously, so none of the increments is lost to a race.
  • Calling handle.join() on every handle blocks main until all ten spawned threads have returned, guaranteeing every increment has already happened before execution reaches the final line.
  • The last counter.lock().unwrap() acquires the lock one more time just to read the value, which is now reliably 10.
  • As each Arc clone is dropped (when its owning thread ends), the reference count decreases; once it reaches zero the Mutex and its inner i32 are freed. No manual cleanup is needed.

Common Mistakes

Mistake 1: forgetting move and trying to borrow data into a thread.

use std::thread;

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

    let handle = thread::spawn(|| {
        println!("{:?}", data);
    });

    handle.join().unwrap();
}

This fails to compile with an error like "closure may outlive the current function, but it borrows data, which is owned by the current function." The closure only reads data, so without move it captures a reference. But thread::spawn requires a 'static closure, because nothing stops you from dropping the JoinHandle and letting the thread run long after main‘s local data would have been freed. The fix is to give the closure ownership:

use std::thread;

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

    let handle = thread::spawn(move || {
        println!("{:?}", data);
    });

    handle.join().unwrap();
}

Output:

[1, 2, 3]

Mistake 2: trying to share an Rc<T> across threads instead of an Arc<T>.

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

fn main() {
    let data = Rc::new(5);

    let handle = thread::spawn(move || {
        println!("{}", data);
    });

    handle.join().unwrap();
}

This fails with an error like "Rc<i32> cannot be sent between threads safely: the trait Send is not implemented for Rc<i32>." Rc‘s reference count is a plain integer with no synchronization, so two threads cloning or dropping it at once could corrupt it — the compiler refuses to let it cross a thread boundary at all. Swap it for the atomic version:

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

fn main() {
    let data = Arc::new(5);

    let handle = thread::spawn(move || {
        println!("{}", data);
    });

    handle.join().unwrap();
}

Output:

5

A related runtime gotcha worth knowing even though it isn’t a compile error: if a thread panics while holding a Mutex lock, the Mutex becomes "poisoned," and every future .lock() call returns an Err instead of blocking forever. Calling .unwrap() on that result will panic in turn — which is usually the right default, since it surfaces that some invariant was left broken, but production code sometimes recovers with .lock().unwrap_or_else(|poisoned| poisoned.into_inner()) when it’s safe to keep using the possibly-inconsistent data.

Best Practices

  • Prefer message passing (channels) over shared mutable state when the design allows it — "share memory by communicating" usually produces simpler, easier-to-reason-about code than shared locks.
  • Reach for Arc<Mutex<T>> only when threads genuinely need to mutate the same data; don’t add a Mutex as a reflexive way to silence the borrow checker.
  • Keep the time spent holding a lock as short as possible — don’t do expensive computation or I/O while a MutexGuard is alive, since every other thread waiting on that lock is blocked.
  • Always .join() handles you care about, or use thread::scope so the compiler enforces that every thread finishes before you move on.
  • Prefer thread::scope over Arc when the spawning function will simply wait for the threads anyway — it avoids reference counting and lets threads borrow local data directly.
  • Remember that .join() returns a Result: a panic in a spawned thread does not crash the whole process by default, so decide deliberately whether to propagate, log, or ignore it rather than always calling .unwrap().
  • For CPU-bound work, OS threads (what this lesson covers) are the right tool; for programs juggling thousands of mostly-idle I/O operations (network sockets, timers), an async runtime such as tokio is usually a better fit — that’s a separate topic from raw threads.

Practice Exercises

  • Spawn 5 threads, each printing its own index (0 through 4) captured with move, then join all of them before main exits. (Hint: the order the lines print in is not guaranteed — that’s expected.)
  • Create an Arc<Mutex<Vec<i32>>> shared across 3 threads, where each thread pushes its own thread number into the vector. Join all threads, then lock once more and print the vector’s contents.
  • Extend the channel example so two separate sender threads (each holding a tx.clone()) send messages into the same channel, while a single receiver loop prints everything it gets. Expected output: 8 "Got: …" lines total, interleaved in some order.

Summary

  • Rust threads are real OS threads created with thread::spawn, which returns a JoinHandle<T> you can .join() to wait for the result.
  • The Send and Sync marker traits let the compiler verify at compile time whether a type can safely cross or be shared across a thread boundary — this is the foundation of "fearless concurrency."
  • Thread closures must be 'static, so they almost always use move to take ownership of captured data instead of borrowing it.
  • Arc<T> gives multiple threads shared ownership; Mutex<T> ensures only one thread mutates the value at a time; together, Arc<Mutex<T>> is the standard pattern for shared mutable state.
  • mpsc channels let threads communicate by sending owned values instead of sharing memory directly, which is often simpler and safer.
  • thread::scope (stable since Rust 1.63) lets threads borrow local data directly, since the compiler can guarantee they all finish before the scope returns.
  • A Mutex can become poisoned if a thread panics while holding its lock — know that .lock() returns a Result for this reason.