Step 13 of 17
Fearless concurrency
Run work on threads, share data with Arc and Mutex, pass messages over channels, and understand how Send and Sync rule out data races.
In most languages, concurrency bugs are found in production: two threads update the same map, a counter loses increments, a lock is forgotten on one code path. Rust moves that class of bug to compile time. The same ownership and borrowing rules you've learned, plus two marker traits, Send and Sync, make data races impossible in safe code. The Rust community calls it fearless concurrency. This lesson covers OS threads, scoped threads, message passing with channels, shared state with Mutex and atomics, how Send and Sync work, and the bugs Rust still can't catch for you.
Spawning threads
std::thread::spawn starts an OS thread running a closure and returns a JoinHandle. Calling join() waits for the thread to finish and returns its result:
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
for i in 1..=3 {
println!("worker: {i}");
thread::sleep(Duration::from_millis(10));
}
42 // the thread's return value
});
println!("main keeps going");
let answer = handle.join().unwrap(); // Err if the thread panicked
println!("worker returned {answer}");
}When main returns, the process exits and any threads still running are killed, so join the ones whose work matters.
A spawned thread can outlive the function that started it, so its closure must be 'static: it can't borrow the caller's locals. Use move to transfer ownership into the thread:
use std::thread;
fn main() {
let data = vec![1, 2, 3];
let handle = thread::spawn(move || data.iter().sum::<i32>());
// data has moved; using it here would be a compile error
println!("{}", handle.join().unwrap());
}Without move, the compiler reports that the closure may outlive the borrowed value and suggests adding move.
Scoped threads
Often you want threads that work on borrowed data and are guaranteed to finish before the function returns. std::thread::scope (Rust 1.63) gives you exactly that: every thread spawned in the scope is joined automatically at its end, so borrowing locals is safe.
use std::thread;
fn main() {
let numbers: Vec<u64> = (1..=1_000_000).collect();
let chunks: Vec<&[u64]> = numbers.chunks(250_000).collect();
let total: u64 = thread::scope(|s| {
let handles: Vec<_> = chunks
.iter()
.map(|chunk| s.spawn(move || chunk.iter().sum::<u64>()))
.collect();
handles.into_iter().map(|h| h.join().unwrap()).sum()
});
println!("{total}"); // 500000500000
println!("{}", numbers.len()); // numbers is still ours
}No Arc, no clones: each thread borrows a slice of numbers, and the compiler knows the borrows end before scope returns. For data-parallel work like this, the rayon crate goes further: change .iter() to .par_iter() and it spreads the work across a thread pool with work stealing.
Message passing with channels
One way to avoid shared state is not to share: threads own their data and send messages to each other. std::sync::mpsc provides multi-producer, single-consumer channels:
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel::<String>();
for id in 0..3 {
let tx = tx.clone(); // each producer gets its own sender
thread::spawn(move || {
for job in 0..2 {
tx.send(format!("worker {id} finished job {job}")).unwrap();
}
});
}
drop(tx); // drop the original so the channel closes when the workers finish
for msg in rx { // iterates until every sender is dropped
println!("{msg}");
}
}Sending a value moves it into the channel, so the sending thread can't touch it afterwards. Ownership transfer is enforced by the type system, not by convention. mpsc::sync_channel(n) creates a bounded channel whose send blocks when n messages are queued, which gives you backpressure. For multiple consumers, or select-style waiting on several channels, the crossbeam-channel crate is the standard choice.
Shared state with Mutex
When several threads really do need the same data, wrap it in a Mutex<T> and share the mutex with Arc:
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let hits: Arc<Mutex<HashMap<String, u32>>> = Arc::new(Mutex::new(HashMap::new()));
let handles: Vec<_> = ["/", "/about", "/"]
.into_iter()
.map(|path| {
let hits = Arc::clone(&hits);
thread::spawn(move || {
let mut map = hits.lock().unwrap(); // blocks until the lock is free
*map.entry(path.to_string()).or_insert(0) += 1;
}) // the guard drops here, unlocking the mutex
})
.collect();
for h in handles {
h.join().unwrap();
}
println!("{:?}", hits.lock().unwrap().get("/")); // Some(2)
}Rust's Mutex is different from most languages' in a way that matters: it owns the data it protects. The only way to reach the HashMap is through lock(), which returns a MutexGuard that dereferences to the data and unlocks when dropped. You can't forget to lock, and you can't forget to unlock, even on an early return.
lock() returns a Result because of poisoning: if a thread panics while holding the lock, the data may be half-updated, so later lock() calls return Err. Most code calls .unwrap() and treats a poisoned lock as a bug that should propagate.
Some practical rules:
- Keep critical sections short. Copy out what you need and drop the guard before slow work (I/O, network calls). Calling
drop(guard)or putting the locked section in its own block makes the scope obvious. - Use
RwLock<T>when reads vastly outnumber writes; many readers can hold it at once. - Use atomics (
AtomicU64,AtomicBool) for single counters and flags; they're lock-free and much cheaper. - Acquire multiple locks in a consistent order everywhere, or you risk deadlock.
use std::sync::atomic::{AtomicU64, Ordering};
use std::thread;
fn main() {
let requests = AtomicU64::new(0); // scoped threads can borrow it: no Arc needed
thread::scope(|s| {
for _ in 0..8 {
s.spawn(|| {
for _ in 0..1000 {
requests.fetch_add(1, Ordering::Relaxed);
}
});
}
});
println!("{}", requests.load(Ordering::Relaxed)); // always 8000
}Ordering::Relaxed is enough for a standalone counter. Stronger orderings (Acquire, Release, SeqCst) matter when an atomic guards other data; if you're unsure, use SeqCst or a Mutex.
Send and Sync
How does the compiler know Rc can't cross threads but Arc can? Two marker traits, which have no methods and which the compiler implements automatically based on a type's fields:
Send: a value of this type can be moved to another thread. Almost everything isSend.Rc<T>isn't, because two threads could update its non-atomic counter at the same time.Sync: a value of this type can be shared between threads by reference, i.e.&TisSend.CellandRefCellaren'tSync, because their unsynchronized mutation through&Twould race.Mutex<T>isSync(whenT: Send), because it serializes access.
thread::spawn requires its closure, and everything it captures, to be Send. Try to send an Rc:
error[E0277]: `Rc<Vec<i32>>` cannot be sent between threads safely
|
= help: within `{closure}`, the trait `Send` is not implemented for `Rc<Vec<i32>>`That's the whole trick. A data race needs shared memory plus unsynchronized mutation. Ownership and borrowing prevent unsynchronized mutation through ordinary references, and Send/Sync prevent sharing types whose internal mutation isn't synchronized. Safe Rust programs can't have data races, and you get the check without running anything. The Send and Sync concept summarizes the rules.
| Combination | One thread | Many threads |
|---|---|---|
| Shared, read-only | Rc<T> | Arc<T> |
| Shared, mutable | Rc<RefCell<T>> | Arc<Mutex<T>>, Arc<RwLock<T>> |
| Counter or flag | Cell<u64> | AtomicU64 |
What Rust doesn't prevent
Fearless isn't careless. The compiler rules out data races, not every concurrency bug:
- Deadlocks. Two threads each holding a lock the other needs will wait forever. Use a consistent lock order, hold locks briefly, and never call unknown code while holding one.
- Race conditions in logic. Checking a balance in one critical section and withdrawing in another lets two threads both pass the check. Do check-and-update under a single lock.
- Starvation and contention. One hot
Mutexcan serialize a whole program. Shard the data, use message passing, or redesign around ownership. - Leaked threads. A detached thread that never finishes keeps running until the process exits.
Practice
Build a parallel word counter.
- Given
let texts: Vec<String>(say, 8 paragraphs), count word frequencies across all of them using one thread per text. - Version A: use
thread::scopeso threads borrowtexts. Each thread builds its ownHashMap<&str, usize>and returns it; merge the maps in the main thread. - Version B: use
Arc<Mutex<HashMap<String, usize>>>shared bythread::spawnthreads, with each thread locking per word. - Which version holds the lock less, and why is version A likely faster? Then try version C: an
mpscchannel where each worker sends its local map to the main thread.
Show solution
use std::collections::HashMap;
use std::sync::{Arc, Mutex, mpsc};
use std::thread;
fn count_scoped(texts: &[String]) -> HashMap<&str, usize> {
thread::scope(|s| {
let handles: Vec<_> = texts
.iter()
.map(|t| {
s.spawn(move || {
let mut local = HashMap::new();
for w in t.split_whitespace() {
*local.entry(w).or_insert(0) += 1;
}
local
})
})
.collect();
let mut total = HashMap::new();
for h in handles {
for (w, n) in h.join().unwrap() {
*total.entry(w).or_insert(0) += n;
}
}
total
})
}
fn count_mutex(texts: Vec<String>) -> HashMap<String, usize> {
let shared = Arc::new(Mutex::new(HashMap::new()));
let handles: Vec<_> = texts
.into_iter()
.map(|t| {
let shared = Arc::clone(&shared);
thread::spawn(move || {
for w in t.split_whitespace() {
*shared.lock().unwrap().entry(w.to_string()).or_insert(0) += 1;
}
})
})
.collect();
for h in handles {
h.join().unwrap();
}
Arc::try_unwrap(shared).unwrap().into_inner().unwrap()
}
fn count_channel(texts: Vec<String>) -> HashMap<String, usize> {
let (tx, rx) = mpsc::channel();
for t in texts {
let tx = tx.clone();
thread::spawn(move || {
let mut local = HashMap::new();
for w in t.split_whitespace() {
*local.entry(w.to_string()).or_insert(0) += 1;
}
tx.send(local).unwrap();
});
}
drop(tx);
let mut total = HashMap::new();
for local in rx {
for (w, n) in local {
*total.entry(w).or_insert(0) += n;
}
}
total
}
fn main() {
let texts: Vec<String> = vec!["a b a".into(), "b c".into(), "a".into()];
println!("{:?}", count_scoped(&texts).get("a")); // Some(3)
println!("{:?}", count_mutex(texts.clone()).get("a")); // Some(3)
println!("{:?}", count_channel(texts).get("b")); // Some(2)
}Version A never locks: each thread works on private data and the merge happens once at the end, and it borrows &str keys instead of allocating Strings. Version B takes the lock once per word, so threads spend much of their time waiting on each other, and the work becomes nearly serial. Version C has A's structure but works with owned data and 'static threads. "Do the work locally, combine at the end" (map-reduce) usually beats fine-grained locking.
Arc::try_unwrap succeeds once every other Arc is dropped (all threads joined), giving back the Mutex, and into_inner takes the map out of it.
Key takeaways
thread::spawnneeds a'static,Sendclosure: move owned data in.thread::scopelets threads borrow locals because they're joined before it returns.- Channels move values between threads; drop unused senders so receivers know when to stop.
Arc<Mutex<T>>shares mutable state; the mutex owns the data, the guard unlocks on drop, and you can't forget either step.SendandSyncare inferred marker traits that keep non-thread-safe types (Rc,RefCell) on one thread, which makes data races a compile error.- Rust doesn't prevent deadlocks or logic races; keep critical sections short and prefer local work plus a final merge.
Check yourself
Quiz
0/4 correct · 4 left
1.Why can threads spawned inside std::thread::scope borrow local variables, while thread::spawn threads can't?
2.Why does sending an Rc<T> into thread::spawn fail to compile?
3.A loop over rx (an mpsc receiver) never ends, even though all worker threads have finished. What's the most likely cause?
4.Which bug can still happen in safe Rust?
Concepts in this lesson
One-page notes on each idea, for review or a quick lookup later.
- Data racesTwo goroutines, one variable, at least one write and no synchronization — and how the race detector finds it.
- The borrow checkerMany shared references or one mutable reference, never both — the compile-time rule behind Rust’s memory and thread safety.
- LifetimesAnnotations like 'a describe how long references stay valid and how outputs relate to inputs; they never extend anything’s life.
- Interior mutabilityMutate through a shared reference with Cell, RefCell, Mutex or atomics — the aliasing rule still holds, checked at runtime.
- Send and SyncTwo auto traits that say which types may move to or be shared with other threads — why safe Rust has no data races.
Finished reading?
Mark it complete to fill in your roadmap.