Fearless Concurrency
Fearless what now?
“Fearless concurrency” is Rust’s marketing phrase, and I’d roll my eyes at it except it’s mostly true. Here’s the trick: the same ownership rules that keep your single-threaded code memory-safe — exactly one mutable reference, or many immutable ones — also prevent data races across threads. The same compiler error that stops two pointers from racing in main thread land stops two threads from racing on shared data. You get to wear the same paranoid hat the whole time.
In return, you get something almost no other systems language has: if your
program compiles and you didn’t use unsafe, you almost certainly don’t
have a data race. Almost. (Logic bugs and deadlocks are still on you.)
Spawning a thread
The basic primitive lives at std::thread::spawn. It takes a closure and
runs it on a new OS thread.
Two key things to notice:
spawnreturns aJoinHandle. Calling.join()blocks until the thread finishes and returns whatever the closure returned (wrapped in aResult, in case the thread panicked).- The closure has to be
'static— it can’t capture stack references from the parent, because the parent might exit before the thread does. If you need to move data in, usemove:
move says “take ownership of every variable I capture.” The vector is now
owned by the closure, which is owned by the new thread. Clean.
Channels — sending messages
The most pleasant way to coordinate threads is through a channel. Don’t share memory by communicating; communicate by sharing memory. (Or however that Go slogan goes.)
mpsc stands for multiple producer, single consumer. You can clone the
sender with tx.clone() to give it out to lots of threads; they’ll all funnel
messages into the same receiver.
(That drop(tx) is essential — if any sender clone is still alive, the
receiver will block waiting for it forever.)
Shared state — Mutex and Arc
Sometimes you really do need shared mutable state — a counter that multiple
threads bump, a cache that any thread can read or write. For that, you reach
for Mutex<T>.
A Mutex is “mutual exclusion.” It owns a value. To access the value, you
have to call .lock(), which blocks until you have exclusive access, then
hands you a guard. When the guard goes out of scope, the lock is released.
Why Arc<Mutex<T>> and not just Mutex<T>? Because each thread needs an
owned handle. Arc is the multi-threaded counterpart to Rc from the smart
pointers chapter — atomic refcount that’s safe to share across threads.
The Mutex is the multi-threaded counterpart to RefCell: it gives you
interior mutability, but enforced with a lock instead of a runtime borrow
check.
RwLock<T> is Mutex’s lazier cousin: many readers or one writer. Use it
when reads massively outnumber writes.
Send and Sync — the marker traits
Two traits in std::marker make the whole show work:
Send— “a value of this type can be moved between threads.”Sync— “a value of this type can be shared between threads” (i.e.&TisSend).
Almost every type is both. The compiler auto-implements them based on what
the type contains. Rc<T> is not Send (the refcount isn’t atomic), which
is why you can’t send an Rc between threads. Arc<T> is Send. Mutex<T>
is Send + Sync (provided T: Send).
You never write Send or Sync by hand. You only ever see them in error
messages, which is the compiler explaining why it just refused to let you
do something dangerous. When you see a message like
the trait
Sendis not implemented forRc<…>
…that’s the compiler saying “you tried to send an Rc to another thread —
that’s exactly the foot-gun this whole system is designed to prevent.”
Brief detour — async, very briefly
This chapter is about threads, but I’d be a bad host if I didn’t at least
nod at async. Rust has an async/await system, separate from threads,
for handling thousands of concurrent I/O-bound operations without spawning
thousands of OS threads. The model:
- An
async fnreturns aFuture— a value that represents work to be done. - An executor (
tokio,async-std,smol,embassy) drives futures to completion. .awaityields control back to the executor until the future is ready.
async fn fetch_two() { let (a, b) = tokio::join!( reqwest::get("https://example.com/a"), reqwest::get("https://example.com/b"), ); println!("got two pages");}We won’t dive in here — async is a chapter (a book, honestly) by itself, and it doesn’t come up unless you’re writing servers or doing heavy I/O.
A worked example — parallel sum
Putting threads, channels, and Arc together to sum a big vector in parallel:
Worth noting how natural that reads — each thread gets a slice (just a
reference into the shared Arc<Vec>), sums its part, returns the partial,
and the main thread folds the partials together. No locks, no shared mutable
state, no data races. The borrow checker did most of the work.
What you take away
thread::spawnruns a closure on a fresh OS thread.JoinHandle::joinblocks for the result.- Channels (
mpsc::channel) are the cleanest way to send data between threads. Senders can be cloned; the receiver is an iterator. Arc<Mutex<T>>is the standard pattern for shared mutable state across threads.RwLockif reads dominate.SendandSyncare compiler-tracked invariants you read about in error messages, not write by hand.- Async exists, is great, and is for another time.
The big idea: every multithreaded bug class that’s a runtime headache in other languages — data races, use-after-free across threads, sharing non-thread-safe types — is a compile error here. You don’t have to remember to be careful. You just have to read the error messages.
Next, and last: macros.