Step 13 of 17

Fearless concurrency

Run work on threads, share data with Arc and Mutex, pass messages over channels, and understand how Send and Sync rule out data races.

18 min 8 sections

In most languages, concurrency bugs are found in production: two threads update the same map, a counter loses increments, a lock is forgotten on one code path. Rust moves that class of bug to compile time. The same ownership and borrowing rules you've learned, plus two marker traits, Send and Sync, make data races impossible in safe code. The Rust community calls it fearless concurrency. This lesson covers OS threads, scoped threads, message passing with channels, shared state with Mutex and atomics, how Send and Sync work, and the bugs Rust still can't catch for you.

Spawning threads

std::thread::spawn starts an OS thread running a closure and returns a JoinHandle. Calling join() waits for the thread to finish and returns its result:

rust
use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..=3 {
            println!("worker: {i}");
            thread::sleep(Duration::from_millis(10));
        }
        42 // the thread's return value
    });

    println!("main keeps going");
    let answer = handle.join().unwrap(); // Err if the thread panicked
    println!("worker returned {answer}");
}

When main returns, the process exits and any threads still running are killed, so join the ones whose work matters.

A spawned thread can outlive the function that started it, so its closure must be 'static: it can't borrow the caller's locals. Use move to transfer ownership into the thread:

rust
use std::thread;

fn main() {
    let data = vec![1, 2, 3];
    let handle = thread::spawn(move || data.iter().sum::<i32>());
    // data has moved; using it here would be a compile error
    println!("{}", handle.join().unwrap());
}

Without move, the compiler reports that the closure may outlive the borrowed value and suggests adding move.

Scoped threads

Often you want threads that work on borrowed data and are guaranteed to finish before the function returns. std::thread::scope (Rust 1.63) gives you exactly that: every thread spawned in the scope is joined automatically at its end, so borrowing locals is safe.

rust
use std::thread;

fn main() {
    let numbers: Vec<u64> = (1..=1_000_000).collect();
    let chunks: Vec<&[u64]> = numbers.chunks(250_000).collect();

    let total: u64 = thread::scope(|s| {
        let handles: Vec<_> = chunks
            .iter()
            .map(|chunk| s.spawn(move || chunk.iter().sum::<u64>()))
            .collect();
        handles.into_iter().map(|h| h.join().unwrap()).sum()
    });

    println!("{total}"); // 500000500000
    println!("{}", numbers.len()); // numbers is still ours
}

No Arc, no clones: each thread borrows a slice of numbers, and the compiler knows the borrows end before scope returns. For data-parallel work like this, the rayon crate goes further: change .iter() to .par_iter() and it spreads the work across a thread pool with work stealing.

Message passing with channels

One way to avoid shared state is not to share: threads own their data and send messages to each other. std::sync::mpsc provides multi-producer, single-consumer channels:

rust
use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel::<String>();

    for id in 0..3 {
        let tx = tx.clone(); // each producer gets its own sender
        thread::spawn(move || {
            for job in 0..2 {
                tx.send(format!("worker {id} finished job {job}")).unwrap();
            }
        });
    }
    drop(tx); // drop the original so the channel closes when the workers finish

    for msg in rx { // iterates until every sender is dropped
        println!("{msg}");
    }
}

Sending a value moves it into the channel, so the sending thread can't touch it afterwards. Ownership transfer is enforced by the type system, not by convention. mpsc::sync_channel(n) creates a bounded channel whose send blocks when n messages are queued, which gives you backpressure. For multiple consumers, or select-style waiting on several channels, the crossbeam-channel crate is the standard choice.

Shared state with Mutex

When several threads really do need the same data, wrap it in a Mutex<T> and share the mutex with Arc:

rust
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let hits: Arc<Mutex<HashMap<String, u32>>> = Arc::new(Mutex::new(HashMap::new()));

    let handles: Vec<_> = ["/", "/about", "/"]
        .into_iter()
        .map(|path| {
            let hits = Arc::clone(&hits);
            thread::spawn(move || {
                let mut map = hits.lock().unwrap(); // blocks until the lock is free
                *map.entry(path.to_string()).or_insert(0) += 1;
            }) // the guard drops here, unlocking the mutex
        })
        .collect();

    for h in handles {
        h.join().unwrap();
    }
    println!("{:?}", hits.lock().unwrap().get("/")); // Some(2)
}

Rust's Mutex is different from most languages' in a way that matters: it owns the data it protects. The only way to reach the HashMap is through lock(), which returns a MutexGuard that dereferences to the data and unlocks when dropped. You can't forget to lock, and you can't forget to unlock, even on an early return.

lock() returns a Result because of poisoning: if a thread panics while holding the lock, the data may be half-updated, so later lock() calls return Err. Most code calls .unwrap() and treats a poisoned lock as a bug that should propagate.

Some practical rules:

  • Keep critical sections short. Copy out what you need and drop the guard before slow work (I/O, network calls). Calling drop(guard) or putting the locked section in its own block makes the scope obvious.
  • Use RwLock<T> when reads vastly outnumber writes; many readers can hold it at once.
  • Use atomics (AtomicU64, AtomicBool) for single counters and flags; they're lock-free and much cheaper.
  • Acquire multiple locks in a consistent order everywhere, or you risk deadlock.
rust
use std::sync::atomic::{AtomicU64, Ordering};
use std::thread;

fn main() {
    let requests = AtomicU64::new(0); // scoped threads can borrow it: no Arc needed
    thread::scope(|s| {
        for _ in 0..8 {
            s.spawn(|| {
                for _ in 0..1000 {
                    requests.fetch_add(1, Ordering::Relaxed);
                }
            });
        }
    });
    println!("{}", requests.load(Ordering::Relaxed)); // always 8000
}

Ordering::Relaxed is enough for a standalone counter. Stronger orderings (Acquire, Release, SeqCst) matter when an atomic guards other data; if you're unsure, use SeqCst or a Mutex.

Send and Sync

How does the compiler know Rc can't cross threads but Arc can? Two marker traits, which have no methods and which the compiler implements automatically based on a type's fields:

  • Send: a value of this type can be moved to another thread. Almost everything is Send. Rc<T> isn't, because two threads could update its non-atomic counter at the same time.
  • Sync: a value of this type can be shared between threads by reference, i.e. &T is Send. Cell and RefCell aren't Sync, because their unsynchronized mutation through &T would race. Mutex<T> is Sync (when T: Send), because it serializes access.

thread::spawn requires its closure, and everything it captures, to be Send. Try to send an Rc:

text
error[E0277]: `Rc<Vec<i32>>` cannot be sent between threads safely
   |
   = help: within `{closure}`, the trait `Send` is not implemented for `Rc<Vec<i32>>`

That's the whole trick. A data race needs shared memory plus unsynchronized mutation. Ownership and borrowing prevent unsynchronized mutation through ordinary references, and Send/Sync prevent sharing types whose internal mutation isn't synchronized. Safe Rust programs can't have data races, and you get the check without running anything. The Send and Sync concept summarizes the rules.

CombinationOne threadMany threads
Shared, read-onlyRc<T>Arc<T>
Shared, mutableRc<RefCell<T>>Arc<Mutex<T>>, Arc<RwLock<T>>
Counter or flagCell<u64>AtomicU64

What Rust doesn't prevent

Fearless isn't careless. The compiler rules out data races, not every concurrency bug:

  • Deadlocks. Two threads each holding a lock the other needs will wait forever. Use a consistent lock order, hold locks briefly, and never call unknown code while holding one.
  • Race conditions in logic. Checking a balance in one critical section and withdrawing in another lets two threads both pass the check. Do check-and-update under a single lock.
  • Starvation and contention. One hot Mutex can serialize a whole program. Shard the data, use message passing, or redesign around ownership.
  • Leaked threads. A detached thread that never finishes keeps running until the process exits.

Practice

Build a parallel word counter.

  1. Given let texts: Vec<String> (say, 8 paragraphs), count word frequencies across all of them using one thread per text.
  2. Version A: use thread::scope so threads borrow texts. Each thread builds its own HashMap<&str, usize> and returns it; merge the maps in the main thread.
  3. Version B: use Arc<Mutex<HashMap<String, usize>>> shared by thread::spawn threads, with each thread locking per word.
  4. Which version holds the lock less, and why is version A likely faster? Then try version C: an mpsc channel where each worker sends its local map to the main thread.
Show solution
rust
use std::collections::HashMap;
use std::sync::{Arc, Mutex, mpsc};
use std::thread;

fn count_scoped(texts: &[String]) -> HashMap<&str, usize> {
    thread::scope(|s| {
        let handles: Vec<_> = texts
            .iter()
            .map(|t| {
                s.spawn(move || {
                    let mut local = HashMap::new();
                    for w in t.split_whitespace() {
                        *local.entry(w).or_insert(0) += 1;
                    }
                    local
                })
            })
            .collect();

        let mut total = HashMap::new();
        for h in handles {
            for (w, n) in h.join().unwrap() {
                *total.entry(w).or_insert(0) += n;
            }
        }
        total
    })
}

fn count_mutex(texts: Vec<String>) -> HashMap<String, usize> {
    let shared = Arc::new(Mutex::new(HashMap::new()));
    let handles: Vec<_> = texts
        .into_iter()
        .map(|t| {
            let shared = Arc::clone(&shared);
            thread::spawn(move || {
                for w in t.split_whitespace() {
                    *shared.lock().unwrap().entry(w.to_string()).or_insert(0) += 1;
                }
            })
        })
        .collect();
    for h in handles {
        h.join().unwrap();
    }
    Arc::try_unwrap(shared).unwrap().into_inner().unwrap()
}

fn count_channel(texts: Vec<String>) -> HashMap<String, usize> {
    let (tx, rx) = mpsc::channel();
    for t in texts {
        let tx = tx.clone();
        thread::spawn(move || {
            let mut local = HashMap::new();
            for w in t.split_whitespace() {
                *local.entry(w.to_string()).or_insert(0) += 1;
            }
            tx.send(local).unwrap();
        });
    }
    drop(tx);
    let mut total = HashMap::new();
    for local in rx {
        for (w, n) in local {
            *total.entry(w).or_insert(0) += n;
        }
    }
    total
}

fn main() {
    let texts: Vec<String> = vec!["a b a".into(), "b c".into(), "a".into()];
    println!("{:?}", count_scoped(&texts).get("a"));       // Some(3)
    println!("{:?}", count_mutex(texts.clone()).get("a"));  // Some(3)
    println!("{:?}", count_channel(texts).get("b"));        // Some(2)
}

Version A never locks: each thread works on private data and the merge happens once at the end, and it borrows &str keys instead of allocating Strings. Version B takes the lock once per word, so threads spend much of their time waiting on each other, and the work becomes nearly serial. Version C has A's structure but works with owned data and 'static threads. "Do the work locally, combine at the end" (map-reduce) usually beats fine-grained locking.

Arc::try_unwrap succeeds once every other Arc is dropped (all threads joined), giving back the Mutex, and into_inner takes the map out of it.

Key takeaways

  • thread::spawn needs a 'static, Send closure: move owned data in. thread::scope lets threads borrow locals because they're joined before it returns.
  • Channels move values between threads; drop unused senders so receivers know when to stop.
  • Arc<Mutex<T>> shares mutable state; the mutex owns the data, the guard unlocks on drop, and you can't forget either step.
  • Send and Sync are inferred marker traits that keep non-thread-safe types (Rc, RefCell) on one thread, which makes data races a compile error.
  • Rust doesn't prevent deadlocks or logic races; keep critical sections short and prefer local work plus a final merge.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.Why can threads spawned inside std::thread::scope borrow local variables, while thread::spawn threads can't?

  2. 2.Why does sending an Rc<T> into thread::spawn fail to compile?

  3. 3.A loop over rx (an mpsc receiver) never ends, even though all worker threads have finished. What's the most likely cause?

  4. 4.Which bug can still happen in safe Rust?

Concepts in this lesson

One-page notes on each idea, for review or a quick lookup later.

Finished reading?

Mark it complete to fill in your roadmap.