Skip to content

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.

🦀 main.rs

Two key things to notice:

  • spawn returns a JoinHandle. Calling .join() blocks until the thread finishes and returns whatever the closure returned (wrapped in a Result, 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, use move:
🦀 main.rs

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.)

🦀 main.rs

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.

🦀 main.rs

(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.

🦀 main.rs

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. &T is Send).

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 Send is not implemented for Rc<…>

…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 fn returns a Future — a value that represents work to be done.
  • An executor (tokio, async-std, smol, embassy) drives futures to completion.
  • .await yields 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:

🦀 main.rs

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::spawn runs a closure on a fresh OS thread. JoinHandle::join blocks 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. RwLock if reads dominate.
  • Send and Sync are 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.