The Book·Chapter 16·10 min

Fearless concurrency

Threads, channels, Send and Sync, and the rules that keep concurrent Rust honest

"Fearless concurrency" is the marketing phrase. The reality is more modest. Rust's type system catches most data races at compile time. It does not catch deadlocks, logic bugs, or starvation. What you get is a strong baseline: if it compiles, no two threads are mutating the same memory through & references.

Spawning threads

use std::thread;
 
let handle = thread::spawn(|| {
    println!("hello from a thread");
});
 
handle.join().unwrap();

thread::spawn takes a closure and runs it on a new OS thread. The returned JoinHandle<T> lets you wait for the thread and recover its return value.

The closure must be 'static and Send. That is why you usually see move:

let data = vec![1, 2, 3];
let handle = thread::spawn(move || {
    println!("{:?}", data);
});
handle.join().unwrap();

Without move, the closure would try to borrow data from the calling stack. Since the thread might outlive the borrow, the compiler refuses. move transfers ownership instead.

Sharing state: Arc<Mutex<T>>

The previous chapter introduced Arc and Mutex. Together they are the canonical "shared mutable state" primitive:

use std::sync::{Arc, Mutex};
use std::thread;
 
let counter = Arc::new(Mutex::new(0u64));
let mut handles = vec![];
 
for _ in 0..10 {
    let c = Arc::clone(&counter);
    handles.push(thread::spawn(move || {
        let mut g = c.lock().unwrap();
        *g += 1;
    }));
}
 
for h in handles { h.join().unwrap(); }
println!("final: {}", counter.lock().unwrap());

Same caveat as before: when an atomic or a channel will do, use that. Arc<Mutex<T>> is the heavy hammer, not the default.

Send and Sync

Two marker traits encode "thread safety" in the type system:

TraitMeaning
SendSafe to transfer ownership to another thread
SyncSafe to share &T across threads

thread::spawn requires its closure to be Send. Capturing an Rc<T> makes the closure not-Send, which is why you see Arc everywhere in multi-threaded code.

You almost never implement these traits yourself. They are auto-derived by the compiler when all fields satisfy them. The unsafe escape hatch exists for raw FFI, but for application code you should never touch it.

Send violations look like:

error[E0277]: `Rc<i32>` cannot be sent between threads safely

The fix is almost always swapping the type (Rc to Arc, RefCell to Mutex), not adding unsafe impl Send.

Channels: ownership transfer between threads

use std::sync::mpsc;
use std::thread;
 
let (tx, rx) = mpsc::channel::<String>();
 
thread::spawn(move || {
    tx.send(String::from("hello")).unwrap();
});
 
let msg = rx.recv().unwrap();
println!("{}", msg);

mpsc stands for "multi-producer, single-consumer." tx can be cloned; rx cannot. Senders moved into threads, receiver stays at the orchestration point.

The mental model: a channel is an ownership-transfer queue. When you send(value), the value moves into the channel; when you recv(), it moves out. No shared mutable state needed.

For most "worker pool" or "producer/consumer" shapes, channels are the right primitive. They are cheaper to reason about than shared state.

A brief detour: async/await

Threads model concurrency at the OS level. Async/await models concurrency at the task level: thousands of cheap tasks multiplexed onto a small thread pool. The runtime (Tokio, in practice) handles the scheduling.

#[tokio::main]
async fn main() {
    let h = tokio::spawn(async {
        do_work().await
    });
    h.await.unwrap();
}

When to pick which:

Use threads whenUse async when
CPU-bound workI/O-bound work
Small number of long-running workersThousands of concurrent connections
Code is mostly synchronousCode already uses async libraries

For the deep dive, see Concepts: Async. The chapter you are reading is about std::thread.

Data parallelism with rayon

For "do the same thing to every element of a collection, in parallel," reach for rayon:

use rayon::prelude::*;
 
let nums: Vec<i32> = (1..=1_000_000).collect();
let sum: i32 = nums.par_iter().sum();

.par_iter() swaps the sequential iterator for a parallel one. Rayon manages a thread pool, splits the work, and joins the results. For embarrassingly parallel work over Vec, slice, HashMap, this is two lines and you get all the cores.

The classic async footgun: blocking lock across await

This bug shows up constantly in agent-written async code:

△ Bad
use std::sync::Mutex;
use std::sync::Arc;

async fn handle(state: Arc<Mutex<State>>) {
  let mut g = state.lock().unwrap();
  g.update();
  fetch_external().await;  // lock held across await
  g.commit();
}
◇ Good
use tokio::sync::Mutex;
use std::sync::Arc;

async fn handle(state: Arc<Mutex<State>>) {
  {
      let mut g = state.lock().await;
      g.update();
  }
  fetch_external().await;
  {
      let mut g = state.lock().await;
      g.commit();
  }
}

Two issues with the bad version. First, std::sync::Mutex blocks the OS thread; held across .await, it can deadlock the entire runtime if the executor schedules another task that needs the same lock. Second, even with tokio::sync::Mutex, holding the guard across an external call is bad practice: it serializes all callers on a slow operation.

The rules:

  • std::sync::Mutex is fine in async code as long as you do not hold the guard across .await. Drop it in a scope.
  • tokio::sync::Mutex is for when you genuinely need to hold a lock across .await. Slower than std::sync::Mutex. Use only when needed.
  • Whenever possible, take the lock, copy what you need, drop the lock, then do async work.