Files
rust-patterns/patterns/concurrency.md
T
rodin-bot a79249bbff fix: correct drifted citations at commit f53b654
- traits.md: iterator.rs anchor L76→L42 (pub const trait Iterator)
- traits.md: deref.rs anchor L60→L139 (pub const trait Deref)
- error-handling.md: fix variant names CliError::Io→IoError, Parse→ParseError
  to match actual stdlib doc example at convert/mod.rs:557
- concurrency.md: mutex.rs and rwlock.rs moved to sync/poison/ subtree
  (3 link updates: mutex×2, rwlock×1)
2026-05-06 17:22:48 -07:00

20 KiB

Rust Concurrency Patterns

Patterns for concurrent programming in Rust, extracted from the standard library source.

Source: rust-lang/rust at commit f53b654

Stats: 783 Atomic type references, 721 Ordering usages, 274 Send/Sync markers, 135 Mutex/RwLock, 125 channel references, 254 OnceLock/LazyLock usages.


1. Send + Sync (Compiler-Enforced Thread Safety)

Source:

library/core/src/marker.rs#L92 (Send), library/core/src/marker.rs#L657 (Sync)

pub unsafe auto trait Send { }
pub unsafe auto trait Sync { }

Why

Send and Sync are auto traits — the compiler implements them automatically when safe. Send means "can be moved to another thread." Sync means "can be shared between threads via &T." These are the foundation of Rust's fearless concurrency: data races are compile-time errors, not runtime bugs.

  • Rc<T> is !Send (not thread-safe reference counting)
  • Arc<T> is Send + Sync (atomic reference counting)
  • Cell<T> is !Sync (interior mutability without synchronization)
  • Mutex<T> is Sync (provides synchronized access)

When to Use

Triggers:

  • When your type contains raw pointers that ARE safe to send (implement unsafe impl Send)
  • When you need to prove thread safety at compile time (bound with T: Send + Sync)
  • When designing concurrent APIs (require Send on closures for spawn)

Example — before:

// Compiles but has a data race (other languages):
let data = Rc::new(RefCell::new(vec![1, 2, 3]));
let data_clone = data.clone();
thread::spawn(move || {
    data_clone.borrow_mut().push(4);  // RACE CONDITION
});
data.borrow_mut().push(5);

Example — in Rust:

// COMPILE ERROR: `Rc<RefCell<Vec<i32>>>` cannot be sent between threads safely
let data = Rc::new(RefCell::new(vec![1, 2, 3]));
thread::spawn(move || {
    data.borrow_mut().push(4);  // ERROR: Rc is !Send
});

// FIX: Use Arc + Mutex for thread-safe shared mutation
let data = Arc::new(Mutex::new(vec![1, 2, 3]));
let data_clone = Arc::clone(&data);
thread::spawn(move || {
    data_clone.lock().unwrap().push(4);  // safe: Mutex provides synchronization
});

When NOT to Use

Don't use this when:

  • Your type is inherently single-threaded (let the auto trait do its job — it will be !Send automatically if it contains !Send fields)
  • You're unsure whether your type is safe to send (DON'T blindly add unsafe impl Send — prove it first)

2. Arc<Mutex> (Shared Mutable State)

Source:

library/std/src/sync/poison/mutex.rs, library/alloc/src/sync.rs

use std::sync::{Arc, Mutex};

let counter = Arc::new(Mutex::new(0));
let counter_clone = Arc::clone(&counter);

thread::spawn(move || {
    let mut num = counter_clone.lock().unwrap();
    *num += 1;
});

Why

Arc provides shared ownership across threads. Mutex provides exclusive access (one thread at a time). Together: multiple threads can share mutable state safely. The lock guard (MutexGuard) drops automatically, releasing the lock — you can't forget to unlock.

When to Use

Triggers:

  • Multiple threads need to read AND write the same data
  • Writes are short-lived (don't hold the lock long)
  • You need strong consistency (every read sees the latest write)

Example — before:

// Single-threaded: RefCell is fine
let state = RefCell::new(HashMap::new());
state.borrow_mut().insert("key", "value");

Example — after (multi-threaded):

use std::sync::{Arc, Mutex};

let state = Arc::new(Mutex::new(HashMap::new()));

// Thread 1:
let state1 = Arc::clone(&state);
thread::spawn(move || {
    state1.lock().unwrap().insert("key", "value");
});

// Thread 2:
let state2 = Arc::clone(&state);
thread::spawn(move || {
    let map = state2.lock().unwrap();
    println!("{:?}", map.get("key"));
});

When NOT to Use

Don't use this when:

  • Only one thread needs access (just own it directly)
  • Reads vastly outnumber writes (use RwLock instead)
  • Lock contention is high (consider lock-free data structures or message passing)
  • The protected data is a simple counter (use AtomicUsize)

Anti-pattern

// DON'T: Hold the lock across await points
let data = Arc::new(Mutex::new(vec![]));
async fn bad(data: Arc<Mutex<Vec<i32>>>) {
    let mut guard = data.lock().unwrap();
    some_async_operation().await;  // DEADLOCK RISK: lock held across await
    guard.push(1);
}

// DO: Lock, extract, drop, then await
async fn good(data: Arc<Mutex<Vec<i32>>>) {
    let value = {
        let guard = data.lock().unwrap();
        guard.last().copied()
    };  // lock dropped here
    some_async_operation().await;  // safe: no lock held
}

3. RwLock for Read-Heavy Workloads

Source:

library/std/src/sync/poison/rwlock.rs

use std::sync::RwLock;

let lock = RwLock::new(5);

// Multiple readers simultaneously:
let r1 = lock.read().unwrap();
let r2 = lock.read().unwrap();
assert_eq!(*r1, 5);
assert_eq!(*r2, 5);
drop((r1, r2));

// Exclusive writer:
let mut w = lock.write().unwrap();
*w += 1;

Why

RwLock allows multiple simultaneous readers OR one exclusive writer. For read-heavy workloads, this dramatically reduces contention compared to Mutex (which blocks all access during any operation).

When to Use

Triggers:

  • Reads vastly outnumber writes (>10:1 ratio)
  • Read operations are not trivially fast (worth avoiding the lock)
  • Multiple threads frequently read simultaneously

When NOT to Use

Don't use this when:

  • Writes are frequent (RwLock has higher overhead than Mutex)
  • You're on a single-threaded runtime
  • The protected operation is so fast that contention doesn't matter

4. Atomic Types for Lock-Free Operations

Source:

library/core/src/sync/atomic.rs

783 Atomic type references, 721 Ordering usages.

use std::sync::atomic::{AtomicUsize, Ordering};

static COUNTER: AtomicUsize = AtomicUsize::new(0);

fn increment() {
    COUNTER.fetch_add(1, Ordering::Relaxed);
}

Why

Atomics provide thread-safe operations WITHOUT locks. They're faster than Mutex for simple values (counters, flags, pointers) because they use CPU hardware instructions directly.

When to Use

Triggers:

  • Simple values: counters, flags, booleans
  • High contention where lock overhead matters
  • Statistics/metrics that don't need precise ordering
  • Implementing lock-free data structures

Example — before:

// Mutex for a simple counter — overkill
let counter = Arc::new(Mutex::new(0usize));
let c = Arc::clone(&counter);
thread::spawn(move || {
    *c.lock().unwrap() += 1;  // lock overhead for one add
});

Example — after:

use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;

let counter = Arc::new(AtomicUsize::new(0));
let c = Arc::clone(&counter);
thread::spawn(move || {
    c.fetch_add(1, Ordering::Relaxed);  // no lock, hardware instruction
});

When NOT to Use

Don't use this when:

  • You need to update multiple values atomically (use Mutex)
  • The operation is complex (compare-and-swap loops get tricky)
  • You don't understand memory ordering (use Mutex instead — wrong Ordering is UB in theory, bugs in practice)

Ordering Guide

// Simple counter — order doesn't matter:
counter.fetch_add(1, Ordering::Relaxed);

// Flag that another thread checks:
flag.store(true, Ordering::Release);      // writer
if flag.load(Ordering::Acquire) { ... }   // reader

// When in doubt (and you can afford it):
value.load(Ordering::SeqCst);  // strongest, safest, slowest

5. Channels (mpsc) for Message Passing

Source:

library/std/src/sync/mpsc.rs

125 channel references in library/.

use std::sync::mpsc;

let (tx, rx) = mpsc::channel();

thread::spawn(move || {
    tx.send("hello").unwrap();
});

let msg = rx.recv().unwrap();

Why

Channels move data between threads without shared state. "Do not communicate by sharing memory; share memory by communicating." The sender and receiver are separate types — the type system ensures correct usage.

When to Use

Triggers:

  • Producer-consumer patterns
  • Work distribution (fan-out)
  • Event notification between threads
  • You want to avoid shared mutable state entirely

Example — before:

// Shared state approach — complex and error-prone
let results = Arc::new(Mutex::new(Vec::new()));
for i in 0..10 {
    let results = Arc::clone(&results);
    thread::spawn(move || {
        let answer = compute(i);
        results.lock().unwrap().push(answer);
    });
}
// How do we know when all threads are done?

Example — after:

let (tx, rx) = mpsc::channel();

for i in 0..10 {
    let tx = tx.clone();
    thread::spawn(move || {
        let answer = compute(i);
        tx.send(answer).unwrap();
    });
}
drop(tx);  // close the sender so rx.iter() terminates

let results: Vec<_> = rx.iter().collect();
// Clean, simple, no shared state

When NOT to Use

Don't use this when:

  • You need multiple consumers (mpsc = multi-producer, SINGLE consumer)
  • You need bidirectional communication (use two channels or shared state)
  • The "message" is just a counter increment (use atomics)

6. OnceLock / LazyLock for One-Time Initialization

Source:

library/std/src/sync/once_lock.rs

254 OnceLock/LazyLock references.

use std::sync::OnceLock;

static CONFIG: OnceLock<Config> = OnceLock::new();

fn get_config() -> &'static Config {
    CONFIG.get_or_init(|| {
        load_config_from_disk()  // runs exactly once, thread-safe
    })
}

Why

OnceLock guarantees initialization happens exactly once, even with concurrent access. Multiple threads calling get_or_init simultaneously will block until the first one finishes, then all get the same value.

When to Use

Triggers:

  • Global/static configuration loaded once at startup
  • Expensive computation cached forever
  • Regex compilation (compile once, use many times)
  • Database connection pools initialized on first use

Example — before:

// Mutex just for initialization — unnecessary lock on every access
static CONFIG: Mutex<Option<Config>> = Mutex::new(None);

fn get_config() -> Config {
    let mut config = CONFIG.lock().unwrap();
    if config.is_none() {
        *config = Some(load_config());
    }
    config.as_ref().unwrap().clone()  // clone on EVERY access!
}

Example — after:

use std::sync::LazyLock;

static CONFIG: LazyLock<Config> = LazyLock::new(|| load_config());

fn get_config() -> &'static Config {
    &CONFIG  // no lock after initialization, just a pointer read
}

When NOT to Use

Don't use this when:

  • The value needs to change after initialization (use Mutex/RwLock)
  • Initialization failure should be retried (OnceLock panics in init leave it permanently empty)
  • You need non-static lifetime (use regular lazy initialization)

7. Scoped Threads (thread::scope)

Source:

library/std/src/thread/scoped.rs

let mut data = vec![1, 2, 3];

thread::scope(|s| {
    s.spawn(|| {
        println!("{data:?}");  // can borrow from parent scope!
    });
    s.spawn(|| {
        println!("{}", data.len());
    });
});
// All threads joined here — data is safe to use again

Why

Regular thread::spawn requires 'static data (because the thread might outlive the caller). thread::scope guarantees all spawned threads finish before the scope ends, so they can BORROW local data. No Arc needed.

When to Use

Triggers:

  • Parallel processing of local data
  • Fork-join parallelism
  • You want multiple threads to borrow the same data without Arc

Example — before:

// thread::spawn requires ownership or 'static — must Arc everything
let data = Arc::new(vec![1, 2, 3, 4, 5]);
let handles: Vec<_> = data.chunks(2).map(|chunk| {
    // Can't do this — chunks borrows data which isn't 'static
    thread::spawn(move || chunk.iter().sum::<i32>())
}).collect();

Example — after:

let data = vec![1, 2, 3, 4, 5];

let sums: Vec<i32> = thread::scope(|s| {
    let handles: Vec<_> = data.chunks(2)
        .map(|chunk| s.spawn(|| chunk.iter().sum::<i32>()))
        .collect();
    handles.into_iter().map(|h| h.join().unwrap()).collect()
});
// No Arc, no clone, just direct borrows

When NOT to Use

Don't use this when:

  • Threads need to outlive the current function (use regular spawn)
  • You're in an async context (use async tasks instead)
  • The work is too small to benefit from parallelism

8. Interior Mutability (Cell/RefCell)

Source:

library/core/src/cell.rs

410 Cell/RefCell usages in library/.

use std::cell::Cell;

struct Counter {
    count: Cell<u32>,
}

impl Counter {
    fn increment(&self) {  // note: &self, not &mut self
        self.count.set(self.count.get() + 1);
    }
}

Why

Sometimes you need mutation through a shared reference (&T). Cell provides this for Copy types (get/set), RefCell for any type (runtime borrow checking). This is !Sync — single-thread only.

When to Use

Triggers:

  • You need mutation through &self (caching, counters, lazy init)
  • Multiple parts of code hold &T but one needs to mutate
  • Single-threaded context only
  • Cell: Value is Copy (integers, booleans)
  • RefCell: Value isn't Copy or you need a reference into it

Example — before:

// Can't cache in &self — need &mut self for mutation
impl Graph {
    fn expensive_traversal(&mut self) -> &[Node] {
        if self.cache.is_none() {
            self.cache = Some(self.compute_traversal());
        }
        self.cache.as_ref().unwrap()
    }
}
// Every caller needs &mut Graph just to read cached data

Example — after:

use std::cell::RefCell;

impl Graph {
    fn expensive_traversal(&self) -> Ref<'_, [Node]> {
        if self.cache.borrow().is_none() {
            *self.cache.borrow_mut() = Some(self.compute_traversal());
        }
        Ref::map(self.cache.borrow(), |c| c.as_ref().unwrap().as_slice())
    }
}
// Callers only need &Graph — mutation is interior

When NOT to Use

Don't use this when:

  • You can restructure to use &mut self (preferred)
  • Multi-threaded (use Mutex/RwLock instead — Cell/RefCell are !Sync)
  • RefCell borrow violations would be common (panic at runtime)

9. thread_local! for Per-Thread State

Source:

library/std/src/thread/local.rs

thread_local! {
    static BUFFER: RefCell<Vec<u8>> = RefCell::new(Vec::new());
}

fn process(data: &[u8]) {
    BUFFER.with(|buf| {
        let mut buf = buf.borrow_mut();
        buf.clear();
        buf.extend_from_slice(data);
        // reuses allocation across calls within same thread
    });
}

Why

Thread-local storage gives each thread its own copy. No synchronization needed because there's no sharing. Useful for per-thread caches, buffers, and RNG state.

When to Use

Triggers:

  • Per-thread caches or buffers (avoid allocation per call)
  • Thread-specific state (random number generators)
  • Avoiding synchronization overhead for thread-independent data

When NOT to Use

Don't use this when:

  • State needs to be shared between threads (use Arc<Mutex>)
  • You're in an async context (tasks migrate between threads — thread-local doesn't follow the task)
  • The state is small and cheap to create per-call

10. Poisoning (Mutex Recovery After Panic)

Source:

library/std/src/sync/poison/mutex.rs

let mutex = Arc::new(Mutex::new(0));

// Thread panics while holding the lock:
let m = Arc::clone(&mutex);
let _ = thread::spawn(move || {
    let _guard = m.lock().unwrap();
    panic!("oops");
}).join();

// The mutex is now "poisoned" — lock() returns Err:
match mutex.lock() {
    Ok(guard) => println!("value: {}", *guard),
    Err(poisoned) => {
        // Can still access the data if you choose:
        let guard = poisoned.into_inner();
        println!("recovered: {}", *guard);
    }
}

Why

If a thread panics while holding a Mutex, the protected data may be in an inconsistent state. Rust "poisons" the mutex: future lock() calls return Err, alerting other threads that something went wrong. You can choose to recover or propagate the panic.

When to Use

Triggers:

  • You need to handle the case where a sibling thread panicked
  • You want to "unwrap" the poisoned lock when you know the data is still valid
  • Robust services that shouldn't crash because one request panicked

When NOT to Use

Don't use this when:

  • A panic in one thread should crash everything (just .unwrap())
  • You're using catch_unwind to prevent panics from poisoning
  • The data is always valid regardless of panic point

Summary: Concurrency Decision Tree

Do you need shared state across threads?
├── NO → Use owned data + message passing (channels)
└── YES → What kind of access?
    ├── Read-only → Arc<T> (no Mutex needed)
    ├── Simple counter/flag → Arc<AtomicUsize> / AtomicBool
    ├── Read-heavy, rare writes → Arc<RwLock<T>>
    └── Frequent reads and writes → Arc<Mutex<T>>

Do threads need to borrow local data?
├── YES → thread::scope (no Arc needed)
└── NO → thread::spawn (requires Send + 'static)

Need one-time initialization?
├── Static, known at compile time → const
├── Static, computed at runtime → LazyLock / OnceLock
└── Per-instance → Option + init-on-first-use

Single-threaded interior mutability?
├── Copy type → Cell<T>
├── Non-Copy → RefCell<T>
└── Multi-threaded → Mutex<T> or RwLock<T>
Pattern Use when
Send + Sync Compiler proves thread safety
Arc<Mutex<T>> Shared mutable state across threads
Arc<RwLock<T>> Read-heavy shared state
AtomicUsize etc. Lock-free counters/flags
mpsc::channel Message passing between threads
OnceLock/LazyLock One-time initialization
thread::scope Parallel work on local data
Cell/RefCell Interior mutability (single-thread)
thread_local! Per-thread state
Poisoning Recovery after panic

See also: