C++ / CONCURRENCY AND PARALLELISM
Atomics and lock-free counters
Count and publish shared state across threads with std::atomic, choosing between relaxed, release/acquire, and compare_exchange retry loops.
What you will learn
- Replace a mutex-guarded counter with std::atomic<int> and fetch_add
- Choose relaxed for standalone counters and release/acquire to publish data
- Build missing ops like fetch_max from a compare_exchange_weak retry loop
- Check is_always_lock_free before trusting atomic<BigType> to avoid a lock
Understanding Atomics and lock-free counters
A std::atomic<int> is not a magic integer; it is an ordinary integer plus a guarantee that certain whole operations on it are indivisible. On x86-64, hits.fetch_add(1) compiles to a single lock xadd instruction, so no other core can slip in between the read and the write, and that gap is precisely where a plain ++ loses updates. The mental shift is that atomicity belongs to the operation, not to the variable: the type only promises indivisibility for the operations it offers, and anything you assemble out of two of them is racy again.
Every atomic operation carries a second job besides indivisibility, which is constraining how the compiler and the CPU may reorder the ordinary accesses around it. The default, memory_order_seq_cst, behaves as if all such operations in the program occurred in one global order; it is the easiest to reason about and the most expensive, because hardware often needs a fence to provide it. memory_order_relaxed keeps indivisibility and drops all ordering, which is exactly right for a hit counter nobody inspects until the threads have joined. When the atomic is a signal that other data is ready, you need a release store paired with an acquire load, because the release makes everything written before it visible to any thread whose acquire load observes that value.
Lock-free means no participating thread can block the others by being descheduled halfway through an operation, which is why lock-free atomics cannot deadlock and are the only ones usable from a signal handler. It does not mean fast: every increment of one shared atomic must take exclusive ownership of the cache line holding it, so sixteen threads hammering a single counter can be slower than sixteen threads each bumping their own and summing at the end. Lock-freedom is also a property of a type on a platform rather than of std::atomic in general, since for types wider than the widest hardware compare-and-swap the library falls back to an internal lock, and is_lock_free and is_always_lock_free are how you find out which you got.
<atomic>
<iostream>
<thread>
<vector>
std::atomic<int> hits{0};
void bump(int times) {
for (int i = 0; i < times; ++i)
hits.fetch_add(1, std::memory_order_relaxed); // one indivisible RMW
}
int main() {
const int workers = 4;
const int per_worker = 250000;
std::vector<std::thread> pool;
for (int t = 0; t < workers; ++t)
pool.emplace_back(bump, per_worker);
for (std::thread& t : pool)
t.join(); // join() makes every increment visible to this thread
std::cout << std::boolalpha;
std::cout << "expected " << workers * per_worker << '\n';
std::cout << "counted " << hits.load(std::memory_order_relaxed) << '\n';
std::cout << "lock free " << std::atomic<int>::is_always_lock_free << '\n';
}
An atomic operation gives indivisibility on one object, and the memory order you attach to it decides what other threads may see about everything else.
Worked examples
A fetch_max built from compare_exchange_weak
Shows how to implement an atomic operation the standard library does not provide, using a compare-and-swap retry loop.
<atomic>
<iostream>
<thread>
<vector>
std::atomic<int> best{0};
// std::atomic has no fetch_max, so build one out of compare_exchange_weak.
void offer(int value) {
int seen = best.load(std::memory_order_relaxed);
while (value > seen &&
!best.compare_exchange_weak(seen, value, std::memory_order_relaxed))
; // a failed exchange refreshed `seen`; retest and try again
}
int main() {
std::vector<int> samples{3, 91, 17, 55, 91, 42};
std::vector<std::thread> pool;
for (int v : samples)
pool.emplace_back(offer, v);
for (std::thread& t : pool)
t.join();
std::cout << "max = " << best.load() << '\n';
}
Example explained
Line 1compare_exchange_weak stores value only if best still holds seen, so two threads racing to raise the maximum cannot overwrite each other's larger result.
Line 2On failure it writes the value it actually found back into seen, which is why the loop body needs no explicit reload.
Line 3The value > seen test short-circuits, so a thread offering 3 does nothing once 91 is already published.
Line 4The weak form may fail spuriously even when the comparison matched, which is harmless inside a retry loop and cheaper than compare_exchange_strong on some architectures.
Release/acquire handoff of non-atomic data
Demonstrates an atomic flag used purely as a synchronisation point that makes an ordinary variable safe to read.
<atomic>
<iostream>
<thread>
int payload = 0; // plain int, deliberately not atomic
std::atomic<bool> ready{false};
int main() {
std::thread producer([] {
payload = 42;
ready.store(true, std::memory_order_release); // publishes the line above
});
std::thread consumer([] {
while (!ready.load(std::memory_order_acquire))
std::this_thread::yield();
std::cout << "payload = " << payload << '\n'; // guaranteed to read 42
});
producer.join();
consumer.join();
}
Example explained
Line 1The release store keeps every earlier write in the producer from moving after it, and the acquire load keeps every later read in the consumer from moving before it.
Line 2Those two halves form one happens-before edge, so payload needs no atomicity of its own; the flag carries the synchronisation.
Line 3Change both orders to memory_order_relaxed and the consumer may see ready true while payload still reads 0, and that read becomes a data race.
Line 4yield() stops the spin from starving the producer on a single core; a busy-wait is only reasonable when the handoff is expected within microseconds.
Not every atomic is lock-free
Compares lock-freedom of small scalar atomics against an atomic over a 24-byte struct.
<atomic>
<iostream>
struct Point { double x, y, z; }; // 24 bytes: no single instruction swaps it
int main() {
std::cout << std::boolalpha;
std::cout << "sizeof(Point) = " << sizeof(Point) << '\n';
std::cout << "atomic<int> always lock free: "
<< std::atomic<int>::is_always_lock_free << '\n';
std::cout << "atomic<double> always lock free: "
<< std::atomic<double>::is_always_lock_free << '\n';
std::cout << "atomic<Point> always lock free: "
<< std::atomic<Point>::is_always_lock_free << '\n';
std::atomic<Point> p{Point{1.0, 2.0, 3.0}};
std::cout << "atomic<Point> lock free this run: "
<< p.is_lock_free() << '\n';
}
Example explained
Line 1is_always_lock_free is a compile-time constant, so static_assert can reject the type before a hidden lock ever ships.
Line 224 bytes is wider than any single compare-and-swap instruction on x86-64, so the library guards this object with an internal lock chosen from its address.
Line 3is_lock_free() answers for one object at runtime, while is_always_lock_free answers for the whole type at compile time.
Line 4These values are implementation-defined; this run is 64-bit x86 with GCC, and a type that fits in a machine word normally stays lock-free.
Important notes
++hits and hits += 1 on a std::atomic really are single atomic read-modify-writes because the operators call fetch_add; only the split form hits = hits + 1 is broken.
std::atomic is neither copyable nor movable, so pass it by reference (std::ref) or keep it at namespace or class scope, and note that fetch_add on floating-point atomics only exists from C++20.
Common mistakes
Writing hits = hits + 1 or hits.store(hits.load() + 1): each half is atomic but the pair is not, so concurrent increments overwrite each other and the total comes out quietly too low, and no sanitizer flags it because every individual access really is atomic.
Publishing data behind a memory_order_relaxed flag: a reader can see the flag set while the payload still holds its old value, which is a data race, and it typically reproduces only on ARM or POWER so x86 testing looks clean.
Reaching for std::atomic<SomeBigStruct> to make a type thread-safe: anything wider than a machine word takes an internal library lock, so you pay mutex cost with none of a mutex's flexibility, and every load copies the whole object.
Try it yourself
Change, predict, then run
Run the main example with hits changed to a plain int and note how the total varies and stays below 1000000, then restore std::atomic<int> but write hits = hits + 1 instead of fetch_add and confirm updates are still lost.
Open the C++ workspaceCheck your understanding
A worker does data = 7; then flag.store(true, std::memory_order_relaxed);, while a reader spins on flag.load(std::memory_order_relaxed) and then reads data. Both flag operations are atomic, so why can the reader still print 0?
- Relaxed atomic stores can be dropped under contention, so the store of true may never take effect.
- std::atomic<bool> uses an internal lock, so the reader can catch the flag half-written.
- Relaxed operations are indivisible but impose no ordering, so the flag can become visible before the write to data.
- The reader must use fetch_add on the flag, because a plain load is not an atomic operation.
Show answer
Indivisibility and ordering are separate guarantees. A relaxed store is a real, never-lost write to the atomic object, but nothing forces the earlier write to data to be visible to a thread that observes the flag, so the reader can legally see the stale 0 and its read of data races with the worker's write. Option 0 is tempting because relaxed sounds unreliable, but relaxed never loses an update; the missing ingredient is a release store paired with an acquire load, which is what creates the happens-before edge.