#277 Jul 23, 2026

277. Release/Acquire — Publish Data Through a Flag, Not Just the Flag

This morning’s swap bite ended with a warning: once the winning thread writes data that other threads read, Relaxed stops being enough. Here’s the two-line fix.

The classic pattern: one thread computes a result, then raises a flag so others know it’s ready. With Relaxed everywhere, that’s broken:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};

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

// writer thread
RESULT.store(42, Ordering::Relaxed);
READY.store(true, Ordering::Relaxed); // may be reordered!

// reader thread
if READY.load(Ordering::Relaxed) {
    // can legally observe RESULT == 0 here
    let r = RESULT.load(Ordering::Relaxed);
}

Relaxed only makes each individual operation atomic — it says nothing about the order two different atomics become visible in. The compiler or CPU may commit the READY store before the RESULT store, so a reader sees the flag up but the data stale.

The fix is a pair, one ordering on each side of the flag:

1
2
3
4
5
6
7
8
9
// writer: Release on the store that publishes
RESULT.store(42, Ordering::Relaxed);
READY.store(true, Ordering::Release);

// reader: Acquire on the load that checks
while !READY.load(Ordering::Acquire) {
    std::hint::spin_loop();
}
assert_eq!(RESULT.load(Ordering::Relaxed), 42); // guaranteed

When an Acquire load sees the value written by a Release store, everything the writer did before the store is visible to the reader after the load. The flag becomes a one-way gate for all the writes behind it — note RESULT itself can stay Relaxed; the flag pair carries the synchronization.

Full picture, verified with a scoped thread:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
std::thread::scope(|s| {
    s.spawn(|| {
        RESULT.store(42, Ordering::Relaxed);
        READY.store(true, Ordering::Release);
    });
    s.spawn(|| {
        while !READY.load(Ordering::Acquire) {
            std::hint::spin_loop();
        }
        assert_eq!(RESULT.load(Ordering::Relaxed), 42);
    });
});

Two things worth knowing. First, the pairing only kicks in when the Acquire load actually sees the Released value — that’s why the reader spins. Second, you won’t catch the Relaxed bug on your x86 laptop: the hardware is strongly ordered and hides it. Your ARM build server is not so forgiving, and the compiler is allowed to reorder either way.

Rule of thumb: Release on the store that publishes, Acquire on the load that consumes, Relaxed for lone counters and flags that guard nothing. This pair is exactly what Mutex unlock/lock and OnceLock do for you under the hood — reach for those first; reach for the raw pair when you can name what’s being published.

Stable since Rust 1.0, on every Atomic* type.

← Previous 276. AtomicBool::swap — Let Exactly One Thread Claim the Job Next → 278. Condvar::wait_while — The Wait Loop You Keep Writing (and Getting Backwards)