I am building some concurrent Rust code and trying to optimize a few small pieces of shared state using atomics such as AtomicBool.
One thing I am still struggling to understand is when Rust code actually needs memory orderings stronger than Ordering::Relaxed.
In C++, I can understand why this matters more directly. But in Rust, we have the borrow checker, and if ordinary memory is being mutated in one thread, another thread cannot safely read the same memory at the same time without some synchronization primitive.
Usually, this synchronization is done through Mutex, RwLock, channels, etc. My understanding is that those primitives already provide the necessary memory ordering through lock/unlock behavior, so adding stronger ordering to a separate atomic flag can often be redundant.
So my question is:
Can someone show a realistic Rust code example where using Ordering::Relaxed would be incorrect, and where Acquire/Release or AcqRel is actually necessary?
I am especially interested in examples where the code is still valid Rust, but the memory ordering is wrong and can lead to race conditions or other concurrency bugs.
One additional thing I realized while thinking about this: Arc::clone can be called concurrently from multiple threads. If I use Ordering::Relaxed on my own atomic flag near Arc::clone, could reordering around Arc::clone cause a race condition? My current guess is “probably not,” because Arc handles its own internal reference-counting correctly, and to mutate internal state, I would still need to go through Mutex, RwLock, or another synchronization primitive. But I would like to understand this better.
Relaxed is insufficient when the atomic variable is not standalone, but used as some form of signal/flag to coordinate the access of some other shared data.
the data and flag can be unrelated (in the sense that there are no data dependencies between them), so the compiler cannot deduce the required order automatically, and the borrow checker is irrelevant in this case.
that's correct, the high level synchronization primitives already provide the required order. manual atomic operations is only needed when you are not using the high level wrappers, or when you are implementing your own high level abstractions.
the canonical example is using a boolean flag to signal the (oneshot) readiness of data in a shared buffer. the consumer must load the flag with Acquire order, and producer must store the flag with Release. any weaker order will cause a bug. it might be a logic error or language UB, depending how the shared buffer is implemented.
let buffer = AtomicI32::new(0);
let ready = AtomicBool::new(false);
thread::scope(|s| {
// procuder thread:
// computes the data and write it to the shared buffer
// then set the ready flag using `Release`
// `SeqCst` is correct but unnecessary for a store-only operation
// all other ordering are incorrect
s.spawn(|| {
let answer = 42;
buffer.store(answer, Relaxed);
ready.store(true, Release);
});
// consumer thread:
// wait for the ready flag to be set, then load the data from the shared buffer
// the flag is loaded using `Acquire`
// weaker ordering is incorrect, and can trigger an assertion failure
while !ready.load(Acquire) {
hint::spin_loop();
}
let answer = buffer.load(Relaxed);
assert_eq!(answer, 42);
});
in this example, if you use the wrong memory order, you get a logic error because the shared "buffer" is just an atomic integer with Relaxed access, and is initialized. if the buffer cannot be pre-initialized, you'll have to use MaybeUnint and unsafe, then the program will have language UB in your unsafe code if the memory order is incorrect.
If you have multiple different atomic variables, and use one atomic bool e.g. to signal that other atomics are now in an updated state or so, then you’d need more than just Relaxed to ensure the updates are actually visible once the bool signals so.
Other than that, sure, it might seem to matter more in e.g. C++ – though also Rust can have the same kind of use-cases once unsafe is involved.[1]
E.g. this is also relevant in the standard library internally, e.g. if you look at how Arc is implemented, there some places where Acquire/Release ordering is necessary for various purposes, including e.g. to make that when the contents are dropped, and/or the memory is to be dellocated, that truly happens after the last read was possible; also that mutable access through API like Arc::get_mut or Arc::make_mut is correctly synchronized; and to over-complicate things, there’s even interaction between several atomics at play there as well. ↩︎
Ok, that makes sense now. The issue appears when multiple atomics are used independently to coordinate state.
The example you shared is best practice and correct. However, if we change:
ready.store(true, Release);
to:
ready.store(true, Relaxed);
then there is no guarantee that the preceding buffer.store(...) operations become visible before ready.store(...). As a result, another thread could observe ready == true while still reading stale values from buffer.
Similarly, if we change:
ready.load(Acquire)
to:
ready.load(Relaxed)
then the synchronization is also lost. Even if the thread observes ready == true, there is no guarantee that it will see the writes to buffer that happened before the Release store.