The memory model and orderings

Atomics are not only about lost updates. A write made by one thread has to become visible to another in a defined order, and the ordering argument of every atomic operation is what says how much of that order the programmer needs. Rust borrows the model from C++ and states it in std::sync::atomic::Ordering.

This lesson starts from the happens-before relation that a channel gives for free, builds it by hand with Release and Acquire, uses it in a spin lock and a fence, and ends with the compiler error for a misspelled ordering.

Happens-before

Two events are ordered when one must be finished before the other can be observed: a write before a send, the matching receive before the read that follows it. That relation is what makes the value in a message visible to the receiver without any extra instruction.

The program sends the length of a vector and reads it on the other side before joining. The vector itself was allocated and filled before the send, and the number is what the reader sees of it.

use std::sync::mpsc; use std::thread; fn main() { let (sender, receiver) = mpsc::channel(); let worker = thread::spawn(move || { let data = vec![1, 2, 3]; sender.send(data.len()).unwrap(); }); let length = receiver.recv().unwrap(); worker.join().unwrap(); println!("{length}"); }

rustc --edition 2024 channel.rs
./channel

3

The channel inserts the ordering the programmer would otherwise have to write, which is why it is the first tool to reach for. The lesson on channels covers the two ends; this lesson is about the visibility they guarantee.

Publishing a value

A store with Release makes every write before it visible to a thread whose load with Acquire sees the stored value. The pair is how one thread hands a block of data to another without a lock: one atomic flag says the data is ready.

The writer stores the number, then the flag; the reader spins on the flag and reads the number. The relaxed load of the number is fine because the acquire on the flag already ordered it.

use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Arc; use std::thread; struct Shared { data: AtomicUsize, ready: AtomicBool, } fn main() { let shared = Arc::new(Shared { data: AtomicUsize::new(0), ready: AtomicBool::new(false), }); let writer = { let shared = Arc::clone(&shared); thread::spawn(move || { shared.data.store(42, Ordering::Relaxed); shared.ready.store(true, Ordering::Release); }) }; while !shared.ready.load(Ordering::Acquire) { std::hint::spin_loop(); } println!("{}", shared.data.load(Ordering::Relaxed)); writer.join().unwrap(); }

rustc --edition 2024 publish.rs
./publish

42

With Relaxed on both sides the reader could see the true flag and the old number, because nothing would order the two stores. The spin loop is also the reason the previous lesson ends with an atomic flag rather than a shared variable.

A spin lock

A lock is a flag and two orderings: swap with Acquire to take it, a store with Release to give it back. The acquire on the way in is what makes the writes of the previous owner visible, and the release on the way out is what publishes the writes of this one.

The program wraps a counter in a spin lock, takes it, writes and releases. The value is one, and the two orderings are in the two methods.

use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; struct SpinLock { locked: AtomicBool, } impl SpinLock { fn new() -> Self { SpinLock { locked: AtomicBool::new(false) } } fn lock(&self) { while self.locked.swap(true, Ordering::Acquire) { std::hint::spin_loop(); } } fn unlock(&self) { self.locked.store(false, Ordering::Release); } } fn main() { let lock = SpinLock::new(); let counter = AtomicUsize::new(0); lock.lock(); counter.store(1, Ordering::Relaxed); lock.unlock(); println!("{}", counter.load(Ordering::Relaxed)); }

rustc --edition 2024 spin.rs
./spin

1

This is the shape std::sync::Mutex has inside, with a futex instead of a busy loop on a real operating system. A spin lock is the right choice only for a critical section shorter than the cost of putting a thread to sleep, which is why the standard library does not expose one. The mutex lesson uses the one that does.

A fence

fence orders the operations around it without touching a value. It is the tool for a sequence of relaxed operations that has to be split into a part before and a part after, which happens in a data structure whose counter and data live in different atomics.

The program stores a number, places a sequential fence and reads the number back. The output is the stored value, and the fence sits between the two operations as the barrier it is.

use std::sync::atomic::{fence, AtomicUsize, Ordering}; fn main() { let value = AtomicUsize::new(1); value.store(2, Ordering::Relaxed); fence(Ordering::SeqCst); println!("{}", value.load(Ordering::Relaxed)); }

rustc --edition 2024 fence.rs
./fence

2

A fence costs more than an ordering on a single operation and less than a lock, and most programs never need one. When a program does, it is usually a queue that hands an index to a consumer and fills the slot behind it, and the proof of correctness is written in a comment beside the fence.

The model itself is the C++ one: a data race is undefined behaviour, and every ordering is a statement about which writes another thread is allowed to see. The lesson on atomics uses three of the five orderings, and SeqCst is the one every program can start with.

A mistyped ordering

The orderings are variants of one enum, so a misspelled name is a method-not-found error with a suggestion rather than a silent default. That is worth knowing: there is no default, and every atomic call states its ordering.

The program writes Aquire where Acquire belongs. The compiler lists no matching variant and points at the correct spelling.

use std::sync::atomic::{AtomicUsize, Ordering}; fn main() { let value = AtomicUsize::new(0); println!("{}", value.load(Ordering::Aquire)); }

rustc --edition 2024 typo.rs

error[E0599]: no variant, associated function, or constant named `Aquire` found for enum `std::sync::atomic::Ordering` in the current scope
--> typo.rs:5:41
|
5 | println!("{}", value.load(Ordering::Aquire));
| ^^^^^^ variant, associated function, or constant not found in `std::sync::atomic::Ordering`
|
help: there is a variant with a similar name
|
5 | println!("{}", value.load(Ordering::Acquire));
| +

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0599`.

The five variants are Relaxed, Acquire, Release, AcqRel and SeqCst, and only some of them are valid for a given operation: a load cannot be Release, and a store cannot be Acquire. The next lesson turns to the marker traits that decide which values may be shared at all.

Exercises

  1. Replace the release store in the publishing program with a relaxed one and explain what could go wrong.
  2. Add a second writer to the spin lock program and count the increments.
  3. Write a producer that sends three values through a channel and check that each is visible in order.

Questions

A new set of questions in a new order every time. Choose one answer for each, then check.

  1. What does Ordering::Release on a store promise?

    Answer

    Every write before it is visible to a thread that acquires the value

    Release publishes the earlier writes, and the matching acquire makes them visible.

  2. Which pair orders a flag and the data it announces?

    Answer

    A Release store and an Acquire load

    The store publishes and the load that sees it takes everything published with it.

  3. What does a fence order?

    Answer

    The operations before it against the operations after it

    A fence is a barrier in one thread, with no value attached.

  4. What is a data race in Rust?

    Answer

    Two threads reaching one value without synchronisation, which is undefined behaviour

    Safe Rust prevents it with ownership and the marker traits, and unsafe code must keep the rule.

  5. What does this program print? use std::sync::atomic::{AtomicUsize, Ordering}; fn main() { let value = AtomicUsize::new(1); value.store(7, Ordering::Release); println!("{}", value.load(Ordering::Acquire)); }

    Answer

    7

    The store writes seven and the acquire load reads it back.

  6. What does this program print? use std::sync::atomic::{AtomicBool, Ordering}; fn main() { let flag = AtomicBool::new(false); let previous = flag.swap(true, Ordering::AcqRel); println!("{} {}", previous, flag.load(Ordering::Relaxed)); }

    Answer

    false true

    The swap returns the old value and stores the new one.

  7. What does this program print? use std::sync::atomic::{AtomicUsize, Ordering}; fn main() { let value = AtomicUsize::new(0); value.store(5, Ordering::Relaxed); value.store(9, Ordering::Relaxed); println!("{}", value.load(Ordering::SeqCst)); }

    Answer

    9

    The second store replaces the first, and the load reads the last value.

  8. What does this program print? use std::sync::atomic::{AtomicUsize, Ordering}; fn main() { let counter = AtomicUsize::new(10); let previous = counter.fetch_add(5, Ordering::AcqRel); println!("{} {}", previous, counter.load(Ordering::SeqCst)); }

    Answer

    10 15

    The addition returns the value from before and leaves fifteen in place.

  9. What is the first line of the compiler output for this program? use std::sync::atomic::{AtomicUsize, Ordering}; fn main() { let value = AtomicUsize::new(0); println!("{}", value.load(Ordering::Aquire)); }

    Answer

    error[E0599]: no variant, associated function, or constant named Aquire found for enum std::sync::atomic::Ordering in the current scope

    The ordering is an enum variant, and the name is checked at compile time.

  10. What is the first line of the compiler output for this program? use std::sync::atomic::{AtomicUsize, Ordering}; fn main() { let value = AtomicUsize::new(0); value.store(1, Ordering::Acquire); }

    Answer

    error: atomic stores cannot have Acquire or AcqRel ordering

    A store can only publish, so the compiler rejects an acquiring store outright.

The code on this page was built with rustc 1.96.1 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container.

Article author: Arthur Isaev