Guide contents

Lesson 76 of 94

The Rust Memory Model

A memory model answers one question: when one thread writes a variable, when is another thread guaranteed to see the write? Its vocabulary includes happens-before, safe publication, and why double-checked locking is easy to get wrong.

Rust uses the same model as C++ (from C++20), with one big difference: safe Rust can't express a data race. The compiler rejects the programs whose behavior a memory model would otherwise have to define. What's left to understand is what synchronization gives you, and which Ordering to pass to atomics.

Concept Rust
Data race on a plain variable Doesn't compile (Send/Sync and borrowing)
Thread start and join ordering thread::spawn / join(), and thread::scope
Mutual exclusion Mutex, RwLock (the lock owns the data)
Blocking queue channel send / recv
Sequentially consistent access atomic with SeqCst
Release and acquire store(_, Release) / load(Acquire)
Relaxed access Relaxed
Fences fence(SeqCst) / fence(Acquire)
Safe publication OnceLock, LazyLock
Avoiding false sharing #[repr(align(64))], crossbeam_utils::CachePadded
Testing concurrent code loom, Miri, ThreadSanitizer

Part 1: Happens-before

1. Why a memory model exists

Compilers and CPUs reorder memory operations. The compiler moves loads and stores to keep values in registers, and CPUs have store buffers and caches that make writes visible to other cores in a different order than the program wrote them. Within one thread, all of this is invisible: the result is "as if" the code ran in order. Across threads it isn't, and a memory model defines which results are allowed.

2. A racy program doesn't compile in Rust

Rust rejects the shared plain variables. The spawned thread borrows data and ready, so the main thread can't assign to them while that thread may still be running. Writing to a value while another thread reads it is a data race, and the borrow checker rules it out.

Rust✗ does not compile · E0506
use std::thread;

fn main() {
    let mut data = 0;
    let mut ready = false;
    thread::scope(|s| {
        s.spawn(|| {
            while !ready {}
            println!("{data}");
        });
        data = 42;
        ready = true;
    });
}
Output
error[E0506]: cannot assign to `data` because it is borrowed
  --> src/main.rs:11:9
   |
 6 |       thread::scope(|s| {
   |                      - has type `&'1 Scope<'1, '_>`
 7 |           s.spawn(|| {
   |           -       -- `data` is borrowed here
   |  _________|
   | |
 8 | |             while !ready {}
 9 | |             println!("{data}");
   | |                        ---- borrow occurs due to use in closure
10 | |         });
   | |__________- argument requires that `data` is borrowed for `'1`
11 |           data = 42;
   |           ^^^^^^^^^ `data` is assigned to here but it was already borrowed
   |
note: requirement that the value outlives `'1` introduced here
  --> /rustc/<version>/library/std/src/thread/scoped.rs:200:34

For values that aren't borrowed but shared through Arc, the same protection comes from Send and Sync: Arc<Cell<i32>> can't be sent to another thread (Atomics, Send & Sync). The only ways to share mutable data between threads are types built for it: locks, channels, and atomics. Each of them creates happens-before edges.

3. Spawn and join create happens-before

Everything a thread did before spawn is visible to the new thread, and everything the thread did is visible after join. Plain, non-atomic data passed this way needs no further synchronization.

Rust
use std::thread;

fn main() {
    let mut totals = vec![0u64; 4];
    let input: Vec<u64> = (1..=1000).collect();

    thread::scope(|s| {
        for (i, slot) in totals.iter_mut().enumerate() {
            let chunk = &input[i * 250..(i + 1) * 250]; // written before spawn: visible
            s.spawn(move || *slot = chunk.iter().sum());
        }
    }); // all threads joined here

    // Everything the threads wrote is visible after the join.
    println!("{totals:?} = {}", totals.iter().sum::<u64>());
}
Output
[31375, 93875, 156375, 218875] = 500500

Each thread gets its own &mut u64 slot, so the compiler can prove there's no overlap. After the scope ends, the main thread reads the plain u64s with no atomics or locks involved.

4. A Mutex unlock happens-before the next lock

A Mutex doesn't just provide mutual exclusion. Releasing it publishes every write made while holding it (and before) to the next thread that acquires it. This is the same guarantee as leaving and entering a synchronized block. Because Rust's Mutex<T> owns the data, you can't forget to lock before accessing it.

Rust
use std::sync::Mutex;
use std::thread;

#[derive(Default, Debug)]
struct Inventory {
    apples: u32,
    pears: u32,
}

fn main() {
    let inv = Mutex::new(Inventory::default());
    thread::scope(|s| {
        for _ in 0..4 {
            s.spawn(|| {
                for _ in 0..1000 {
                    let mut g = inv.lock().unwrap();
                    g.apples += 1; // two plain writes, published together at unlock
                    g.pears += 2;
                } // unlock: Release
            });
        }
    });
    let g = inv.lock().unwrap(); // lock: Acquire
    println!("{g:?}, consistent: {}", g.pears == 2 * g.apples);
}
Output
Inventory { apples: 4000, pears: 8000 }, consistent: true

5. A channel send happens-before the receive

Sending a value through a channel moves it, and the receiver sees the value exactly as the sender left it, including everything it points to. This is how most Rust programs publish data between threads, without thinking about orderings at all.

Rust
use std::collections::HashMap;
use std::sync::mpsc;
use std::thread;

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

    let producer = thread::spawn(move || {
        let mut index = HashMap::new(); // built with plain writes
        for word in "the cat and the hat and the bat".split(' ') {
            *index.entry(word.to_string()).or_insert(0) += 1;
        }
        tx.send(index).unwrap(); // publish: the whole map moves
    });

    let index = rx.recv().unwrap(); // everything written before `send` is visible
    let mut words: Vec<_> = index.into_iter().collect();
    words.sort();
    println!("{words:?}");
    producer.join().unwrap();
}
Output
[("and", 2), ("bat", 1), ("cat", 1), ("hat", 1), ("the", 3)]

The rule of thumb for application code: share through channels, Mutex, or OnceLock, and you never need to think about the memory model. The rest of this lesson is for when you're writing the synchronization itself.

Part 2: Atomic orderings

6. What Relaxed guarantees

Relaxed operations are atomic (no torn reads, no lost updates) and each atomic variable has a single modification order that all threads agree on. A thread that has seen one value will never later see an older one. What Relaxed does not do is order anything else: other variables may appear in any order.

Rust
use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
use std::thread;

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

fn main() {
    thread::scope(|s| {
        s.spawn(|| {
            for i in 1..=100_000 {
                PROGRESS.store(i, Relaxed);
            }
        });
        s.spawn(|| {
            let mut last = 0;
            let mut went_backwards = false;
            while last < 100_000 {
                let now = PROGRESS.load(Relaxed);
                went_backwards |= now < last; // never true: coherence of one variable
                last = now;
            }
            println!("reader reached {last}, went backwards: {went_backwards}");
        });
    });
}
Output
reader reached 100000, went backwards: false

That's enough for counters, statistics, id generators, and progress indicators: anything where the value itself is the whole message.

7. Relaxed is not enough to publish other data

The moment a flag means "the other data is ready", Relaxed is wrong. This program can fail on ARM (most phones, AWS Graviton, Apple Silicon), because nothing orders the write of DATA before the write of READY. The compiler is also allowed to reorder the two stores. On x86 it usually works anyway, because the CPU doesn't reorder stores with other stores, which is how this bug ships to production.

Rust
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering::Relaxed};
use std::thread;

static DATA: AtomicU64 = AtomicU64::new(0);
static READY: AtomicBool = AtomicBool::new(false);

fn main() {
    thread::scope(|s| {
        s.spawn(|| {
            DATA.store(42, Relaxed);
            READY.store(true, Relaxed); // BUG: should be Release
        });
        s.spawn(|| {
            while !READY.load(Relaxed) {} // BUG: should be Acquire
            assert_eq!(DATA.load(Relaxed), 42); // may fail: the reader can see READY before DATA
        });
    });
}

The fix is the Release/Acquire pair from Atomics, Send & Sync: a Release store publishes everything the thread wrote before it, and an Acquire load that reads that value sees all of it. Think of Release as "send" and Acquire as "receive".

Note that the buggy version isn't undefined behavior, because all accesses are atomic. It's a logic bug: a reader can see a stale value. A data race (UB) needs at least one non-atomic access, and safe Rust can't write one.

8. A spin lock: Acquire on lock, Release on unlock

Locks are the classic use of Acquire/Release. Acquiring the lock must see everything the previous holder wrote, and releasing it must publish the current holder's writes. Here's a minimal spin lock guarding plain, non-atomic data. It uses UnsafeCell, the building block explained in the next lesson, and the orderings are what make it correct.

Rust
use std::cell::UnsafeCell;
use std::ops::{Deref, DerefMut};
use std::sync::atomic::{AtomicBool, Ordering::{Acquire, Relaxed, Release}};
use std::thread;

pub struct SpinLock<T> {
    locked: AtomicBool,
    value: UnsafeCell<T>,
}

// Safety: the lock ensures only one thread accesses `value` at a time.
unsafe impl<T: Send> Sync for SpinLock<T> {}

pub struct Guard<'a, T> {
    lock: &'a SpinLock<T>,
}

impl<T> SpinLock<T> {
    pub const fn new(value: T) -> Self {
        Self { locked: AtomicBool::new(false), value: UnsafeCell::new(value) }
    }

    pub fn lock(&self) -> Guard<'_, T> {
        // Acquire: if we win, we see everything the previous holder wrote.
        while self.locked.compare_exchange_weak(false, true, Acquire, Relaxed).is_err() {
            std::hint::spin_loop();
        }
        Guard { lock: self }
    }
}

impl<T> Deref for Guard<'_, T> {
    type Target = T;
    fn deref(&self) -> &T {
        unsafe { &*self.lock.value.get() } // Safety: we hold the lock
    }
}

impl<T> DerefMut for Guard<'_, T> {
    fn deref_mut(&mut self) -> &mut T {
        unsafe { &mut *self.lock.value.get() } // Safety: we hold the lock
    }
}

impl<T> Drop for Guard<'_, T> {
    fn drop(&mut self) {
        self.lock.locked.store(false, Release); // Release: publish our writes
    }
}

fn main() {
    let log = SpinLock::new(Vec::new());
    thread::scope(|s| {
        for t in 0..4 {
            let log = &log;
            s.spawn(move || {
                for i in 0..1000 {
                    log.lock().push(t * 1000 + i);
                }
            });
        }
    });
    let entries = log.lock();
    println!("{} entries, none lost: {}", entries.len(), entries.iter().copied().collect::<std::collections::HashSet<_>>().len() == 4000);
}
Output
4000 entries, none lost: true

The failure ordering of compare_exchange_weak is Relaxed because a failed attempt doesn't enter the critical section, so it has nothing to synchronize with. In real code use std::sync::Mutex: it spins briefly and then parks the thread, while this lock burns CPU when contended.

9. SeqCst vs Acquire/Release: store buffering

Acquire/Release orders operations between two threads that communicate through the same variable. It doesn't give all threads a single global order of operations on different variables. The classic example (it's the core of Dekker's and Peterson's mutual exclusion algorithms): each thread sets its own flag, then checks the other's.

With SeqCst, all SeqCst operations happen in one total order, so at least one thread must see the other's flag:

Rust
use std::sync::atomic::{AtomicBool, Ordering::SeqCst};
use std::thread;

fn main() {
    let mut both_missed = 0;
    for _ in 0..20_000 {
        let (x, y) = (AtomicBool::new(false), AtomicBool::new(false));
        let (saw_y, saw_x) = thread::scope(|s| {
            let a = s.spawn(|| {
                x.store(true, SeqCst);
                y.load(SeqCst)
            });
            let b = s.spawn(|| {
                y.store(true, SeqCst);
                x.load(SeqCst)
            });
            (a.join().unwrap(), b.join().unwrap())
        });
        if !saw_y && !saw_x {
            both_missed += 1;
        }
    }
    println!("runs where both threads missed the other's flag: {both_missed}");
}
Output
runs where both threads missed the other's flag: 0

Change the stores to Release and the loads to Acquire, and "both missed" becomes a legal outcome. It really happens, even on x86: each core's store sits in its store buffer while its load runs ahead. SeqCst makes the compiler emit a full barrier (mfence or a locked instruction on x86) to prevent it.

Rust makes you choose an ordering, and SeqCst is the right choice whenever the correctness argument involves more than one atomic variable and you aren't sure Acquire/Release suffices.

10. Fences: one barrier for many relaxed operations

A fence applies an ordering to surrounding Relaxed operations instead of one specific operation. A common use: poll many flags cheaply with Relaxed, then issue a single Acquire fence once one of them is set, instead of paying for Acquire on every poll.

Rust
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering::{Acquire, Relaxed, Release}, fence};
use std::thread;

static RESULTS: [AtomicU64; 4] = [const { AtomicU64::new(0) }; 4];
static DONE: [AtomicBool; 4] = [const { AtomicBool::new(false) }; 4];

fn main() {
    thread::scope(|s| {
        for w in 0..4 {
            s.spawn(move || {
                let sum: u64 = (w * 1000..(w + 1) * 1000).sum();
                RESULTS[w as usize].store(sum, Relaxed);
                DONE[w as usize].store(true, Release); // publish this worker's result
            });
        }

        // Poll all flags with Relaxed loads...
        while !DONE.iter().all(|d| d.load(Relaxed)) {
            std::hint::spin_loop();
        }
        // ...then one Acquire fence pairs with every Release store we observed.
        fence(Acquire);
        let total: u64 = RESULTS.iter().map(|r| r.load(Relaxed)).sum();
        println!("total: {total}");
    });
}
Output
total: 7998000

The rule: a Release store (or a Release fence followed by a store) that is read by a Relaxed load followed by an Acquire fence creates a happens-before edge, just as if the load had been Acquire. Fences are an optimization for hot synchronization code. Most code should put the ordering on the operation itself.

11. Choosing an ordering

Situation Ordering
Counter, statistic, id generator, "is shutdown requested?" hint Relaxed
Flag or index that says "other data is ready" (writer side) Release store
Reading such a flag or index before touching the data Acquire load
Read-modify-write that both takes and publishes (lock handoff, reference count reaching zero) AcqRel
Correctness depends on the order of operations on different atomics across threads SeqCst
Not sure SeqCst, or better, a Mutex or channel

Arc itself is a good example of mixed orderings: clone increments with Relaxed (a new owner doesn't need to see anything), while drop decrements with Release and the thread that reaches zero issues an Acquire fence before freeing, so it sees every other owner's writes.

12. Access modes and Rust orderings

Each kind of memory access corresponds to a Rust ordering.

Access Rust Notes
Plain access non-atomic access (&mut T, or under a lock) a race on it doesn't compile
Relaxed access load(Relaxed) / store(_, Relaxed) atomic and coherent, no ordering with other data
Acquire and release load(Acquire) / store(_, Release) publication pairs
Sequentially consistent access load(SeqCst) / store(_, SeqCst) a single total order
Compare and set compare_exchange(_, _, SeqCst, SeqCst) pick weaker orderings when you can justify them
Weak compare and set compare_exchange_weak(_, _, Relaxed, Relaxed) may fail spuriously; use in loops
Fences fence(Acquire) / fence(Release) / fence(SeqCst) order the surrounding relaxed operations
Values visible after construction a value published through OnceLock, a channel, or a Release store Rust has no special rule for constructors

Note that std::ptr::read_volatile is not a synchronization tool. It's for memory-mapped hardware registers and prevents the compiler from removing or merging the access, but gives no ordering or atomicity between threads. For threads, always use atomics.

Part 3: Patterns and pitfalls

13. Safe publication without double-checked locking

Lazy initialization of a shared object is a famous trap: double-checked locking without the right ordering can publish a partly constructed object. Rust's OnceLock and LazyLock implement the correct version (an Acquire check on the fast path; on the slow path, one thread runs the initializer while the others block) and are the only tools you need.

Rust
use std::collections::HashMap;
use std::sync::LazyLock;
use std::sync::atomic::{AtomicUsize, Ordering::Relaxed};
use std::thread;

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

static COUNTRY_NAMES: LazyLock<HashMap<&'static str, &'static str>> = LazyLock::new(|| {
    INIT_RUNS.fetch_add(1, Relaxed);
    HashMap::from([("DE", "Germany"), ("FR", "France"), ("IN", "India")])
});

fn main() {
    let names: Vec<&str> = thread::scope(|s| {
        let handles: Vec<_> = ["DE", "FR", "IN", "DE"].into_iter().map(|code| s.spawn(move || COUNTRY_NAMES[code])).collect();
        handles.into_iter().map(|h| h.join().unwrap()).collect()
    });
    println!("{names:?}");
    println!("initializer ran {} time(s)", INIT_RUNS.load(Relaxed));
}
Output
["Germany", "France", "India", "Germany"]
initializer ran 1 time(s)

Every thread that reads COUNTRY_NAMES sees the fully built map, and the initializer runs exactly once even when four threads race to trigger it.

14. False sharing: independent data on the same cache line

CPUs move memory between cores in 64-byte cache lines (128 bytes on some ARM chips). Two atomics written by different threads but stored next to each other share a line, and every write by one core invalidates the other core's copy. The code is correct but can be several times slower. In Rust you control layout with #[repr(align(64))].

Rust
use std::mem::{align_of, size_of};
use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
use std::thread;

// Four counters in 32 bytes: usually on one cache line, contended by four threads.
struct Packed {
    counters: [AtomicU64; 4],
}

// Each counter on its own cache line.
#[repr(align(64))]
struct CachePadded(AtomicU64);

struct Padded {
    counters: [CachePadded; 4],
}

fn main() {
    println!("Packed: {} bytes; CachePadded: {} bytes, align {}", size_of::<Packed>(), size_of::<CachePadded>(), align_of::<CachePadded>());

    let packed = Packed { counters: [const { AtomicU64::new(0) }; 4] };
    let padded = Padded { counters: [const { CachePadded(AtomicU64::new(0)) }; 4] };

    thread::scope(|s| {
        for t in 0..4 {
            let (packed, padded) = (&packed, &padded);
            s.spawn(move || {
                for _ in 0..100_000 {
                    packed.counters[t].fetch_add(1, Relaxed); // fights over one line
                    padded.counters[t].0.fetch_add(1, Relaxed); // private line per thread
                }
            });
        }
    });
    let total = |c: &[u64]| c.iter().sum::<u64>();
    println!(
        "totals: {} {}",
        total(&packed.counters.iter().map(|c| c.load(Relaxed)).collect::<Vec<_>>()),
        total(&padded.counters.iter().map(|c| c.0.load(Relaxed)).collect::<Vec<_>>())
    );
}
Output
Packed: 32 bytes; CachePadded: 64 bytes, align 64
totals: 400000 400000

Time the two loops separately on a multi-core machine and the padded version is typically several times faster. crossbeam_utils::CachePadded<T> picks the right alignment per platform (128 bytes on x86-64 too, because adjacent-line prefetching pairs lines). Better still, avoid sharing: give each thread a local counter and combine at the end, as in Performance.

15. Testing concurrent code

A concurrency bug that shows up once in a million runs, or only on ARM, won't be found by running tests a few times. Three tools help, from most to least thorough:

Tool What it does
loom runs a small test under every thread interleaving (up to a configurable bound) and many weak-memory reorderings
Miri interprets the program, detects data races and UB, and explores weak-memory behaviors with random seeds
ThreadSanitizer instruments a real build and reports data races at runtime

loom replaces std's atomics and threads in test builds:

TOMLCargo.toml
[target.'cfg(loom)'.dev-dependencies]
loom = "0.7"
Rust
#[cfg(loom)]
#[test]
fn publication_is_visible() {
    use loom::sync::Arc;
    use loom::sync::atomic::{AtomicBool, AtomicU64, Ordering::{Acquire, Relaxed, Release}};
    use loom::thread;

    loom::model(|| {
        let data = Arc::new(AtomicU64::new(0));
        let ready = Arc::new(AtomicBool::new(false));
        let (d, r) = (Arc::clone(&data), Arc::clone(&ready));
        let writer = thread::spawn(move || {
            d.store(42, Relaxed);
            r.store(true, Release);
        });
        if ready.load(Acquire) {
            assert_eq!(data.load(Relaxed), 42);
        }
        writer.join().unwrap();
    });
}
Terminal
RUSTFLAGS="--cfg loom" cargo test --release publication_is_visible
MIRIFLAGS="-Zmiri-many-seeds=0..64" cargo +nightly miri test   # many schedules under Miri
RUSTFLAGS="-Zsanitizer=thread" cargo +nightly test -Zbuild-std --target x86_64-unknown-linux-gnu

Change Release to Relaxed in the loom test and loom reports an execution in which the assertion fails, whatever CPU you run it on.

16. Realistic example: a lock-free single-producer ring buffer

A bounded single-producer, single-consumer (SPSC) queue is the textbook use of Release/Acquire. The producer writes a slot, then publishes it by advancing tail with Release. The consumer reads tail with Acquire, which makes the slot's contents visible, then frees the slot by advancing head with Release. The slots here are atomics, so the whole queue is safe Rust.

Rust
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering::{Acquire, Relaxed, Release}};
use std::thread;

const CAP: usize = 8;

struct Spsc {
    slots: [AtomicU64; CAP],
    head: AtomicUsize, // next slot to read (written by the consumer only)
    tail: AtomicUsize, // next slot to write (written by the producer only)
}

impl Spsc {
    const fn new() -> Self {
        Self { slots: [const { AtomicU64::new(0) }; CAP], head: AtomicUsize::new(0), tail: AtomicUsize::new(0) }
    }

    fn try_push(&self, value: u64) -> bool {
        let tail = self.tail.load(Relaxed); // only we write tail
        if tail - self.head.load(Acquire) == CAP {
            return false; // full: wait until the consumer frees a slot
        }
        self.slots[tail % CAP].store(value, Relaxed);
        self.tail.store(tail + 1, Release); // publish the slot
        true
    }

    fn try_pop(&self) -> Option<u64> {
        let head = self.head.load(Relaxed); // only we write head
        if head == self.tail.load(Acquire) {
            return None; // empty
        }
        let value = self.slots[head % CAP].load(Relaxed); // visible thanks to Acquire on tail
        self.head.store(head + 1, Release); // hand the slot back to the producer
        Some(value)
    }
}

fn main() {
    let queue = Spsc::new();
    let (sum, count, in_order) = thread::scope(|s| {
        s.spawn(|| {
            for v in 1..=100_000u64 {
                while !queue.try_push(v) {
                    std::hint::spin_loop();
                }
            }
        });
        let consumer = s.spawn(|| {
            let (mut sum, mut count, mut last, mut in_order) = (0u64, 0u64, 0u64, true);
            while count < 100_000 {
                if let Some(v) = queue.try_pop() {
                    in_order &= v == last + 1;
                    last = v;
                    sum += v;
                    count += 1;
                }
            }
            (sum, count, in_order)
        });
        consumer.join().unwrap()
    });
    println!("received {count} values, sum {sum}, in order: {in_order}");
}
Output
received 100000 values, sum 5000050000, in order: true

Each index is written by only one thread, so it can load its own index with Relaxed. The indices grow without wrapping and are reduced with % CAP when used; with a usize they won't overflow in the lifetime of any real program. To store non-Copy values, the slots become UnsafeCell<MaybeUninit<T>> with exactly the same orderings. That's how SPSC crates such as rtrb work.

Summary

  • Rust uses the C++20 memory model. Safe Rust can't write a data race: shared mutation requires a lock, a channel, or an atomic.
  • Happens-before edges come from spawn/join, Mutex unlock/lock, channel send/receive, OnceLock/LazyLock, and Release/Acquire atomics. Application code that uses these never needs to think about orderings.
  • Relaxed is atomic and coherent per variable, and orders nothing else: use it for counters and hints, never to publish other data.
  • Release store + Acquire load publishes everything written before the store. AcqRel does both for read-modify-write operations.
  • SeqCst adds a single total order across variables; it's needed for store-buffering patterns.
  • Fences apply an ordering to surrounding relaxed operations. Pad hot per-thread atomics to a cache line to avoid false sharing.
  • Test lock-free code with loom, Miri, and ThreadSanitizer. It may work on x86 and still be wrong.

Next: Aliasing & Undefined Behavior: the guarantees behind & and &mut that the optimizer relies on, and what unsafe code must never break.