Shared State, Send/Sync & Atomics

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Rust 1.99.0 and the 2024 edition, checked on 2026-10-06. Message passing (see Threads & Message Passing) avoids sharing, but many programs need several threads to read and update the same data — a cache, a counter, a connection registry. Rust allows that through types that synchronise access (Mutex, RwLock, atomics) and two marker traits, Send and Sync, that let the compiler reject any program in which unsynchronised data could be reached from two threads at once.

Arc<Mutex<T>>

A Mutex<T> owns the data it protects; the only way to reach it is lock(), which blocks until the lock is free and returns a MutexGuard<T> that dereferences to T and unlocks when dropped. Wrapping it in Arc lets several threads own the mutex:

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

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

    let handles: Vec<_> = ["alice", "bob", "alice", "carol", "alice"]
        .into_iter()
        .map(|member| {
            let loans = Arc::clone(&loans);
            thread::spawn(move || {
                let mut map = loans.lock().expect("lock poisoned"); // blocks until available
                *map.entry(member.to_string()).or_default() += 1;
            }) // guard dropped here: unlocked
        })
        .collect();
    for h in handles {
        h.join().unwrap();
    }
    let map = loans.lock().unwrap();
    let mut counts: Vec<_> = map.iter().collect();
    counts.sort();
    println!("{counts:?}"); // [("alice", 3), ("bob", 1), ("carol", 1)]
}

Keep critical sections short: copy out what you need and drop the guard (end the block, or call drop(guard)) before doing slow work such as I/O. Never hold a std::sync::MutexGuard across an .await in async code — the future becomes non-Send and the lock may be held while the task is suspended; see Async Runtimes & Tokio for when to use tokio::sync::Mutex instead.

Poisoning

If a thread panics while holding a MutexGuard, the mutex becomes poisoned: later lock() calls return Err(PoisonError), signalling that the data may be in an inconsistent state. PoisonError::into_inner still gives access when you know the data is fine. Most code simply calls .lock().unwrap() (or expect), treating a poisoned lock as a bug that should propagate.

RwLock: many readers or one writer

RwLock<T> allows any number of concurrent readers (read()) or one writer (write()). It pays off when reads are frequent, long and rarely interrupted by writes; for short critical sections a Mutex is often as fast. Since 1.92, RwLockWriteGuard::downgrade turns a write lock into a read lock atomically, without letting another writer in between:

use std::sync::{RwLock, RwLockWriteGuard};

fn main() {
    let catalogue = RwLock::new(vec!["Dune".to_string()]);

    {
        let readers: Vec<_> = (0..3).map(|_| catalogue.read().unwrap()).collect(); // three readers at once
        println!("{} readers see {} books", readers.len(), readers[0].len());
    }

    let mut writer = catalogue.write().unwrap();
    writer.push("Emma".into());
    let reader = RwLockWriteGuard::downgrade(writer); // 1.92: keep reading what we just wrote
    println!("{:?}", *reader);
}

The standard RwLock's fairness policy depends on the operating system; a writer may starve under constant reads on some platforms. parking_lot offers alternative Mutex/RwLock implementations with different trade-offs.

Condvar, Barrier and OnceLock

  • A Condvar lets threads sleep until another thread signals that a condition protected by a Mutex may have changed: wait_while(guard, |state| !ready(state)) re-checks the condition on every wake-up, avoiding missed signals and spurious wake-ups.

  • A Barrier makes n threads wait until all of them reach the same point.

  • OnceLock<T> and LazyLock<T> initialise a value exactly once across threads (see Interior Mutability & Lazy Initialization).

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

fn main() {
    // A bounded "returns" tray: the shelver waits until there are at least three books.
    let tray = Arc::new((Mutex::new(Vec::<&str>::new()), Condvar::new()));

    let shelver = {
        let tray = Arc::clone(&tray);
        thread::spawn(move || {
            let (lock, ready) = &*tray;
            let books = ready.wait_while(lock.lock().unwrap(), |b| b.len() < 3).unwrap();
            format!("shelving {books:?}")
        })
    };

    for title in ["Dune", "Emma", "Kindred"] {
        let (lock, ready) = &*tray;
        lock.lock().unwrap().push(title);
        ready.notify_one();
    }
    println!("{}", shelver.join().unwrap());
}

Send and Sync

Two auto traits encode thread safety:

  • T: Send — a value of type T can be moved to another thread;

  • T: Sync — a &T can be shared between threads (equivalently, &T: Send).

The compiler implements them automatically for a struct or enum when all its fields have them, and the standard library opts specific types out. thread::spawn requires its closure (and result) to be Send + 'static, and Arc<T> is only Send/Sync when T: Send + Sync, so violations are compile errors (E0277):

use std::rc::Rc;
use std::thread;

fn main() {
    let titles = Rc::new(vec!["Dune"]);
    let handle = thread::spawn(move || titles.len()); // error[E0277]: `Rc<Vec<&str>>` cannot be sent between threads safely
    println!("{}", handle.join().unwrap());
}
Table of which common types are Send and Sync. i32, String, Vec and Box, references, Arc, Mutex, RwLock and atomics, mpsc::Sender and JoinHandle are both Send and Sync. MutexGuard is Sync but not Send, because it must be unlocked on the locking thread. Cell and RefCell are Send but not Sync, because their interior mutation is unsynchronised. Rc and rc::Weak are neither, because their reference count is not atomic. Raw pointers are neither; wrapper types opt in. mpsc::Receiver is Send but not Sync.

Rc is neither because two threads cloning or dropping the same Rc would race on its non-atomic counter; Cell and RefCell are Send (one thread at a time may own them) but not Sync (sharing &RefCell would allow unsynchronised mutation). Raw pointers are neither, which is why types built on them — like Vec — must opt back in.

Implementing Send and Sync unsafely

When a type contains raw pointers but its API guarantees thread safety (for example, a wrapper around a C library handle that the library documents as thread-safe), you can assert it with unsafe impl. The compiler cannot check the claim; getting it wrong causes data races, which are undefined behaviour:

struct CHandle {
    ptr: *mut u8, // imagine a handle returned by a C library
}

// SAFETY: the (hypothetical) C library documents that a handle may be moved to and used
// from another thread, as long as only one thread uses it at a time (so not Sync).
unsafe impl Send for CHandle {}

impl CHandle {
    fn is_open(&self) -> bool {
        !self.ptr.is_null()
    }
}

fn main() {
    let h = CHandle { ptr: std::ptr::null_mut() };
    // Use the handle through a method: a closure that named `h.ptr` directly would capture only
    // that field (disjoint capture, 2021 edition), and a bare `*mut u8` is not Send.
    let t = std::thread::spawn(move || h.is_open());
    println!("{}", t.join().unwrap());
}

Opting out on stable uses a marker field such as PhantomData<*const ()> (negative impls like impl !Send are nightly-only).

Atomics and memory orderings

The std::sync::atomic types (AtomicBool, AtomicU8 …​ AtomicU64, AtomicUsize, AtomicIsize, AtomicPtr) are integers that threads can read and modify without a lock, using CPU atomic instructions. Every operation takes an Ordering, which says what other memory accesses the operation synchronises with:

Ordering Guarantee and typical use

Relaxed

the operation itself is atomic, nothing more: counters and statistics read independently

Release (store) / Acquire (load)

everything written before a Release store is visible to a thread whose Acquire load reads that value: publishing data, flags guarding initialisation, locks

AcqRel

both, for read-modify-write operations

SeqCst

AcqRel plus a single total order of all SeqCst operations: the safest default when unsure

use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::thread;

static REQUESTS: AtomicU64 = AtomicU64::new(0);

fn main() {
    // Relaxed counter: only the final total matters.
    let workers: Vec<_> = (0..4)
        .map(|_| thread::spawn(|| for _ in 0..1000 { REQUESTS.fetch_add(1, Ordering::Relaxed); }))
        .collect();
    for w in workers {
        w.join().unwrap();
    }
    println!("requests: {}", REQUESTS.load(Ordering::Relaxed)); // 4000

    // Release/Acquire: publish data, then raise a flag.
    let data = Arc::new(AtomicU32::new(0));
    let ready = Arc::new(AtomicBool::new(false));
    let (d, r) = (Arc::clone(&data), Arc::clone(&ready));
    let producer = thread::spawn(move || {
        d.store(1965, Ordering::Relaxed);
        r.store(true, Ordering::Release);       // everything before this...
    });
    while !ready.load(Ordering::Acquire) {      // ...is visible after this load sees `true`
        std::hint::spin_loop();
    }
    println!("data: {}", data.load(Ordering::Relaxed)); // guaranteed 1965
    producer.join().unwrap();

    // Read-modify-write with a closure: Atomic*::update (1.95) returns the previous value.
    let stock = AtomicU32::new(2);
    let before = stock.update(Ordering::AcqRel, Ordering::Acquire, |n| n.saturating_sub(1));
    // compare_exchange: succeed only if the value is still what we expect.
    let swapped = stock.compare_exchange(1, 10, Ordering::AcqRel, Ordering::Acquire);
    println!("{before} {swapped:?} {}", stock.load(Ordering::Acquire)); // 2 Ok(1) 10
}

fetch_update (and the newer, infallible update, 1.95) run a compare-and-swap loop for you; try_update is the fallible variant that stops when the closure returns None. Getting orderings wrong produces bugs that may only appear on weakly ordered CPUs such as ARM; when in doubt use SeqCst, or better, a Mutex. The Rustonomicon’s chapter on atomics and Mara Bos’s book Rust Atomics and Locks explain the memory model in depth; C and C++ use the same model (see C: Atomics & Memory Consistency).

Data races versus race conditions

Safe Rust prevents data races — two threads accessing the same memory concurrently, at least one writing, without synchronisation — which are undefined behaviour. It does not prevent race conditions in general: logic that depends on timing (check-then-act on two separate lock acquisitions, an atomic counter read and then written separately). Design critical sections so that each invariant is checked and updated under one lock or one atomic operation.

Lock contention

A lock that many threads want at once becomes a bottleneck: threads spend time blocked instead of working, CPU usage stays low while latency grows. In a traced service this shows up as spans with long gaps before work starts, or as spawn_blocking/worker threads waiting; Gangappa & Rangaraj (Mastering Distributed Observability in Rust, chapter 2) show how contention on an Arc<Mutex<..>> cache appears in traces and flamegraphs. Remedies, roughly in order:

  • shorten the critical section (clone or compute outside, hold the guard briefly);

  • use RwLock for read-mostly data, or atomics for counters;

  • shard the data (several mutexes keyed by hash), or use a concurrent map such as dashmap;

  • replace sharing with message passing to a single owner thread or task.

Deadlocks and lock ordering

Rust does not prevent deadlocks. Two threads that lock a then b, and b then a, can each wait for the other forever; locking a Mutex twice on the same thread deadlocks (or panics) too. Rules that avoid most deadlocks:

  • acquire multiple locks in a single global order (for example, by account ID);

  • never call unknown code (callbacks, trait objects) while holding a lock;

  • hold locks for as short a time as possible, and never across blocking I/O or .await;

  • prefer one lock protecting a struct over several locks protecting its fields.