Part 2 made a promise: the readers-XOR-writer borrow rule — the very same rule — would make data races compile errors. Time to cash that check. Recall the definition of a data race: two threads accessing the same memory concurrently, at least one writing, with no synchronization. Now reread Part 2’s rule: at any moment, any number of readers XOR exactly one writer. Those two sentences describe the same forbidden zone — the borrow checker doesn’t know what a thread is, and it doesn’t need to. In other languages, concurrency bugs are a matter of luck: tests pass, production explodes. In Rust they’re a build problem: it doesn’t compile, so “production” never happens. That’s the entire meaning of “fearless concurrency.”
The compile-time ban on data races
Start with two pieces of code that “obviously must fail” — which in other languages not only compile but often “mostly work”:
let mut v = vec![1, 2, 3];
let _h1 = thread::spawn(move || v.push(4));
let _h2 = thread::spawn(move || v.push(5));
// error[E0382]: use of moved value: `v`
A move closure takes ownership of v — and there’s only one ownership to take; the second closure gets air. The screenplay of two writers mutating one Vec in parallel is shredded at the ownership layer. Now the sharing case:
let shared = Rc::new(vec![1, 2, 3]);
thread::spawn(move || println!("{shared:?}"));
// error[E0277]: `Rc<Vec<i32>>` cannot be sent between threads safely
Part 6 told you Rc’s count isn’t atomic — two threads cloning simultaneously would corrupt it, then double-free. The fuse of that disaster is cut at compile time by a trait called Send.
Send and Sync are the two marker traits that explain the whole story — no methods, just “property tags” on types:
Send: ownership of this type may safely move to another thread.Sync: shared references to this type may safely be used from many threads at once.
Two key design decisions. First, they’re auto traits — the compiler derives them for almost everything automatically (String, Vec, your structs, as long as every field qualifies); you write nothing. Second, they translate Part 6’s price list into type-level facts: Rc is neither (cross-thread use is E0277), RefCell is Send but not Sync (mutable in one thread, unshareable), and their cross-thread relative Arc<Mutex<T>> is both. Implementing either trait by hand is an unsafe operation — because then you are making the safety proof the compiler can’t, and that’s a topic for the unsafe part.
Scoped threads: the legal way to borrow stack data
thread::spawn demands a 'static closure — Part 3’s two hats meet again: not “immortal data,” but “data that borrows nothing.” Yet this requirement catches an innocent, extremely natural pattern: split a big array, hand each segment to a thread, let the main thread wait and continue. The data plainly outlives the threads; the signature just can’t say so.
thread::scope (stable since 1.63) makes it provable: the scope guarantees it joins every spawned thread before exiting, so borrowing stack data is perfectly legal — the threads are proven to die before the data does.
let mut data = vec![1, 2, 3, 4];
let (left, right) = data.split_at_mut(2);
thread::scope(|s| {
s.spawn(|| left.iter_mut().for_each(|x| *x *= 10));
s.spawn(|| right.iter_mut().for_each(|x| *x += 100));
}); // both threads join here; the borrows end
data.push(5);
println!("{data:?}"); // [10, 20, 103, 104, 5]
Notice the echo of the whole series hidden in this snippet: two threads each holding an &mut, yet no data race — because split_at_mut proved to the borrow checker that the slices don’t overlap. Part 2’s rules, Part 3’s lifetimes, Part 7’s “who chooses the type” — all working verbatim in a concurrent context. No Arc, no 'static, no heap allocation — this is Rust concurrency at its sharpest: the compiler knows when the threads die, so it knows how long the borrows may live.
Arc<Mutex<T>>: the multi-threaded answer to shared mutable state
When data must outlive the scope — server state, cross-request caches — scoped threads can’t help; you need the multi-threaded edition of Part 6’s classic combo. The correspondence is table-exact:
| Single-threaded (Part 6) | Multi-threaded (this part) | Duty |
|---|---|---|
Rc<T> |
Arc<T> |
shared ownership (counting) |
RefCell<T> |
Mutex<T> |
mutability (runtime mutual exclusion) |
Rc<RefCell<T>> |
Arc<Mutex<T>> |
shared and mutable |
use std::sync::{Arc, Mutex};
use std::thread;
let counter = Arc::new(Mutex::new(0u32));
let mut handles = vec![];
for _ in 0..4 {
let c = Arc::clone(&counter);
handles.push(thread::spawn(move || {
for _ in 0..1000 {
*c.lock().unwrap() += 1;
}
}));
}
for h in handles { h.join().unwrap(); }
println!("count = {}", *counter.lock().unwrap()); // 4000
Four threads, four thousand increments, result exactly 4000 — every increment under the lock’s protection. Two old friends from Part 5 hide in the details: the guard returned by lock() (MutexGuard) implements DerefMut, so *guard += 1 operates on the data directly; it also implements Drop, so leaving the scope releases the lock — “forgot to unlock” is not a bug that exists in Rust; RAII turns the most common concurrency accident into an impossibility. That’s where Mutex differs most from RefCell, and also where it rhymes: the check still happens at runtime, but a RefCell violation is a panic while a Mutex “violation” queues up and waits — mutual exclusion is the rule.
Two disciplines: keep the locked region minimal (doing I/O while holding a lock is the fastest way to turn a highway into a one-lane road); and when multiple locks exist, fix one acquisition order project-wide (deadlock is not something the compiler can prove away — the first rule in this series where it can’t help, said honestly).
Channels: share memory by communicating
The other road, the one the standard library supports directly: don’t share memory — pass messages. Go shouts this mantra the loudest, but in Rust it’s literally true — send transfers ownership:
use std::sync::mpsc;
use std::thread;
let (tx, rx) = mpsc::channel();
let tx2 = tx.clone(); // second producer
thread::spawn(move || {
for i in 0..3 { tx.send(format!("job {i}")).unwrap(); }
});
thread::spawn(move || {
tx2.send(String::from("job from second producer")).unwrap();
});
for msg in rx { // iteration ends when the last tx drops
println!("got: {msg}");
}
mpsc is multiple producer, single consumer: the sending half clones freely, the receiving half is one — an asymmetry that rhymes with Part 2’s readers-writer rule. After sending, the value vanishes from the sender’s world (ownership transferred), so “keep mutating the object after sending it” is unwritable; and the receiver’s for msg in rx terminates naturally once every sender has dropped — graceful shutdown is the default, no poison-pill convention needed. Selection in one sentence: pass work between threads with channels, share state with Arc<Mutex<T>>; when unsure, try the channel first — it draws the data flow in the code, where shared state hides it in timing.
Practice, then Part 10
- Reproduce both negative cases: the double-
moveE0382 and the sent-RcE0277. Read both errors in full — they are the two load-bearing walls of this part, and hitting them once yourself beats reading them ten times. - Use scoped threads to sum the segments of a large vector in parallel, with the main thread combining the results. Constraint: no
Arc, no heap-shared state — onlysplit_at_mut(orchunks_mut) and the scope. - Turn the counter demo into “four producers + one consumer”: producers send increments over a channel, the consumer accumulates and prints the final value. Then answer in a comment: in this version versus the
Arc<Mutex<T>>version, which one makes the fact “where the data lives” easier to see — and which hides it?
Part 10 is the second half of concurrency: async. What async/.await actually compiles into, why a Future “does nothing until polled,” which bottleneck of the thread model an async runtime (the tokio family) solves, and the selection rule that matters most — threads for CPU-bound work, async for I/O-bound work, with both coexisting peacefully inside one type system.