RFA-161 · Case file with fixtures · Case 133 of 694 · Compiler evidence
Why compare_exchange Failure Ordering Cannot Be Release
compare_exchange has two memory-ordering contracts because success performs a read-modify-write while failure performs only a load. The failure path can be Relaxed, Acquire, or SeqCst, never Release or AcqRel.
- Reviewed
- Rust
- Rust 1.98.1, edition 2024
- Targets
- targets supporting AtomicUsize
- Profiles
- check, dev, release, test
Direct answer
What this Rust failure means
- Why it happens
- A failed comparison performs only a load and no write, so its ordering can be Relaxed, Acquire, or SeqCst but cannot carry Release semantics.
- First discriminating check
- Separate the success read-modify-write path from the failure load-only path and justify each ordering independently.
compare_exchange asks for two orderings because it can perform two different operations. Treating them as “strong ordering” and “fallback ordering” hides the important distinction.
The failing program calls an atomic compare-exchange with AcqRel on success and Release on failure. Rust 1.98.1 rejects the second argument:
failure ordering may not be `Release` or `AcqRel`,
since a failed `compare_exchange` does not result in a write
That last clause contains the complete mechanical reason.
Success and failure are different atomic shapes
compare_exchange reads the current atomic value and compares it with an expected value.
On success, it writes the replacement. This path is a read-modify-write operation:
read current -> comparison succeeds -> write new
On failure, it returns the observed current value without changing the atomic:
read current -> comparison fails -> no write
The success ordering can describe both read and write effects. The failure ordering describes only a load. Release is a write-side ordering, so it has nothing to attach to on failure.
Valid failure orderings are load orderings
The standard documentation limits failure ordering to:
Relaxed;Acquire;SeqCst.
These are the same categories available to an atomic load. The related Atlas case, Why an atomic load cannot use Release, explains the direction directly.
The repaired program uses Acquire for failure. The comparison deliberately fails and returns Err(1), verifying the load-only path.
This makes the code compile, but Acquire is correct only if the value observed on failure must synchronize with a compatible release publication. If the caller merely retries and uses no protected data, Relaxed may be enough.
Failure is often part of the algorithm
It is tempting to treat compare-exchange failure like an exceptional case. In lock-free loops, failure is ordinary contention. The returned value becomes the next expected value or informs another branch.
For example:
let mut current = counter.load(Ordering::Relaxed);
loop {
let next = current + 1;
match counter.compare_exchange_weak(
current,
next,
Ordering::AcqRel,
Ordering::Acquire,
) {
Ok(_) => break,
Err(observed) => current = observed,
}
}
The failure ordering controls what can be assumed after reading observed. It is not an ordering applied to a write that did not happen.
I reason about that returned value like any other atomic load result.
Success ordering also splits into read and write parts
The Ordering documentation notes an important detail for read-modify-write operations:
- success
Acquiremakes the store part Relaxed; - success
Releasemakes the load part Relaxed; - success
AcqRelsupplies both directions; - success
SeqCstadds the strongest global-order contract.
So even the success argument is not one undivided strength. I choose it based on whether the operation must receive previous publication, publish earlier writes, or both.
This connects to release sequences, the subject of "Release Sequences in Rust: The Rule Behind a Surprising Acquire Load", where the load and store sides of an atomic update matter separately.
A stronger failure ordering can be invalid relative to success
Beyond excluding Release and AcqRel, the failure ordering cannot demand a stronger memory guarantee than the success ordering permits. Rust's API documentation defines the allowed combinations.
I avoid memorizing only a matrix. I write two sentences:
If replacement succeeds, this operation must ...
If comparison fails and observes another value, this thread must ...
Those statements produce the pair. If I cannot finish them, choosing SeqCst for both may avoid one compiler error but does not provide an understandable algorithm.
Do not hide the pair behind vague parameters
A helper that accepts arbitrary success: Ordering and failure: Ordering pushes an expert invariant onto every caller. For a domain operation, I prefer a function whose implementation owns the chosen pair.
If configurability is truly needed, I validate supported combinations or use a smaller enum representing meaningful strategies. That makes impossible combinations unrepresentable before they reach the atomic method.
I also keep orderings close to the data-invariant comment. A future reviewer needs to know which ordinary writes are published and which observed state is acquired.
My failure-path review
For each compare-exchange, I check:
- What exact operation occurs on success?
- What exact operation occurs on failure?
- Is the failure value only compared or used to read other memory?
- Which release operation, if any, does an Acquire failure observe?
- Is failure a retry, a state transition, or a terminal result?
- Are success and failure both exercised in tests?
A test that always succeeds never checks the second ordering's application logic. The fixture deliberately starts from the wrong expected value so failure is guaranteed.
The core principle is outcome-specific reasoning. A successful compare-exchange reads and writes; a failed one only reads. Release cannot describe the failed path because that path publishes nothing. Once I model both operations separately, the two ordering arguments stop looking redundant and the compiler restriction becomes inevitable.