Fearless Concurrency: Threads, Channels, and Arc<Mutex<T>>
Rust lets you write multi-threaded code without dread, because the same ownership rules that stop use-after-free bugs also stop data races — at compile time.
Concurrency has a well-earned reputation as one of the hardest parts of programming, because most languages let two threads touch the same memory at the same time and simply trust the programmer not to mess it up. Rust doesn't trust you — or rather, it doesn't need to, because the ownership and borrowing rules you've been using since early in this course (one owner, or many readers XOR one writer) turn out to be exactly the rules that prevent data races too. Every concurrency primitive in Rust's standard library is designed so that a data race, if you tried to write one, fails to compile rather than fails at 3 a.m. in production. That's what 'fearless concurrency' means in practice: not that concurrent code is easy to reason about, but that the compiler is reasoning about the dangerous part for you.
The most basic tool is `std::thread::spawn`, which takes a closure and runs it on a new OS thread, returning a `JoinHandle`. The parent thread doesn't wait for the spawned thread automatically — if `main` finishes first, the whole process exits and any unfinished spawned threads are simply cut off. Calling `.join()` on the handle blocks the current thread until the spawned one finishes, and it returns a `Result` because the spawned thread might have panicked; `.join().unwrap()` is the common shorthand when you just want to propagate that panic.
Notice the `move` keyword you'll see in front of almost every thread closure. By default, a closure borrows the variables it uses from its environment, but a borrowed reference has no guarantee it will outlive the thread using it — the spawned thread might still be running long after the function that created it has returned. `move` forces the closure to take ownership of everything it captures instead of borrowing it, so the data is guaranteed to live exactly as long as the thread needs it. This is ownership doing the same job it always does — deciding who's responsible for a value's lifetime — just applied across a thread boundary instead of a function boundary.
Threads that never talk to each other aren't very useful, so the standard library ships `std::sync::mpsc` — multi-producer, single-consumer channels — for message passing. You create a channel with `mpsc::channel()`, which hands back a `Sender` and a `Receiver`; clone the `Sender` to give multiple threads a way to push values into the same channel, but there's only ever one `Receiver` pulling them out. Sending a value through the channel *moves* it — the sending thread can no longer use it — which is Rust's ownership rules again quietly preventing two threads from touching the same data at once, this time by making sure only the receiving side ever holds it.
Message passing works well when data flows in one direction, but sometimes multiple threads genuinely need to read and write the *same* piece of state — a shared counter, a cache, a connection pool. For that you reach for `Arc<T>` combined with `Mutex<T>`. `Arc` is `Rc`'s thread-safe sibling: same reference-counting idea, same cheap `.clone()`, but its counter updates use atomic operations so multiple threads can clone and drop it simultaneously without corrupting the count. `Mutex<T>` provides the interior mutability — you call `.lock()` to get exclusive access, which blocks the calling thread if another thread already holds the lock, and returns a guard that releases the lock automatically when it goes out of scope. `Arc<Mutex<T>>` together is the multi-threaded equivalent of the `Rc<RefCell<T>>` pattern from the last lesson: shared ownership plus safe interior mutability, just built from atomic, thread-safe primitives instead.
All of this is enforced by two traits you'll rarely write yourself but should know by name: `Send`, meaning a type is safe to transfer to another thread, and `Sync`, meaning a type is safe to access from multiple threads at once through a shared reference. `Rc<T>` deliberately does not implement `Send` or `Sync` — the compiler will refuse to let you move one into a `thread::spawn` closure at all — precisely because its reference count isn't atomic and would corrupt under concurrent access. That refusal is the whole idea: instead of a code review catching a subtle threading bug, or a rare crash in production catching it for you, the compiler catches it before the program exists. Nothing about the language 'knows' concurrency is hard; it just happens that the ownership rules built for single-threaded safety generalize perfectly to multi-threaded safety too.
use std::thread;fn main() {let data = vec![1, 2, 3, 4, 5];let handle = thread::spawn(move || {let sum: i32 = data.iter().sum();println!("sum computed on spawned thread: {sum}");sum});let result = handle.join().unwrap();println!("main thread received: {result}");}
`move` transfers ownership of data into the spawned thread's closure, so the closure — not main — is responsible for it; join() blocks until the thread finishes and hands back its return value wrapped in a Result.
use std::sync::mpsc;use std::thread;fn main() {let (tx, rx) = mpsc::channel();for id in 0..3 {let tx_clone = tx.clone();thread::spawn(move || {let message = format!("hello from producer {id}");tx_clone.send(message).unwrap();});}drop(tx);for received in rx {println!("main received: {received}");}}
Each producer thread gets its own cloned Sender, but there's only one Receiver; dropping the original tx lets the for loop over rx know when every producer has finished and the channel is closed.
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());}
Each thread clones the Arc (cheap, atomic) and calls lock() to get exclusive, blocking access to the shared i32 inside the Mutex; ten threads increment the same counter with zero data races and no unsafe code.
🧠 Check your understanding
0/1 · 0/1 answered1. Why won't the following code compile: spawning a thread that captures an `Rc<RefCell<i32>>` by moving it into the closure, incrementing it, and joining the thread?