atomic and the memory model
How is sharing without locks possible? std::atomic and CAS, reordering by the compiler and CPU, and the seq_cst, acquire/release, and relaxed memory orderings with happens-before.
So far we have protected shared data with locks, letting one thread at a time into the critical section. This part turns the other way, into the world of sharing without locks. std::atomic and the memory model behind it are the subtlest and hardest subject in this series. We will take it slowly.
Why do it without locks?
A mutex is reliable but it costs something: the overhead of lock/unlock itself, the cost of threads sleeping and waking under contention, and the way a critical section serialises execution and eats into parallelism. Taking a mutex just to increment a counter is overkill.
std::atomic performs certain operations atomically — indivisibly — without a lock. From another thread’s point of view the operation has either fully happened or not happened at all; no intermediate state is exposed.
The basics of atomic
Part two explained that ++counter races because it is three steps — read, add, write. std::atomic<int> makes that increment a single indivisible operation.
#include <atomic>
std::atomic<int> counter{0};
void increment() {
++counter; // atomic. safe without a mutex
}
No mutex, no lock. And yet, even with many threads doing ++counter at once, no increments leak. For a simple counter this is far lighter and faster than a mutex.
The basic operations look like this.
std::atomic<int> x{0};
x.store(10); // atomic write
int v = x.load(); // atomic read
int old = x.exchange(5); // set to 5 and return the previous value
x.fetch_add(3); // atomic addition (similar to ++)
compare_exchange: the heart of lock-free
The core of atomic operations is CAS (compare-and-swap), which in C++ is compare_exchange_weak/compare_exchange_strong. Atomically: “if the current value equals what I expected, replace it with the new value; otherwise do not.”
std::atomic<int> x{0};
int expected = 0;
bool ok = x.compare_exchange_strong(expected, 42);
// if x was 0 -> set it to 42 and return true
// if x was not 0 -> leave it, put the current value into expected, return false
This is the foundation of lock-free algorithms. The typical pattern is a retry loop: read the current value, compute the new value, and swap with CAS — which fails if another thread changed the value in the meantime, so you try again.
std::atomic<int> x{0};
void multiplyBy(int factor) {
int current = x.load();
while (!x.compare_exchange_weak(current, current * factor)) {
// on failure, current holds the latest value and the loop retries as is.
}
}
weak is more efficient on hardware where it can fail spuriously, so it goes inside loops; strong has no such spurious failure and suits a single attempt outside a loop.
That was the easy part
It would be nice if atomicity were the end of it, but it is not. The genuinely hard part is the ordering between several atomic variables (or between atomics and ordinary variables). Understanding it means accepting two uncomfortable truths.
The compiler and the CPU reorder instructions. As long as the result looks the same on a single thread, they rearrange reads and writes for optimisation. On a single thread this is harmless. But if another thread is watching those variables, the order you wrote in the code and the order that thread observes can diverge.
Here is the classic example.
int data = 0;
std::atomic<bool> ready{false};
// thread A (producing)
data = 42; // (1)
ready.store(true); // (2)
// thread B (consuming)
while (!ready.load()) {} // (3)
std::cout << data; // (4) is 42 guaranteed?
Intuitively, since B saw ready become true, data ought to be 42. But with no ordering guarantee, (1) and (2) can be reordered, so B can see ready == true while data is still 0. Controlling that divergence is what memory ordering is for.
Memory ordering
Atomic operations take a memory ordering as a second argument. It specifies “how far the other memory accesses around this operation may be reordered”. It helps to think of it in three tiers.
seq_cst (sequential consistency): the default, the strongest. All atomic operations behave as if they follow one global order. It is the most intuitive and the hardest to get wrong. Omitting the ordering gives you this. Most code is fine with it, and if you are unsure, use it.
x.store(1); // implicitly seq_cst
x.store(1, std::memory_order_seq_cst); // explicitly the same
acquire/release: the middle tier, used in pairs. Every memory write before a release write is guaranteed visible to a thread that reads that value with acquire. This is exactly how you fix the data/ready problem above.
int data = 0;
std::atomic<bool> ready{false};
// thread A
data = 42; // (1)
ready.store(true, std::memory_order_release); // (2) release
// thread B
while (!ready.load(std::memory_order_acquire)) {} // (3) acquire
std::cout << data; // (4) 42 is now guaranteed
The release write (2) “seals” every write before it (1), and when the acquire read (3) opens that seal, (1) comes with it. This release-acquire pair is the core tool for synchronising “what has happened” between threads. It is weaker than seq_cst and therefore faster on some architectures (ARM especially).
relaxed: the weakest, no ordering guarantee. It guarantees only the atomicity of the operation, and nothing about ordering relative to other memory accesses. Use it only when, as with a pure counter, “the count just has to come out right and the order relative to other data is irrelevant”.
std::atomic<int> counter{0};
counter.fetch_add(1, std::memory_order_relaxed); // only the count matters
relaxed is the fastest and the most dangerous. Depend on ordering even slightly and it breaks subtly. If you are not certain, do not use it.
happens-before: the conceptual skeleton
Underneath all of this is the happens-before relation. If A happens-before B, A’s effects are guaranteed visible to B. Within one thread, code order is happens-before. Across threads, happens-before arises only through synchronisation. A mutex’s unlock→lock, a release→acquire pair, and thread creation/join are those bridges.
The precise definition of a data race comes from here too. Two accesses with no happens-before relation between them that touch the same location, at least one of which is a write, are a data race — and that is undefined behaviour. Atomics and memory orderings are ultimately tools for building those happens-before bridges and eliminating races.
atomic::wait: lock-free waiting (C++20)
You will remember the condition variable from part three. C++20 made it possible to “wait and wake” with a single atomic, with neither a mutex nor a cv.
std::atomic<bool> ready{false};
// waiting side
ready.wait(false); // sleep while the value is false
// notifying side
ready.store(true);
ready.notify_one(); // wake a waiting thread
wait(false) means “wait while the value is false”. For simple flag-based waiting this is far lighter than a cv + mutex combination.
When to use atomic and when not to
Atomics are not a cure-all. Most code should in fact use a mutex.
Atomics fit simple operations on a single variable: counters, flags, replacing a single pointer. There an atomic is faster than a mutex and carries no risk of deadlock.
Avoid atomics when several variables must change consistently together. If you have to update several fields of a data structure at once, making each of them atomic does not make them look as if they changed all at once. That calls for a mutex. Implementing lock-free data structures yourself is expert territory, full of traps: CAS retries, the ABA problem, memory reclamation. In most cases the right move is a well-tested library or simply a mutex.
In one sentence: when in doubt, use a seq_cst atomic or a mutex. relaxed and hand-rolled lock-free code are tools to reach for only when you have a genuinely measured bottleneck and the understanding to handle them.
Summary
- atomic: performs certain operations indivisibly without a lock. Lighter than a mutex for counters and flags.
- compare_exchange (CAS): “swap if it matches what I expected”. The basis of lock-free retry loops.
weakin loops,strongfor single attempts. - The truth about reordering: the compiler and CPU rearrange order as long as the single-threaded result is the same. Other threads see the divergence.
- Memory ordering:
seq_cst(default, strongest, safest),acquire/release(paired cross-thread synchronisation),relaxed(atomicity only, most dangerous). - happens-before: cross-thread visibility arises only through synchronisation (unlock→lock, release→acquire, and so on). Conflicting accesses without it are a data race (UB).
- atomic::wait (C++20): simple flag waiting without a cv.
- Choosing: atomic for simple operations on a single variable, mutex for consistent updates across several. When in doubt, seq_cst or a mutex.
Having descended as far as atomicity and ordering, what remains is what happens when all these tools are used wrongly. The final part covers concurrency bugs — deadlocks, data races, false sharing — and how to catch them.