🎓 A1Learner
🔍

Rust Atomic Ordering

An ordering is a promise about what another thread can see when an atomic value changes. Stronger promises cost a little speed.

Relaxed: the value is safe, nothing else is promised

A counter that guards no other data needs no promises. The total is still exact — every fetch_add lands:

use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;
fn main() {
    static COUNT: AtomicUsize = AtomicUsize::new(0);
    thread::scope(|s| {
        for _ in 0..4 {
            s.spawn(|| {
                for _ in 0..100 { COUNT.fetch_add(1, Ordering::Relaxed); }
            });
        }
    });
    println!("count = {}", COUNT.load(Ordering::Relaxed));
}

Release then Acquire: one thread publishes, another receives

The writer stores its data first, then raises the flag with Release. A reader that Acquires the flag and sees it up is promised the data is there too:

use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::thread;
static DATA: AtomicUsize = AtomicUsize::new(0);
static READY: AtomicBool = AtomicBool::new(false);
fn main() {
    thread::scope(|s| {
        s.spawn(|| {
            DATA.store(42, Ordering::Relaxed);
            READY.store(true, Ordering::Release);
        });
        while !READY.load(Ordering::Acquire) { std::hint::spin_loop(); }
        println!("data = {}", DATA.load(Ordering::Relaxed));
    });
}

SeqCst: every thread agrees on one single order

Two threads each set their own flag, then read the other's. With SeqCst, both reading "not set yet" is impossible — that would need two different global orders. The threads simply race, and the printed line is a promise, not a hope:

use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
static X: AtomicBool = AtomicBool::new(false);
static Y: AtomicBool = AtomicBool::new(false);
fn main() {
    let (r1, r2) = thread::scope(|s| {
        let a = s.spawn(|| { X.store(true, Ordering::SeqCst); Y.load(Ordering::SeqCst) });
        let b = s.spawn(|| { Y.store(true, Ordering::SeqCst); X.load(Ordering::SeqCst) });
        (a.join().unwrap(), b.join().unwrap())
    });
    println!("at least one saw the other = {}", r1 || r2);
}

Read-modify-write takes AcqRel

fetch_add and compare_exchange both read and write. So Acquire or Release alone does not fit them. Acquire belongs on loads. Release belongs on stores. The compiler refuses the mismatches outright. A Release load does not even compile:

counter.fetch_add(1, Ordering::AcqRel); // Acquire for the read half, Release for the write half

Example

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

static PARTS: [AtomicUsize; 3] = [AtomicUsize::new(0), AtomicUsize::new(0), AtomicUsize::new(0)];
static READY: AtomicBool = AtomicBool::new(false);

fn main() {
    thread::scope(|s| {
        s.spawn(|| {
            let answers = [6, 7, 42];
            for (slot, value) in PARTS.iter().zip(answers.iter()) {
                slot.store(*value, Ordering::Relaxed);
            }
            READY.store(true, Ordering::Release);
        });
        while !READY.load(Ordering::Acquire) {
            std::hint::spin_loop();
        }
        let mut got = Vec::new();
        for slot in &PARTS {
            got.push(slot.load(Ordering::Relaxed));
        }
        println!("published answers = {:?}", got);
    });
}

Key points

  • An Ordering answers one question.
  • If another thread sees this atomic change, what else is it guaranteed to see along with it?
  • Relaxed promises nothing beyond the atomic value itself — right for counters and statistics that guard no other data
  • Release belongs on a store.
  • Everything the thread wrote before that store becomes visible.
  • It becomes visible to a thread that Acquires the same atomic.
  • Acquire belongs on a load.
  • It reads a Release store on the same atomic.
  • After that, all the writes published with that store are visible too.
  • The pair is how one thread hands finished work to another.
  • SeqCst is the strongest: all SeqCst operations line up in one single order that every thread agrees on.
  • It costs extra fencing, and it is the honest choice whenever you cannot state your ordering argument in one sentence.
  • The ordering belongs to each operation, not to the variable.
  • One atomic may be stored with Release in one place.
  • It may be loaded with Relaxed in another.
  • x86 orders memory strongly.
  • Yet the code can still be wrong.
  • The bug only shows on weakly-ordered hardware such as ARM.
  • Test concurrent code there when you can.

In this example, a writer thread stores three answers into three atomic slots. It uses Relaxed stores. That is legal. The slots are not what synchronises the two threads. It then stores true into READY with Release. The main thread spins on READY with Acquire loads. The moment it sees true, the Release/Acquire pair on READY does its work. It guarantees the three earlier stores are visible to it as well. So the Relaxed loads that follow print [6, 7, 42]. Never zeros. Never a half-published mix. Swap READY to Relaxed on both sides and the promise disappears. The program looks identical. It would still usually work on x86. Yet it would be wrong.

Note

Weak orderings can look perfectly fine on x86.

Try it Yourself

Edit the code, then press Run.

Running is simulated · Rust Playground

Exercise

Thread A writes ordinary data, then stores a flag with Release. Thread B Acquire-loads the flag and sees the new value. What may B rely on?