Atomics, CAS Loops, Memory Ordering & Thread-Safe Singleton
In modern C++, we never guess what the compiler does. We look directly at byte offsets, CPU registers, and object ownership lifetimes.
On architectures with Load-Linked / Store-Conditional (LL/SC) instructions (such as ARM, PowerPC, and RISC-V), compare_exchange_weak maps directly to a single LL/SC instruction pair. It is permitted to fail spuriously due to interrupts or cache line invalidations. compare_exchange_strong, by contrast, must generate an inner loop on ARM to mask spurious failures and retry until value mismatch occurs. Because a lock-free CAS loop already retries on failure, using compare_exchange_weak avoids redundant checks and produces leaner, faster machine code.
A store operation on an atomic object with std::memory_order_release synchronizes-with a load operation on that same atomic object with std::memory_order_acquire that observes the stored value. This establishes a transitive memory barrier: all memory writes (both atomic and non-atomic) performed by the storing thread prior to the release store are guaranteed to be visible to the loading thread after the acquire load.
In C++98, the language lacked a multithreaded memory model. In instance = new Singleton(), the compiler or CPU could reorder the pointer assignment before the constructor finished, allowing concurrent threads checking if (instance != nullptr) to access partially constructed memory. In C++11, Section Β§6.7 guarantees that if control enters the declaration of a block-scope static variable concurrently, execution stalls until initialization completes, turning Meyers' Singleton into a language-guaranteed thread-safe construct.
Interviewer's lens
Atomics and memory ordering are the ultimate litmus test for senior systems and low-latency C++ engineers.
What separates candidates:
- Junior instinct: Assumes
std::atomicis just a 'lightweight mutex' or that addingvolatileprevents data races (it does not in C++). - Senior mastery: Understands the mechanical reality of modern hardwareβhow out-of-order CPUs reorder instructions, why classic Double-Checked Locking (DCLP) leaked half-built objects, how Acquire-Release semantics establish a synchronizes-with boundary across CPU cores without expensive bus-locking barriers, and how to write rock-solid lock-free CAS loops with
compare_exchange_weak.
Under the hood & memory layout
Think of multicore CPUs as chefs working in separate kitchens who share a central storehouse (RAM). Each chef works with a private counter (L1/L2 cache) and a pad of sticky notes for outbound deliveries (the per-core Store Buffer). Without explicit synchronization, writes stay on the pad, and chefs work out of order.
Mechanically, atomics and memory barriers enforce consistency in 3 ways:
- Hardware-Locked Instructions (Atomic RMW): A plain
++countertakes 3 machine steps: load into register, add 1, write back. Interleaved executions clobber updates. Wrapping instd::atomicemits hardware bus-locking instructions (e.g.LOCK XADDon x86, or exclusiveLDAXR/STLXRon ARM) that lock the cache line across all cores during the read-modify-write. - Memory Fences (Store Buffer Drains): Full sequential consistency (
seq_cst) stalls CPU pipelines with heavyweight barriers (MFENCE). With Acquire-Release, the producer'sreleaseflushes pending writes from its store buffer into cache before setting the flag, and the consumer'sacquireensures it never reads payload data before reading the flag. - Atomic CAS (State-Machine Transitions):
compare_exchange_weakatomically checks: "Does the memory location still hold my expected value? If yes, swap it with my desired value. If no, overwrite my expectation with the current value so I can recalculate and retry."
For fundamental types (int, bool, uint64_t, pointers), sizeof(std::atomic<T>) == sizeof(T) (typically 4 or 8 bytes) with natural alignment matching alignof(T). The compiler guarantees that naturally aligned loads/stores are atomic at the bus level without extra bytes or hidden mutex allocations when is_lock_free() returns true.
On modern x86/ARM CPUs, stores are initially buffered in a per-core FIFO store buffer before committing to L1 cache. Relaxed stores do not drain this buffer; memory_order_release ensures all preceding stores commit to cache before the atomic flag is marked visible, and memory_order_seq_cst flushes store buffers globally across all cores.
L1/L2 caches track memory in 64-byte chunks (cache lines). If two independent std::atomic<int> variables reside within the same 64-byte block, two threads writing to them bounce the cache line back and forth via the MESI protocol (Invalidate -> Read For Ownership), degrading throughput by up to 50x unless aligned with alignas(hardware_destructive_interference_size).
In p = new Singleton(), the compiler and CPU are permitted to reorder operations into: (1) allocate raw heap bytes, (2) assign raw address to pointer p, (3) execute constructor fields. A concurrent thread checking if (p != nullptr) observes non-null p and dereferences uninitialized object state, causing immediate memory corruption.
Core concepts & syntax traps
Why ++counter on a Plain int Is a 3-Step Read-Modify-Write Data Race
Why ++counter on a Plain int Is a 3-Step Read-Modify-Write Data Race
Even though ++counter looks like a single line of C++, modern hardware executes it as three separate machine steps:
- Load (Read): The CPU core reads the value of
counterfrom its L1/L2 cache line into an internal general-purpose register (EAX/W0). - Modify: The ALU increments the register value by 1 (
add eax, 1). - Store (Write): The CPU writes the updated register value back to the cache line.
The Interleaving Disaster
If Thread A and Thread B both read counter == 10 concurrently:
- Thread A increments its register to
11. - Thread B increments its register to
11. - Both write
11back to memory. Two increments happened, but the counter only advanced by 1!
This is a classic Read-Modify-Write (RMW) data race, invoking Undefined Behavior (UB) under ISO C++.
The Hardware Fix: Wrapping the integer in std::atomic<int> instructs the CPU to emit a hardware-locked atomic instruction (such as LOCK XADD on x86). The CPU locks the cache line across all cores during the read, increment, and write, preventing clobbered updates.
// DATA RACE (Undefined Behavior):
int rawCounter = 0;
// Thread 1: ++rawCounter; // mov eax, [rawCounter]; add eax, 1; mov [rawCounter], eax;
// Thread 2: ++rawCounter; // Interleaved execution clobbers updates!
// THREAD-SAFE (Hardware Atomic RMW):
std::atomic<int> atomicCounter{0};
++atomicCounter; // Compiles to: 'lock xadd [atomicCounter], eax' on x86
atomicCounter.fetch_add(1, std::memory_order_relaxed); // Explicit atomic RMWNever use plain scalar types for concurrent counters; std::atomic emits hardware-locked instructions preventing lost updates.
Compare-And-Swap (CAS) Loops: compare_exchange_weak vs compare_exchange_strong
Compare-And-Swap (CAS) Loops: compare_exchange_weak vs compare_exchange_strong
Atomic variables provide built-in operations for addition and subtraction (fetch_add, fetch_sub). But what if you need an atomic fetch_max or need to update the head pointer of a lock-free stack?
The universal building block for custom atomic transitions is Compare-And-Swap (CAS).
How CAS Works Mechanically
atomic.compare_exchange_weak(expected, desired) performs a single atomic check:
- Match: Does
atomicequalexpected? If yes, replaceatomicwithdesiredand returntrue. - Mismatch: If another thread modified
atomicin the meantime, leaveatomicuntouched, overwriteexpectedwith the new actual value, and returnfalse.
Weak vs Strong CAS in C++
compare_exchange_weak(For Retry Loops): May fail spuriously (returnfalseeven if values matched) on architectures like ARM (Load-Linked / Store-Conditional) due to cache line invalidations or interrupts. Because it runs inside awhileloop that will retry anyway,weakcompiles to leaner, faster machine code.compare_exchange_strong(For Single Checks): Guarantees failure occurs only when the actual value does not matchexpected. Use this when CAS is executed once without a retry loop.
bool atomicUpdateMax(std::atomic<int>& maxVal, int newVal) noexcept {
int current = maxVal.load(std::memory_order_relaxed);
// CAS Loop: retry as long as newVal exceeds current and CAS failed
while (newVal > current &&
!maxVal.compare_exchange_weak(current, newVal,
std::memory_order_release,
std::memory_order_relaxed)) {
// On failure, 'current' is updated with the latest maxVal value automatically!
}
return newVal > current;
}Always use compare_exchange_weak inside a retry loop; use compare_exchange_strong only when performing a single non-looping CAS check.
Memory Ordering Decoded: relaxed, acquire/release, and seq_cst
Memory Ordering Decoded: relaxed, acquire/release, and seq_cst
By default, every operation on std::atomic uses std::memory_order_seq_cst (Sequentially Consistent). While safe, it imposes a single global timeline across all cores, requiring pipeline-stalling hardware memory barriers (MFENCE).
Modern C++ defines 3 practical memory models:
-
Relaxed (
memory_order_relaxed):- Guarantees atomicity of that single variable only.
- Imposes zero ordering constraints with other variables. Fastest possible atomic operation; ideal for standalone counters and metrics.
-
Acquire-Release (
memory_order_acquire/release):- The Producer-Consumer Boundary:
- Release Store: "Ensure all my prior memory writes (even plain non-atomic data) commit before marking this flag visible."
- Acquire Load: "Do not read any payload data until after reading this flag as ready."
- An acquire load observing a release store forms a synchronizes-with boundary. All preceding writes become safely visible to the consumer without a mutex.
- The Producer-Consumer Boundary:
-
Sequential Consistency (
memory_order_seq_cst):- All cores observe all
seq_cstoperations in the exact same global order. Safest default, but carries the highest hardware overhead.
- All cores observe all
// Thread A (Producer):
std::string payload = "Ready payload";
readyFlag.store(true, std::memory_order_release); // All prior writes flushed!
// Thread B (Consumer):
while (!readyFlag.load(std::memory_order_acquire)) { // Synchronizes-with Thread A
std::this_thread::yield();
}
std::println("{}", payload); // 100% safe to read payload without mutex!Use memory_order_relaxed for standalone counters; use acquire-release for message passing and publishing state across threads.
Why Classic Double-Checked Locking Pattern (DCLP) Was Broken
Why Classic Double-Checked Locking Pattern (DCLP) Was Broken
In early C++, developers tried to optimize singleton access using Double-Checked Locking (DCLP) to avoid acquiring a mutex on every read:
// BROKEN CLASSIC DCLP:
if (instance == nullptr) { // 1. Check without lock
std::lock_guard<std::mutex> lock(mtx);
if (instance == nullptr) { // 2. Check with lock
instance = new Singleton(); // 3. HAZARD: Memory corruption!
}
}
The Mechanical Reordering Trap
The statement instance = new Singleton() involves three distinct machine operations:
- Allocate Memory:
void* rawMem = operator new(sizeof(Singleton)) - Construct Object:
new (rawMem) Singleton() - Assign Pointer:
instance = static_cast<Singleton*>(rawMem)
The Hazard: Modern compilers and out-of-order CPUs can reorder step 3 before step 2! instance becomes non-null in memory before the constructor finishes running.
- A concurrent Thread B arrives at the first check
if (instance == nullptr). - It observes a non-null pointer, skips the lock, and returns
instance. - Thread B immediately accesses a half-constructed object, causing memory corruption and segmentation faults!
// FIX using std::atomic with acquire-release (if manual DCLP is required):
std::atomic<Singleton*> instancePtr{nullptr};
std::mutex mtx;
Singleton* getDclpInstance() {
Singleton* p = instancePtr.load(std::memory_order_acquire); // Acquire load
if (!p) {
std::lock_guard<std::mutex> lock(mtx);
p = instancePtr.load(std::memory_order_relaxed);
if (!p) {
p = new Singleton();
instancePtr.store(p, std::memory_order_release); // Release store
}
}
return p;
}Never write raw pointer DCLP; reordered instructions expose half-constructed objects to concurrent readers.
The 2 Modern Thread-Safe Singleton Idioms in C++
The 2 Modern Thread-Safe Singleton Idioms in C++
Modern C++ completely eliminates the need for manual DCLP with two language-guaranteed idioms:
-
Idiom 1: Meyers' Singleton (C++11 Magic Statics - Recommended):
- Declare a
static ClassName instance;inside a function. - Under Section Β§6.7 of the ISO C++ standard, block-scope static variables are guaranteed thread-safe by the compiler and runtime.
- The compiler automatically injects an atomic guard byte check. The first thread to enter acquires the initialization lock; other threads wait. Subsequent calls compile to a single relaxed read of the guard byte (~0.5 ns).
- Declare a
-
Idiom 2:
std::call_oncewithstd::once_flag:- Used when initialization requires runtime arguments or custom dynamic heap allocation.
- Guarantees the initialization lambda executes exactly once across all threads, cleanly propagating exceptions if initialization fails.
// Idiom 1: Meyers' Singleton (Recommended for 99% of C++ use cases):
class MeyersSingleton {
MeyersSingleton() = default;
public:
MeyersSingleton(const MeyersSingleton&) = delete;
MeyersSingleton& operator=(const MeyersSingleton&) = delete;
static MeyersSingleton& instance() noexcept {
static MeyersSingleton inst; // Thread-safe since C++11!
return inst;
}
};
// Idiom 2: std::call_once + std::once_flag:
class CallOnceSingleton {
static inline std::once_flag flag_;
static inline CallOnceSingleton* inst_{nullptr};
CallOnceSingleton() = default;
public:
static CallOnceSingleton& instance() {
std::call_once(flag_, []() { inst_ = new CallOnceSingleton(); });
return *inst_;
}
};Prefer Meyers' Singleton (static local) for modern C++; it is zero-boilerplate, crash-proof, and guaranteed thread-safe by the language specification.
Unlock Live Implementation & Interview Scenarios
The C++23 production code, live coding steps, scenario twist solutions, and common UB traps are part of Brute2Best Pro. Module 1 is completely free.
First anchor question per pattern, Part 1 LLD & Module 1 C++ basics are 100% Free forever.