Ownership Across Threads
This chapter is where the bill from the first four chapters gets paid.
Everything so far has been about a single thread: who owns a value, who may read it, who may change it, how long it stays valid. The discipline was justified by memory safety, and memory safety alone would have been a reasonable return. But the rule chapter 3 stated — many readers or one writer, never both — is the same rule that makes concurrent access safe, and because the compiler has already been enforcing it everywhere, it gets thread safety without a second mechanism.
A data race requires three things at once: two or more threads touching the same
memory, at least one of them writing, and no synchronization between them. Rust
forbids the combination by construction. If the data is shared, you have a &T
and cannot write. If you can write, you have a &mut T and nobody else has
anything. There is no third case, so there is no race.
That result has a name in the community — fearless concurrency — and the feeling it describes is real. Writing threaded code in Rust does not carry the low-grade dread that writing it in Java or C++ does, because the class of bug that makes threading frightening cannot compile.
Two marker traits
The compiler needs to know which types are safe to move between threads and which are safe to share, and it tracks that with two traits that have no methods.
Send means a value of this type can be moved to another thread. Almost
everything is Send. i32, String, Vec<T> when T: Send, and any struct
whose fields are all Send.
Sync means &T is Send — that a reference to this type can be shared
with another thread, which is to say that concurrent reads are safe. Again most
things qualify, and a type is Sync when its fields are.
Both are derived automatically by the compiler; you never implement them, except
in unsafe code wrapping something the compiler cannot see into.
What matters is the exceptions, because they are the cases where the compiler stops you.
Rc<T> is neither Send nor Sync. Its reference count is a plain integer
incremented without synchronization, so two threads cloning the same Rc could
lose an increment and free the value while it is still in use. The threaded
equivalent, Arc<T>, uses atomic operations and is Send and Sync, and pays a
small cost for it. That cost is why both exist.
RefCell<T> is Send but not Sync. Moving one to another thread is fine;
sharing it is not, because its borrow counter has the same problem. The threaded
equivalent is Mutex<T> or RwLock<T>.
Raw pointers are neither, so unsafe code that shares them is
something you have to vouch for explicitly.
The payoff is that these two traits turn a category of design error into a type error:
use std::rc::Rc;
use std::thread;
fn main() {
let shared = Rc::new(vec![1, 2, 3]);
let handle = thread::spawn(move || {
println!("{:?}", shared);
});
handle.join().unwrap();
}
The compiler rejects it with Rc<Vec<i32>> cannot be sent between threads safely. No documentation to read, no convention to remember, no race to
reproduce at three in the morning.
Spawning, and why 'static appears
use std::thread;
fn main() {
let mut handles = Vec::new();
for id in 0..4 {
handles.push(thread::spawn(move || {
let total: u64 = (1..=1000u64).map(|n| n * id).sum();
(id, total)
}));
}
for handle in handles {
let (id, total) = handle.join().unwrap();
println!("thread {id} computed {total}");
}
}
The move keyword forces the closure to take ownership of what it captures
rather than borrowing. That is required, and the reason is in the signature:
thread::spawn demands F: Send + 'static.
Chapter 4 explained what that 'static bound means — the closure may hold no
reference with a lifetime shorter than the program. It is not a claim that the
thread runs forever. It is the compiler pointing out that spawn returns
immediately, main might return while the new thread is still running, and any
borrow of a local in main would then dangle.
When you want a thread to borrow local data, the answer is a scoped thread, which guarantees the threads finish before the scope ends:
use std::thread;
fn main() {
let readings = vec![12, 45, 7, 88, 23];
let mut maximum = 0;
thread::scope(|scope| {
let handle = scope.spawn(|| readings.iter().sum::<i32>());
maximum = *readings.iter().max().unwrap();
println!("sum {}", handle.join().unwrap());
});
println!("max {maximum}, readings still here: {readings:?}");
}
The closure borrows readings without cloning or wrapping it, main reads it at
the same time — both are shared borrows, so that is allowed — and readings is
usable afterward. thread::scope does not return until every thread spawned
inside has finished, and that is what makes the borrow sound.
Channels move ownership
The mechanism that fits Rust’s model most naturally is not shared memory at all. A channel moves values from one thread to another, and because a move transfers ownership, the sending thread cannot touch what it sent:
use std::sync::mpsc;
use std::thread;
fn main() {
let (sender, receiver) = mpsc::channel();
for worker in 0..3 {
let sender = sender.clone();
thread::spawn(move || {
for job in 0..3 {
sender.send(format!("worker {worker} finished job {job}")).unwrap();
}
});
}
drop(sender);
let mut messages: Vec<String> = receiver.iter().collect();
messages.sort();
for message in messages {
println!("{message}");
}
}
Two details make this work. Each worker gets its own clone of the sender, and the
original is dropped in main; the receiver’s iterator ends when every sender has
been dropped, so forgetting that drop hangs the program. And the String sent
is gone from the worker’s side — there is no shared buffer to guard, because
ownership moved with the value.
This is Rust’s version of the advice to communicate by sharing memory only when you must. The difference from Go, where the same advice is a slogan, is that here the compiler enforces which things you have handed over.
Mutex owns its data
When threads really must share mutable state, the standard library’s mutex has a shape worth studying, because it is different from every mutex you have used before.
In Java, C and Go, a lock and the data it protects are separate. The association between them lives in a comment or a naming convention, and nothing stops code from touching the data without taking the lock. Every codebase with threads has at least one place where somebody did.
In Rust, the mutex contains the data, and the only way to reach the data is to lock:
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let tally = Arc::new(Mutex::new(0u64));
let mut handles = Vec::new();
for _ in 0..8 {
let tally = Arc::clone(&tally);
handles.push(thread::spawn(move || {
for _ in 0..1000 {
let mut guard = tally.lock().unwrap();
*guard += 1;
}
}));
}
for handle in handles {
handle.join().unwrap();
}
println!("{}", tally.lock().unwrap());
}
The program prints 8000, every time. lock() returns a guard that dereferences
to the data, and the guard releases the lock when it is dropped — which happens
at the end of the iteration here, automatically, on every path including a panic.
There is no unlock to forget and no finally block to write.
Arc<Mutex<T>> is the threaded counterpart of Rc<RefCell<T>> from chapter 12,
and the same advice applies: it is correct when the design genuinely needs shared
mutable state, and it is a code smell when it is the first thing you reach for.
Channels and scoped threads cover more cases than people expect.
Two details. lock() returns a Result because a mutex becomes poisoned if a
thread panics while holding it — the data may be in a half-updated state, and the
Err is a warning that it might be inconsistent. Most code calls unwrap on it,
which propagates the panic, and that is usually right.
And RwLock<T> is the variant that permits many readers or one writer — the
same rule the borrow checker applies, moved to runtime. Use it when reads
dominate heavily; for anything close to balanced, a Mutex is faster because it
is simpler.
What Rust does not prevent
The guarantee is precise and it is worth knowing its edges, because “fearless concurrency” gets read as more than it claims.
Deadlock is still possible. Two threads taking two locks in opposite orders
will deadlock in Rust as they would in any other language. The type system tracks
who may access what, not the order in which locks are acquired. The usual
disciplines — a global lock ordering, holding one lock at a time, using
try_lock with a timeout — all still apply.
Race conditions in the logical sense are still possible. A program that reads a balance, computes a new one, and writes it back in three separate locked operations has a bug that no amount of locking each step fixes. Rust prevents data races, which is a memory-level property. It does not know what your invariants are.
Livelock, starvation and priority inversion are unaffected.
Leaks are still possible, including through reference cycles, as chapter 12 described.
What you get is that the failures above are the ones that remain, and they are the ones that can be reasoned about. The failure that cannot be reasoned about — memory being read while it is written, producing a value that was never stored — is gone.
Data parallelism
For the common case where the work is a loop over a collection, threads are more
machinery than the problem needs. The rayon crate turns a sequential iterator
chain into a parallel one by changing the first method call:
use rayon::prelude::*;
let total: u64 = readings.par_iter().map(|r| expensive(r)).sum();
par_iter splits the work across a thread pool and joins the results. It is safe
for the same reason everything else in this chapter is: the closure gets &T,
Sync guarantees that sharing is sound, and the compiler checks it. For
CPU-bound work over a collection this is usually the right answer, and it is one
line.
A word on async
Asynchronous Rust is a large subject and this book is not the place for it, but the connection to ownership is worth drawing because it explains most of what people find difficult about it.
An async fn compiles into a state machine. Everything alive across an .await
point has to be stored in that state machine, which is why holding a MutexGuard
across an await is a problem — the guard is now part of a value that may be moved
and may live for an unknown time. Spawning a task on a multi-threaded executor
requires the future to be Send, for the same reason thread::spawn does, and
usually 'static, because the executor decides when it runs.
So the questions async asks are the questions this book has been asking throughout: what owns this, how long does it live, can it move to another thread. The syntax is new; the model is not. Tokio’s documentation is the place to go next, and it will make more sense with these four chapters behind you.
Conclusion
Data race freedom is not a separate feature in Rust. It is the borrowing rule,
applied across threads, enforced by two marker traits that the compiler derives
and checks. Send says a value can move between threads; Sync says it can be
shared. The types that are neither — Rc, RefCell, raw pointers — are exactly
the ones whose internal bookkeeping is unsynchronized.
Three shapes cover most threaded code. Channels move ownership between threads
and need no locks. Scoped threads borrow local data with a compiler-checked
guarantee that they finish first. Arc<Mutex<T>> shares mutable state, with the
lock owning the data so there is no way to reach it unlocked.
What remains unprevented is deadlock, logical races, and leaks. Those are real and they are tractable, unlike the class of bug that is now impossible.
The next chapter zooms out from values and types to the unit a Rust programmer actually ships: the crate, its modules, and the public surface you choose to expose.
Questions to think through
Rc is not Send because its count is not atomic. Describe the interleaving of
two threads that would lose an increment, and say what the program does
afterward.
A mutex that owns its data cannot be bypassed. Find a place in code you know where a lock and the data it protects are separate. What currently stops someone from reading the data without the lock, and how would you know if they had?
The chapter says Rust does not prevent logical race conditions. Write down an operation from your own system that reads, computes and writes, and say what would have to be true for it to be safe under concurrency.