Chapter 9: Concurrency
Rust's promise of "fearless concurrency" rests on one fact: the same ownership and borrowing rules that prevent memory errors also prevent data races at compile time. If two threads share data, the compiler insists that the sharing is safe — either immutable, or guarded by a synchronization primitive. This chapter covers the two mainstream models, threads with shared state and message passing, and the traits (Send, Sync) that make them safe.
Learning Objectives
- Spawn threads and join them.
- Share data safely with
Arc,Mutex, andRwLock. - Communicate between threads with channels (
mpsc). - Understand
SendandSyncand why they matter. - Avoid the common deadlocks and pitfalls of shared-state concurrency.
9.1 Spawning threads
std::thread::spawn starts an OS thread and returns a JoinHandle. Call .join() to wait for it to finish.
use std::thread; fn main() { let handle = thread::spawn(|| { for i in 0..5 { println!("child says {i}"); } }); for i in 0..3 { println!("main says {i}"); } handle.join().unwrap(); // wait for the child thread }
Closures and move
A spawned thread cannot borrow local variables unless they live long enough — and main might return before the thread finishes. The fix is move, which transfers ownership of captured variables into the closure:
use std::thread; fn main() { let data = vec![1, 2, 3]; let handle = thread::spawn(move || { // `data` is now owned by this thread. println!("got {} items", data.len()); }); handle.join().unwrap(); // println!("{:?}", data); // error: `data` was moved into the thread }
9.2 Send and Sync
These two marker traits are the foundation of thread safety. You do not implement them yourself; the compiler derives them automatically when all fields are Send/Sync.
Send: a typeTisSendif it is safe to move aTto another thread.Sync: a typeTisSyncif it is safe for multiple threads to hold&Tsimultaneously (i.e.,&TisSend).
Most types are both. The exceptions are types with interior mutability without synchronization — Rc<T> is the classic example: it is not Send or Sync, because its reference counting is not atomic. Use Arc<T> (atomic reference count) instead when sharing across threads.
9.3 Shared state: Arc + Mutex
To share mutable data between threads, combine:
Arc<T>— an atomically reference-counted pointer, so several threads can own the same allocation.Mutex<T>— a lock that guarantees exclusive access to the inner value.
use std::sync::{Arc, Mutex}; use std::thread; fn main() { // Shared, mutable counter protected by a mutex, shared via Arc. let counter = Arc::new(Mutex::new(0)); let handles: Vec<_> = (0..10) .map(|_| { let counter = Arc::clone(&counter); thread::spawn(move || { let mut num = counter.lock().unwrap(); *num += 1; }) }) .collect(); for h in handles { h.join().unwrap(); } println!("final = {}", *counter.lock().unwrap()); // 10 }
.lock().unwrap() deserves a word: a Mutex can be poisoned if a thread panics while holding the lock. .lock() then returns Err. Calling .unwrap() propagates the panic, which is usually the right thing — a poisoned lock means the data may be in an inconsistent state.
RwLock — many readers, one writer
When reads vastly outnumber writes, RwLock allows multiple simultaneous readers:
#![allow(unused)] fn main() { use std::sync::RwLock; let cache = RwLock::new(0); // Multiple readers can hold read locks at once. { let r1 = cache.read().unwrap(); let r2 = cache.read().unwrap(); println!("reads: {} {}", *r1, *r2); } // A writer gets exclusive access. { let mut w = cache.write().unwrap(); *w += 1; } }
9.4 Message passing: channels
The other concurrency model is to share data by communicating, not by sharing memory. Rust's std::sync::mpsc (multi-producer, single-consumer) channel does this.
use std::sync::mpsc; use std::thread; use std::time::Duration; fn main() { let (tx, rx) = mpsc::channel(); let sender = thread::spawn(move || { let messages = ["hi", "from", "the", "thread"]; for m in messages { tx.send(m).unwrap(); thread::sleep(Duration::from_millis(10)); } }); // `rx` is an iterator that yields received values until the sender drops. for received in rx { println!("got: {received}"); } sender.join().unwrap(); }
sendreturnsResultbecause the receiver might have been dropped.- Multiple producers: clone
txwithtx.clone()and move each into a different thread.
Channels decouple the threads: the sender does not need to know who reads, and the locking is hidden inside the channel implementation.
9.5 A tiny worker pool
Putting the pieces together — a pool of worker threads consuming jobs from a shared queue:
use std::sync::{Arc, Mutex}; use std::sync::mpsc; use std::thread; fn main() { let (tx, rx) = mpsc::channel::<Box<dyn FnOnce() + Send>>(); let rx = Arc::new(Mutex::new(rx)); // Spawn four workers that share the receiver. let mut workers = Vec::new(); for _ in 0..4 { let rx = Arc::clone(&rx); workers.push(thread::spawn(move || loop { let job = { let lock = rx.lock().unwrap(); lock.recv() }; match job { Ok(task) => task(), Err(_) => break, // all senders dropped — shut down } })); } // Send a few jobs. for i in 0..8 { tx.send(Box::new(move || { println!("job {i} on {:?}", thread::current().id()); })) .unwrap(); } drop(tx); // close the channel so workers can exit for w in workers { w.join().unwrap(); } }
The Mutex around the receiver is necessary because mpsc::Receiver is not Sync — only one thread may call recv at a time.
9.6 Pitfalls
- Deadlock. Acquiring two locks in different orders in different threads deadlocks. Acquire locks in a consistent global order, or use a single lock that guards both resources.
- Holding a lock across
.awaitin async code. Astd::sync::Mutexguard is not designed to span.awaitpoints. In async code, usetokio::sync::Mutex, or scope the guard so it drops before the.await. - Using
Rcacross threads. The compiler rejects it (Rcis notSend). Switch toArc. - Forgetting to
join. Detached threads may outlive the data they reference — but since Rust forcesmoveor'staticborrows, this is caught at compile time. - Too much locking. If every operation takes a global mutex, you have serialized the work. Prefer finer-grained locks, sharded data, or channels.
9.7 Summary
Rust makes data races impossible by construction: shared mutable state requires a Mutex, reference counting across threads requires Arc, and the Send/Sync traits are checked at compile time. Use channels when threads communicate; use Arc<Mutex<T>> (or RwLock) when they share mutable data. The borrow checker turns concurrency bugs that are Heisenbergian in other languages into compile errors here.
Exercises
- Spawn ten threads, each incrementing a shared
Arc<Mutex<i32>>a thousand times, and print the final value. - Rewrite the previous exercise using a channel instead of shared state: each thread sends its increment count to a receiver that sums them.
- Build a pipeline where one thread generates numbers, a second squares them, and a third prints them, connected by two channels.