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

Rust roadmap · Systems & production · 18 min read · https://levelcode.org/roadmaps/rust/concurrency

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.

> [!TIP]
> Forgetting to drop the original `tx` is the classic channel bug: the receiving loop waits forever because one sender is still alive. Drop it explicitly after cloning it into the workers.

## 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](https://levelcode.org/concepts/data-races) 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](https://levelcode.org/concepts/send-and-sync) concept summarizes the rules.

| Combination | One thread | Many threads |
| --- | --- | --- |
| Shared, read-only | `Rc<T>` | `Arc<T>` |
| Shared, mutable | `Rc<RefCell<T>>` | `Arc<Mutex<T>>`, `Arc<RwLock<T>>` |
| Counter or flag | `Cell<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.

**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 `String`s. 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.

## Quiz

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

   A. Scoped threads run on the same OS thread
   B. The scope joins every thread before it returns, so the borrows can't outlive the data
   C. Scoped threads copy all locals automatically
   D. thread::spawn threads are not Send

   **Answer: B.** thread::spawn threads may outlive the caller, so they require 'static closures. A scope guarantees all its threads finish before it ends, so the compiler can allow ordinary borrows.

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

   A. Rc<T> is not Send: its reference count isn't updated atomically
   B. Rc<T> is not Clone
   C. Threads can only receive Copy types
   D. Rc<T> is always 'static

   **Answer: A.** Two threads cloning or dropping the same Rc would race on its plain counter. The compiler infers Rc is !Send and rejects it; Arc uses atomic counts and is Send + Sync when T is.

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

   A. Workers must call rx.close()
   B. Channels need a capacity
   C. The original tx sender in the main thread was never dropped
   D. The receiver needs to be wrapped in a Mutex

   **Answer: C.** The receiver ends iteration only when every sender is dropped. Cloning tx for each worker leaves the original alive unless you drop it explicitly.

4. Which bug can still happen in safe Rust?

   A. Two threads writing the same HashMap without a lock
   B. Reading freed memory after another thread drops a value
   C. Tearing a u64 write across two threads with no synchronization
   D. Two threads deadlocking by taking two Mutexes in opposite orders

   **Answer: D.** The type system rules out data races and use-after-free, but not deadlocks. Acquire locks in a consistent order and hold them briefly.
