5.2.26 · D5C++ Programming

Question bank — std - atomic — lock-free operations

2,071 words9 min readBack to topic

Picture 1 — the race window (why plain x++ loses counts)

The figure below is a timeline: time flows left to right, one row per thread. Watch the red overlap band — that is the "race window" where both threads have loaded the same value before either stores.

Figure — std - atomic — lock-free operations

Picture 2 — acquire/release as a one-way gate

This figure shows why memory order matters. Each thread has an ordered stack of operations; the arrows show which reorderings the compiler/CPU is allowed to do (green = allowed to slide, red = blocked by the gate).

Figure — std - atomic — lock-free operations

True or false — justify

Marking a variable std::atomic<int> makes the statement a = a + 1; a single atomic step.
False. a + 1 is an atomic load, then a = ... is a separate atomic store — two operations with a gap between them where another thread can slip in (exactly the race window in Picture 1). Use a.fetch_add(1) or ++a for one indivisible read-modify-write.
std::atomic always avoids taking a lock.
False. Lock-free hardware instructions only exist for operand sizes the CPU can handle in one shot (typically up to a pointer/word, sometimes a double-word via CMPXCHG16B). A std::atomic<BigStruct> of, say, 40 bytes has no single instruction that can swap it atomically, so the library hides an internal mutex. Only types where is_always_lock_free is true (or is_lock_free() returns true at runtime) are guaranteed lock-free.
A lock-free algorithm guarantees every thread makes progress.
False. Lock-free guarantees some thread makes progress system-wide; an individual thread can be starved, its CAS failing every time because faster threads keep changing the value. The stronger property where every thread finishes in bounded steps is called wait-free.
Under heavy contention a lock-free counter always beats a mutex-protected one.
False. A CAS loop can spin many times when many threads collide, and each retry re-fetches a cache line that is bouncing between cores — burning CPU. At high contention a mutex (which parks waiters instead of spinning) may actually win — always measure.
memory_order_relaxed weakens the atomicity of an operation.
False. Relaxed keeps the operation fully atomic (no torn or lost values); it only drops the ordering guarantees relative to other memory locations. Counting is still correct.
The default memory order for atomic operations is relaxed.
False. The default is memory_order_seq_cst (sequentially consistent) — the safest and slowest. You must opt in to relaxed.
compare_exchange_weak is buggy because it can fail even when the values match.
False. Its spurious failure is intentional: on some CPUs (like ARM's load-linked/store-conditional pair) the store-conditional can fail if the cache line was merely touched, and forcing a re-check would cost extra. Allowing spurious failure lets the compiler emit the cheap single attempt, which is why weak is meant to live inside a retry loop.
A release store on its own synchronizes two threads.
False. Synchronization needs a pair: a release store that publishes and a matching acquire load on another thread that reads the same value (the diagonal edge in Picture 2). One half alone creates no happens-before edge.
memory_order_consume is just a cheaper synonym for acquire.
False. consume orders only operations that are data-dependent on the loaded value (e.g. dereferencing a pointer you just loaded), not all subsequent reads/writes like acquire does. It was meant for pointer-chasing on weakly-ordered CPUs, but it is so hard to specify correctly that current compilers promote it to acquire, so in practice it gives you no speedup today.
Two threads doing counter++ on a plain int will always give the correct total, just slowly.
False. Plain int increments can interleave in their load–add–store steps (Picture 1), so updates are silently lost — the final total is often less than expected, not just late. It is also formally undefined behaviour.
is_lock_free() and is_always_lock_free mean the same thing.
False (subtly). is_always_lock_free is a compile-time constant guaranteed for that type on that platform; is_lock_free() is a runtime check on a specific object, which can matter for over-aligned or unusual instances.

Spot the error

std::atomic<int> a{0};
a = a + 1;   // "atomic increment"

::: The comment lies. This is a load of a plus a separate store into a, with a race window between them (Picture 1). Replace with a.fetch_add(1) or ++a.

std::atomic<bool> ready{false};
int data = 0;
// producer
ready.store(true, std::memory_order_relaxed);
data = 42;

::: Two bugs: the publish uses relaxed (no ordering, so the consumer may see ready==true before data==42), and data = 42 is written after the flag. The flag must be a release store placed after the data write.

Node* n = new Node{v, head.load()};
head.compare_exchange_weak(n->next, n);   // no loop

::: compare_exchange_weak can fail spuriously and fail on real contention, so a single call may silently not push. It must be wrapped in while(!head.compare_exchange_weak(n->next, n)){}.

std::atomic<int> a{0};
if (a.compare_exchange_strong(expected, 5)) { /* ... */ }
// expected was never initialized

::: expected must be seeded with the value you believe a holds before the call. Uninitialized, the compare is meaningless; and on failure CAS overwrites expected with the current value of a, which you then must use.

std::atomic<int> x{0}, y{0};
// thread A
x.fetch_add(1, std::memory_order_relaxed);
y.fetch_add(1, std::memory_order_relaxed);
// thread B expects: if it sees y==1 then x==1

::: With relaxed, there is no ordering between the two atomics across threads — B can observe y==1 while still seeing x==0. If B relies on that ordering, use release/acquire (or seq_cst).


Why questions

Why does the LOCK prefix (e.g. LOCK XADD) make an increment atomic without any software lock?
It tells the cache-coherency protocol to hold exclusive ownership of the cache line for the whole load-add-store, so no other core can read or write it mid-instruction — the hardware itself supplies the indivisibility.
Why is a single CAS instruction enough to build any read-modify-write?
Because you can express any RMW as: read the old value, compute the new one, and CAS only if nobody changed it meanwhile; on failure you re-read and recompute. This "optimistic" retry loop turns CAS into a universal primitive.
Why is relaxed the right choice for a pure statistics counter?
You only care that the total is correct, not how it orders relative to other variables. Atomicity (no lost updates) is preserved under relaxed; you merely drop the expensive cross-variable ordering you don't need.
Why must the consumer's flag-read be an acquire load?
An acquire load forbids later reads/writes from being reordered before it (the ceiling in Picture 2), so once it observes the published value it is guaranteed to also see everything the producer wrote before its release store.
Why can a release store never let earlier writes "leak" after it?
A release store is a one-way gate: no read or write that appears before it in program order may be moved after it (the floor in Picture 2). That is exactly what lets it "seal the package" of prior writes before publishing.
Why was memory_order_consume intended to be faster than acquire on some hardware?
On weakly-ordered CPUs, a full acquire needs a fence to order all following operations, but consume only needs to respect the natural data-dependency chain (the CPU already won't reorder a pointer dereference before the load that produced the pointer). That fence-free path is the intended win — undelivered in practice because compilers currently upgrade consume to acquire.
Why does compare_exchange update expected on failure instead of leaving it alone?
So your retry loop doesn't need a separate reload — the current value is handed straight back to you in expected, ready to recompute the desired value from.
Why might a lock-free stack still suffer performance problems even though it never blocks?
Contention causes CAS retries, and threads hammering the same head pointer bounce that cache line between cores — this cache-line ping-pong (a cousin of False Sharing) can dominate cost.

Edge cases

What happens if two threads call push on the CAS-loop stack at the exact same moment?
One thread's compare_exchange succeeds; the other's fails because head changed, so n->next is refreshed to the new head and it retries. Both pushes eventually land — no update is lost.
In a while(!head.compare_exchange_weak(n->next, n)){} push loop, what if the CPU makes compare_exchange_weak fail spuriously on the very first try even though nothing changed?
Harmless. The loop simply retries; n->next is refreshed to the (unchanged) head and the next attempt succeeds. This is precisely why the weak form belongs inside a loop — the loop absorbs spurious failures for free.
Does std::atomic<double> support fetch_add?
On C++20 yes (floating-point atomics were added); before C++20, fetch_add was not provided for floating types, so you had to emulate it with a CAS loop.
Can is_lock_free() return false for std::atomic<int> on a real platform?
Almost never on mainstream CPUs — word-sized integers are lock-free everywhere modern. But the standard permits it (imagine a tiny CPU without an atomic add instruction), so portable code should not assume it; check when it matters.
What if expected in compare_exchange_strong happens to equal a but a was briefly changed to another value and back?
CAS sees only the current value, not history — it succeeds because the value matches now. This is the classic ABA problem; guarding against it needs tagged pointers or hazard pointers.
What ordering do you get if you call a.load() with no memory-order argument?
You get memory_order_seq_cst — the strongest, participating in a single global total order. Correct but potentially slower than acquire if that's all you needed.
Is ++a (member operator) on std::atomic<int> the same as a = a + 1?
No. ++a is a single atomic fetch_add; a = a + 1 is a separate atomic load and atomic store with a race window between them. Prefer the operator or fetch_add.

Recall One-line self-test before you close the page

Name the one true difference between "atomic" and "ordered". ::: Atomic = the operation is indivisible (no torn/lost value); ordered = how other memory writes become visible around it. Relaxed keeps the first, drops the second.


See also: Mutex and Lock · std::thread and std::async · Memory Model (C++) · Compare-And-Swap · False Sharing