Free lesson · Concurrency Interview Mastery
std::atomic and Memory Orders
This is the lesson where C++ shows off something no other language in this course can match: explicit, fine-grained control over memory ordering. std::atomic gives you lock-free operations on shared variables, and its memory-order parameters let you choose exactly how much ordering guarantee you pay for - from the strongest and most intuitive down to the fastest and most dangerous. This is genuinely advanced material; it's also exactly the kind of thing that separates a senior C++ engineer from a competent one in an interview. We'll build it up carefully.
A caution before we start: most code should use mutexes and default atomics. The relaxed memory orderings we'll cover are a power tool that has humbled experts, including ones far more experienced in lock-free programming than most engineers will ever be. Learn them to understand the model and to answer interview questions with depth - but reach for them in production only with profiling data and serious care.
std::atomic basics
std::atomic<T> wraps a value so that operations on it are atomic - indivisible, with no data race even when accessed concurrently from multiple threads. This is the C++ fix for the counter++ race: an atomic increment can't be split.
#include <atomic>
std::atomic<int> counter{0};
void increment() {
counter++; // atomic - no race, no lock
counter.fetch_add(1); // explicit atomic add, equivalent
}
The core atomic operations:
load()- atomically read the value.store(x)- atomically write a value.exchange(x)- atomically set toxand return the old value.fetch_add(n)/fetch_sub(n)- atomically add/subtract, return the previous value.compare_exchange_weak/strong(expected, desired)- the heart of lock-free programming: atomically, if the value equalsexpected, set it todesired; otherwise updateexpectedwith the actual value. This is compare-and-swap (CAS), which gets a full treatment in the lock-free module.
std::atomic<int> value{5};
int expected = 5;
// atomically: if value==5, set to 10; else load actual into 'expected'
bool success = value.compare_exchange_strong(expected, 10);
You can also check counter.is_lock_free() to see whether the atomic is implemented without locks on your platform (most small types are genuinely lock-free; large types may fall back to an internal lock).
Memory orders: the distinctive part
Here's where C++ becomes unique. Every atomic operation accepts an optional memory order argument that specifies how strongly it orders surrounding non-atomic operations across threads. The spectrum runs from strongest (safest, slowest) to weakest (fastest, hardest to reason about).
flowchart LR
SC["seq_cst<br/>strongest · default<br/>single global order"] --> AR["acquire / release<br/>pairwise ordering<br/>the common lock-free tool"]
AR --> RLX["relaxed<br/>atomicity only<br/>NO ordering guarantee"]
SC -.->|slower, simpler to reason| AR
RLX -.->|faster, very hard to reason| AR
This diagram shows the spectrum of C++ memory orders from strongest to weakest. On the strong end is sequential consistency, seq cst, which is the default and provides a single global order of operations - it's the simplest to reason about but the slowest. In the middle is the acquire-release pair, which provides ordering between a release in one thread and an acquire in another - this is the common tool for lock-free code. On the weak end is relaxed, which guarantees only atomicity with no ordering guarantee at all - it's the fastest but extremely hard to reason about correctly. Moving toward stronger orders is slower but simpler; moving toward weaker is faster but much harder to reason about.
Let's take them in turn.
Sequential consistency (seq_cst) - the default
memory_order_seq_cst is the default if you specify no order. It guarantees a single, total order of all sequentially-consistent atomic operations that every thread agrees on. This matches the naive mental model: things happen in a consistent global order. It's the easiest to reason about and the safest, at the cost of being the most expensive (it may require the strongest CPU barriers). When in doubt, use the default seq_cst.
counter.store(1); // seq_cst (default)
counter.store(1, std::memory_order_seq_cst); // explicit, same thing
Acquire-release - the workhorse of lock-free code
The acquire-release pair is the most important non-default ordering, because it expresses exactly the publish/consume relationship most lock-free code needs, at lower cost than seq_cst. The pattern:
- A release store (
memory_order_release) ensures all writes before it in this thread are visible to another thread that performs an acquire load (memory_order_acquire) of the same atomic and sees the released value.
This is precisely the "publish data, then set a flag" idiom from the Java memory-model lesson, made explicit. Write your data, then release-store a ready flag; a reader that acquire-loads the flag and sees it set is guaranteed to see the data.
std::atomic<bool> ready{false};
int data = 0;
void producer() {
data = 42; // 1. write data
ready.store(true, std::memory_order_release); // 2. release: publishes the write above
}
void consumer() {
while (!ready.load(std::memory_order_acquire)) // acquire: if we see true, we see data
;
std::cout << data; // guaranteed to read 42, no UB
}
The acquire-release pair establishes a happens-before edge between the producer's writes and the consumer's reads, only through that one atomic - cheaper than the global ordering seq_cst imposes, while still correct for this publish/consume pattern. This is the bread-and-butter ordering for hand-written lock-free structures.
Relaxed - atomicity without ordering
memory_order_relaxed guarantees only that the operation itself is atomic - no ordering relationship with any other memory operation. It's the fastest and by far the hardest to use correctly. It's appropriate when you need an atomic operation but genuinely don't care about ordering relative to other data - the classic example being a statistics counter where you only need the final total to be correct, not any ordering between increments.
std::atomic<int> hits{0};
void on_request() {
hits.fetch_add(1, std::memory_order_relaxed); // just count; ordering irrelevant
}
Warning: Relaxed ordering is correct only when no other thread's correctness depends on operations being ordered relative to this atomic. A relaxed counter for pure statistics is fine. A relaxed flag used to publish data is a bug - the data writes can be reordered relative to the flag, so a reader can see the flag set but not the data. The moment one thread's correctness depends on seeing another's writes in a particular order, relaxed is wrong and you need at least acquire-release. Misusing relaxed produces some of the nastiest, rarest, most platform-dependent bugs in all of programming.
The guiding principle
The orderings form a clear cost/safety tradeoff: stronger is simpler to reason about and slower; weaker is faster and treacherous. So the discipline is straightforward even though the topic is hard.
Tip: Start with
seq_cst(the default) - it's correct and intuitive. If profiling shows the synchronization is a real bottleneck and you can rigorously prove correctness, step down to acquire-release for publish/consume patterns. Use relaxed only for operations whose ordering genuinely doesn't matter, like independent counters. The vast majority of correct concurrent C++ uses mutexes and default atomics; the relaxed orderings are a specialist tool. Reaching for relaxed because it "sounds faster" without proving correctness is how you ship a bug that surfaces once a year on one customer's CPU architecture.
Why C++ alone exposes this control
It's a fair question why C++ hands you this dangerous, intricate set of memory orderings when Java, Python, Go, and C# largely don't. The answer is C++'s foundational principle: zero-overhead abstraction - you don't pay for what you don't use, and what you do use, you couldn't write better by hand. Different CPU architectures provide memory barriers of different strengths, and sequential consistency on a weakly-ordered architecture can require expensive full barriers. For most code that cost is invisible and irrelevant, but for the hottest paths in the most performance-critical systems - operating system kernels, lock-free data structures, high-frequency trading engines, the storage and replication layers of databases - that barrier cost can dominate. C++ exposes the memory orderings so that an expert who has profiled and proven correctness can pay only for the exact ordering they need, mapping directly onto the cheapest hardware barrier that suffices.
Higher-level languages make a different bargain: they give you a single, strong, intuitive model (or hide the question behind locks entirely) and accept the overhead, because their audience and use cases rarely justify the complexity and danger of relaxed orderings. C++ targets the cases where that overhead is unacceptable, so it gives you the knobs and trusts you to use them correctly. This is the same tradeoff that runs through all of C++: more control and more performance, paid for with more responsibility and more ways to be subtly wrong. The memory orderings are arguably the sharpest expression of that bargain in the entire language.
How the orders map to hardware
A mental model that helps: each memory order corresponds, roughly, to a category of CPU memory barrier. Sequential consistency may require a full barrier that prevents any reordering across it and forces global visibility - the strongest and most expensive. Acquire and release map to one-directional barriers: an acquire prevents later operations from moving before it, a release prevents earlier operations from moving after it, which is exactly enough to make the publish/consume pattern work without paying for a full barrier. Relaxed maps to no barrier at all - just an atomic operation with no ordering constraints, which on many architectures is nearly as cheap as a plain access.
On a strongly-ordered architecture like x86, the hardware already provides quite strong ordering, so the difference between seq_cst and acquire-release is often small or even zero in practice. On weakly-ordered architectures like ARM, the difference is real and can be significant, because the hardware reorders more aggressively and the stronger orders require explicit, costlier barriers. This is why lock-free code that "works fine" on an x86 laptop can fail or run slower than expected on an ARM server - the memory-ordering bugs that x86's strong ordering accidentally papered over become visible on weaker hardware. It's a concrete reason to reason about ordering explicitly rather than relying on what happens to work on your development machine.
A cautionary tale
I want to reinforce the danger one more time, because it's the most important practical point in the lesson. Lock-free programming with relaxed and acquire-release orderings is a domain where being slightly wrong produces bugs that are nearly impossible to find: they appear once in millions of runs, only under specific timing, often only on specific hardware, and they vanish the moment you attach a debugger or add a print statement that perturbs the timing. Experts with decades of experience in this exact area publish papers correcting each other's lock-free algorithms. The history of lock-free data structures is littered with published, peer-reviewed algorithms that were later found to have subtle ordering bugs.
The lesson is not "never use these" - they're essential for the systems that need them - but "respect them enormously." Reach for relaxed or acquire-release only when you have profiling data proving the synchronization is a genuine bottleneck, when you deeply understand the memory model, and ideally when you can verify correctness with tools designed for it. For the overwhelming majority of concurrent C++, mutexes and default sequentially-consistent atomics are correct, fast enough, and vastly easier to reason about. The relaxed orderings are a specialist's scalpel, not a general-purpose tool, and using them to feel clever rather than because you've measured a need is how serious bugs enter serious systems.
Tying it together: a simple spinlock
To see atomics and memory orders working in concert, here's a minimal spinlock - a lock built directly from an atomic flag, which busy-waits instead of sleeping. It's a compact illustration of acquire-release in action.
#include <atomic>
class SpinLock {
std::atomic<bool> locked{false};
public:
void lock() {
// spin until we successfully flip false -> true
while (locked.exchange(true, std::memory_order_acquire)) {
// busy-wait (a real spinlock would add a CPU pause hint here)
}
}
void unlock() {
locked.store(false, std::memory_order_release);
}
};
Walk through why the memory orders are what they are. The lock() uses exchange with memory_order_acquire: it atomically sets the flag to true and returns the old value, looping until the old value was false (meaning we acquired the lock). The acquire ordering ensures that once we hold the lock, all the reads and writes we do inside the critical section cannot be reordered to before the acquisition - so we genuinely see the protected state as of the moment we acquired. The unlock() uses store with memory_order_release: it sets the flag back to false, and the release ordering ensures all the writes we did inside the critical section are published before the flag is released, so the next thread that acquires sees our completed work. The acquire on lock and the release on unlock pair up to create exactly the happens-before edge that makes the critical section safe - the same publish/consume relationship from earlier, now expressed as a lock.
This is also a good illustration of when not to hand-roll concurrency. A spinlock busy-waits, burning CPU while it spins, which is only appropriate when the critical section is extremely short and contention is low; otherwise a real std::mutex (which sleeps the thread rather than spinning) is far better. And getting the memory orders exactly right, as above, is precisely the kind of subtle reasoning that's easy to get wrong - using the default seq_cst here would also be correct and only marginally slower, which is usually the right call. The spinlock is worth understanding to see acquire-release concretely, but in production you reach for std::mutex unless you've measured a specific need and can prove the hand-rolled version correct. It's a perfect microcosm of the whole lesson: the tools are powerful, the reasoning is subtle, and the default tools are usually the right answer.
The interview angle
Memory orders are a senior-level C++ topic, and handling them well signals real depth.
Interview: Likely questions: (1) "What does
std::atomicgive you?" - Lock-free, data-race-free atomic operations on a shared variable; the fix for races likecounter++without a mutex. (2) "What are memory orders?" - A per-operation specification of how strongly an atomic orders surrounding memory across threads, fromseq_cst(default, strongest, single global order) through acquire-release (pairwise publish/consume ordering) to relaxed (atomicity only, no ordering). (3) "Explain acquire-release." - A release store publishes all prior writes to an acquire load that reads the value; it's the cheaper, targeted version of the publish-a-flag pattern, the workhorse of lock-free code. (4) "When is relaxed safe?" - Only when no other thread relies on ordering relative to this atomic, e.g. a pure statistics counter; never for publishing data. (5) "What'scompare_exchange?" - Atomic compare-and-swap, the foundation of lock-free algorithms. Discussing the cost/safety tradeoff and defaulting to seq_cst shows mature judgment, not just knowledge.
Quick reference
#include <atomic>
std::atomic<int> a{0};
a.load(); a.store(x); a.exchange(x);
a.fetch_add(n); a.fetch_sub(n);
a.compare_exchange_strong(expected, desired); // CAS - lock-free foundation
a.is_lock_free();
// memory orders (strongest -> weakest)
std::memory_order_seq_cst // default: single global order, intuitive, slowest
std::memory_order_acquire // load side of publish/consume
std::memory_order_release // store side of publish/consume
std::memory_order_relaxed // atomicity only, NO ordering - use rarely
// publish/consume pattern:
// producer: data = X; flag.store(true, release);
// consumer: if (flag.load(acquire)) use(data); // sees X
// RULE: default to seq_cst; relax only with proof + profiling
Next, a uniquely C++ idiom that makes all of this safer in practice - RAII applied to locks, the pattern that guarantees you never forget to release a mutex even when an exception is thrown.