Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.