Concurrency with Threads and Channels

std::thread::spawn, Arc<Mutex<T>> for shared state, mpsc channels, and Send/Sync at compile time.

Spawning threads with std::thread::spawn

Rust's standard library maps threads directly onto native OS threads with std::thread::spawn, which takes a closure to run on the new thread and returns a JoinHandle you can use to wait for it to finish:

Rust
use std::thread;

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

    for i in 1..=3 {
        println!("main thread: {i}");
    }

    handle.join().unwrap(); // blocks main until the spawned thread finishes
    println!("both threads done");
}

The two loops interleave unpredictably — the OS scheduler decides how threads are interleaved, and nothing here guarantees an order. .join() is what turns "fire and forget" into "wait for this to actually finish," and it returns a Result (the Err case fires if the spawned thread panicked), which is why .unwrap() appears above.

Moving data into threads with move closures

A closure passed to thread::spawn almost always needs the move keyword, because the new thread might outlive the scope the data was borrowed from — Rust can't prove a borrowed reference will still be valid for as long as the spawned thread might run, so it requires the closure to take full ownership instead:

Rust
use std::thread;

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

    let handle = thread::spawn(move || {
        println!("data inside thread: {:?}", data); // the thread now owns `data`
    });

    handle.join().unwrap();
    // println!("{:?}", data); // compile error — data was moved into the closure
}

Trying to capture a plain, non-'static reference instead fails to compile outright, for exactly the ownership reasons covered on the ownership page earlier in this track:

Rust
use std::thread;

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

    let handle = thread::spawn(|| {
        println!("{:?}", data); // compile error: closure may outlive the current function,
    });                          // but it borrows `data`, which is owned by the current function

    handle.join().unwrap();
}

This is the ownership system doing exactly what it's designed to do: it refuses to let a thread hold a reference to data that might be destroyed before the thread finishes using it — a bug class known as a dangling reference, caught here at compile time instead of surfacing as a crash under load.

Send and Sync — how the compiler knows what's thread-safe

Two marker traits, implemented automatically by the compiler for most types, are what make all of this checkable at compile time rather than left to convention:

  • Send — a type is Send if it's safe to transfer ownership of it to another thread. Almost every type is Send; the notable exception is Rc<T> (Rust's non-thread-safe reference-counted pointer), because its reference count isn't updated atomically.
  • Sync — a type is Sync if it's safe for multiple threads to hold a shared reference (&T) to it at once. A plain RefCell<T> is not Sync, because its interior-mutability bookkeeping isn't safe under concurrent access.

Trying to move an Rc<T> into thread::spawn fails to compile with an error naming Send directly — this is the compiler catching, before the program ever runs, precisely the kind of bug that would otherwise be a runtime data race in most other languages. It's why the next two sections reach for Arc (an atomic reference-counted pointer, Send and Sync) instead of Rc.

Shared state with Arc<Mutex<T>>

When several threads genuinely need to read and modify the same piece of data, Rust's standard tool is Arc<Mutex<T>>:

  • Arc<T> ("Atomically Reference Counted") lets multiple threads jointly own the same heap-allocated value — cloning an Arc bumps a shared, atomic counter rather than copying the underlying data, and the data is only dropped once every clone has gone out of scope.
  • Mutex<T> ("mutual exclusion") wraps a value so that only one thread can access it at a time — .lock() blocks until the lock is available, then returns a MutexGuard that derefs to the inner value and automatically releases the lock when it's dropped.
Rust
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); // clones the Arc (cheap), not the underlying i32
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap(); // blocks until the lock is free
            *num += 1;
        }); // `num` (the MutexGuard) is dropped here, releasing the lock automatically
        handles.push(handle);
    }

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

    println!("Result: {}", *counter.lock().unwrap()); // Result: 10, every time
}

The compiler enforces that you can't touch the i32 inside the Mutex without going through .lock() first — there's no way to accidentally read or write the shared value without acquiring the lock, unlike a raw shared variable in C, where nothing stops a thread from reading it mid-write.

Message passing with mpsc channels

The alternative philosophy — summarized by Rust's own documentation as "do not communicate by sharing memory; instead, share memory by communicating" — is a channel: threads send owned values through it instead of touching shared state at all. std::sync::mpsc ("multiple producer, single consumer") provides exactly that:

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

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

    for id in 0..3 {
        let tx = tx.clone(); // each thread gets its own cloned sender
        thread::spawn(move || {
            let result = id * id;
            tx.send(result).unwrap(); // ownership of `result` moves into the channel
        });
    }

    drop(tx); // drop the original sender so the receiver knows when every clone is gone

    let mut results: Vec<i32> = rx.iter().collect(); // blocks, collecting until all senders finish
    results.sort();
    println!("{:?}", results); // [0, 1, 4]
}

Sending a value with tx.send(result) moves result into the channel — the sending thread can no longer use it afterward, so there's no possibility of both the sender and the receiver mutating the same data at once. rx.iter() yields values as they arrive and stops automatically once every Sender (the original plus every .clone()) has been dropped, which is why the original tx is explicitly dropped after spawning — otherwise rx.iter() would block forever waiting for a sender that will never send again.

Shared state vs. message passing

Shared state (Arc<Mutex<T>>) Message passing (mpsc)
Model Multiple threads access one shared value, one at a time, under a lock Threads communicate by sending owned values through a channel
Compile-time guarantee You cannot touch the data without holding the MutexGuard from .lock() Sending a value moves it — sender and receiver never both hold it at once
Best fit A small, frequently-touched shared value (a counter, a cache) Pipelines, worker pools, collecting independent results
Main failure mode Deadlock — acquiring multiple locks in inconsistent order across threads Sending into a channel whose receiver was dropped returns an Err instead of hanging

Common mistakes

  • Forgetting to Arc::clone the Arc before moving it into each thread — the first move closure takes ownership of the original, and every subsequent thread::spawn fails to compile because there's nothing left to move.
  • Holding a MutexGuard longer than necessary (e.g., across an unrelated, slow computation) and serializing work that didn't actually need to be serialized — keep the locked section as small as possible.
  • Acquiring two different mutexes in different orders across different threads, creating a classic deadlock where each thread waits forever for a lock the other one holds.
  • Reaching for Rc<RefCell<T>> out of habit in threaded code — it compiles fine in single-threaded code but fails to compile the moment it's sent across a thread boundary, because neither Rc nor RefCell is Sync; Arc<Mutex<T>> is the thread-safe equivalent.

Interview questions

Q: How does Rust prevent data races at compile time when using threads? Through a combination of ownership (a move closure takes full ownership of the data it captures, so a spawned thread can never hold a dangling reference) and the Send/Sync marker traits, which the compiler checks automatically — a type that isn't safe to share or transfer across threads (like Rc<T>) simply fails to compile when used that way, rather than compiling into a runtime race condition.

Q: What's the difference between Arc<Mutex<T>> and mpsc channels for coordinating threads, and when would you reach for each? Arc<Mutex<T>> lets multiple threads share and mutate one value, one thread at a time, enforced by requiring .lock() before any access. mpsc channels instead have threads send owned values to each other, so no two threads ever touch the same data simultaneously. Shared state fits a small, frequently-touched value like a counter; message passing fits a pipeline or worker-pool pattern where independent tasks each produce a result that needs collecting.

Q: Why does thread::spawn typically require a move closure? Because the spawned thread might outlive the function that created it, so Rust can't guarantee a borrowed reference captured by the closure would still be valid for as long as the thread runs. move forces the closure to take ownership of everything it captures instead of borrowing it, which the compiler can verify is safe regardless of how long the thread actually runs.