61. Memory ordering: Relaxed, Acquire/Release, SeqCst, and a spinlock
CPUs and compilers reorder memory operations. An atomic ordering states which reorderings are not allowed. Rust uses the C++20/C11 model, so the C guide §38 applies one-to-one.
| Ordering | Guarantees | Use for |
|---|---|---|
Relaxed |
atomicity only; no ordering with other memory | counters, statistics |
Release (store) / Acquire (load) |
everything written before the Release store is visible to the thread whose Acquire load reads that store | publishing data, locks |
AcqRel |
both, for read-modify-write operations | compare_exchange in lock-free structures |
SeqCst |
Acquire/Release plus one global order of all SeqCst operations |
when you cannot prove a weaker ordering is enough |
61.1 Publishing data with Release/Acquire
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::thread;
static DATA: AtomicU64 = AtomicU64::new(0);
static READY: AtomicBool = AtomicBool::new(false);
fn main() {
let producer = thread::spawn(|| {
DATA.store(42, Ordering::Relaxed); // (1) write the payload
READY.store(true, Ordering::Release); // (2) publish: (1) cannot move after this
});
let consumer = thread::spawn(|| {
while !READY.load(Ordering::Acquire) { // (3) once this sees true...
std::hint::spin_loop();
}
DATA.load(Ordering::Relaxed) // (4) ...this is guaranteed to see 42
});
producer.join().unwrap();
println!("consumer saw {}", consumer.join().unwrap());
}
With Relaxed on both READY operations, a weakly ordered CPU such as an
ARM server or an Apple Silicon Mac may legally let the consumer see READY == true and DATA == 0. On x86 the hardware hides most of these
reorderings, so bugs like this can pass every test on a developer laptop
and then fail on Graviton. Test lock-free code with
loom, which explores the possible
interleavings and orderings systematically.
61.2 A spinlock with the right orderings
use std::cell::UnsafeCell;
use std::ops::{Deref, DerefMut};
use std::sync::atomic::{AtomicBool, Ordering};
pub struct SpinLock<T> { locked: AtomicBool, value: UnsafeCell<T> }
// SAFETY: the lock gives one thread at a time access to `value`, so sharing &SpinLock across
// threads is sound whenever T itself may be sent between threads.
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 on success: we must see every write made by the previous holder before its Release.
while self.locked.compare_exchange_weak(false, true, Ordering::Acquire, Ordering::Relaxed).is_err() {
while self.locked.load(Ordering::Relaxed) { std::hint::spin_loop(); } // spin on reads, not on CAS
}
Guard { lock: self }
}
}
impl<T> Deref for Guard<'_, T> {
type Target = T;
// SAFETY: holding the Guard means we hold the lock: no other reference to `value` exists.
fn deref(&self) -> &T { unsafe { &*self.lock.value.get() } }
}
impl<T> DerefMut for Guard<'_, T> {
fn deref_mut(&mut self) -> &mut T { unsafe { &mut *self.lock.value.get() } }
}
impl<T> Drop for Guard<'_, T> {
fn drop(&mut self) { self.lock.locked.store(false, Ordering::Release); } // publish our writes
}
fn main() {
static COUNTER: SpinLock<u64> = SpinLock::new(0);
std::thread::scope(|s| {
for _ in 0..8 {
s.spawn(|| for _ in 0..100_000 { *COUNTER.lock() += 1; });
}
});
println!("{}", *COUNTER.lock()); // 800000, every time
}
The guard is what makes this a Rust API rather than a C API. You cannot
reach the data without locking, and you cannot forget to unlock. Do not use
spinlocks in user space in practice. A preempted lock holder makes every
waiter burn a full time slice. std::sync::Mutex spins briefly and then
sleeps on a futex, which is what you want
(OS Ch 18).
Spinlocks belong in kernels and interrupt handlers
(C §52).