std::atomic — Lock-Free Basics
Sharing data between threads without synchronization is one of the most common sources of undefined behaviour in C++. A mutex solves this but introduces blocking: threads stall waiting for a lock. For simple shared state — counters, flags, indices — there is a better option. std::atomic wraps a value in operations that are guaranteed to be indivisible from every other thread's point of view, with no locks required. Understanding what "atomic" actually means, and which operations qualify, is the foundation for writing correct concurrent code.
The problem: reads and writes are not atomic by default
When two threads read and write to the same variable without synchronization, the result is a data race — undefined behaviour in the C++ standard, meaning anything can happen: corrupted values, crashes, or code that appears to work in debug builds but silently misbehaves in release. A plain ++counter on an int is not atomic — the processor must load the value, increment it, and store it back. If two threads interleave these three micro-steps, increments are lost.
// ✗ WRONG: data race — undefined behaviour
int counter = 0;
void increment() {
for (int i = 0; i < 100'000; ++i)
++counter; // NOT atomic: load → add → store
}
// Running increment() on two threads:
// Expected: 200'000
// Actual: could be anything — some increments are lostThe fix is to make the operation atomic. The C++ standard library provides std::atomic<T> in the <atomic> header. An atomic operation is one that completes without any thread ever seeing a partially-updated state. No other thread can observe the variable between the "before" and "after" of an atomic read-modify-write.
#include <atomic>
#include <thread>
std::atomic<int> counter{0};
void increment() {
for (int i = 0; i < 100'000; ++i)
++counter; // atomic fetch_add — indivisible
}
// Two threads both calling increment():
// Result is always exactly 200'000The C++ memory model: sequential consistency by default
C++11 introduced a well-defined memory model — the rules for when a write to a shared variable in one thread becomes visible to reads in other threads. Before C++11, multithreading was not part of the language at all; every threading library was operating in a zone of undefined behaviour. The memory model has three dimensions: which operations are atomic (indivisible), in what order operations are partially ordered across threads, and when writes become visible.
By default, all std::atomic operations use sequential consistency (memory order seq_cst), which provides the strongest guarantees: the instructions of every thread appear to execute in source-code order, and there is a single global ordering of all atomic operations visible to all threads. This is the safe default. Other memory orderings (relaxed, acquire, release, acq_rel) exist for performance-critical code where you can prove weaker guarantees are sufficient — but they require expert knowledge to use correctly and are not covered in this lesson.
// Default: sequential consistency — safe and predictable
std::atomic<int> x{0};
x.store(42); // memory_order_seq_cst by default
int v = x.load(); // always reads a consistent value
// Advanced: explicit memory order (not for beginners)
x.store(42, std::memory_order_relaxed); // no ordering guarantees
int v2 = x.load(std::memory_order_acquire); // pairs with releasestd::atomic_flag — the one guaranteed lock-free atomic
std::atomic_flag is the simplest atomic type: a boolean flag with only two operations. It is the only type in the standard library that is unconditionally guaranteed to be lock-free on every platform. All other atomic types may or may not be lock-free depending on the hardware — on platforms that lack native atomic instructions for a given size, the implementation is allowed to use a mutex internally. atomic_flag has no such escape hatch.
In C++20, atomic_flag gained a test() member (to read the current value without setting it) and the wait(), notify_one(), and notify_all() members for blocking until a flag changes. Before C++20, the flag had to be initialized with ATOMIC_FLAG_INIT; in C++20 and later, a default-constructed flag starts in the cleared (false) state.
#include <atomic> // C++20: default-constructed starts cleared (false) std::atomic_flag flag; // C++11/14/17: must use ATOMIC_FLAG_INIT std::atomic_flag flag2 = ATOMIC_FLAG_INIT; // test_and_set: sets flag to true, returns the OLD value bool was_set = flag.test_and_set(); // was_set = false, flag is now true was_set = flag.test_and_set(); // was_set = true, flag still true flag.clear(); // reset to false bool current = flag.test(); // C++20: read without modifying
The classic use of atomic_flag is building a spinlock — a mutex that does not put the thread to sleep but instead spins in a tight loop until the lock is available. This is efficient when the lock is held for very short durations (a few nanoseconds) but wasteful otherwise, as the waiting thread burns CPU time doing nothing useful.
class SpinLock {
std::atomic_flag flag;
public:
void lock() {
while (flag.test_and_set()) // spin until we acquire the flag
std::this_thread::yield();
}
void unlock() {
flag.clear();
}
};
SpinLock sl;
void critical_section() {
sl.lock();
// ... protected work (keep very short) ...
sl.unlock();
}std::atomic<T> — the workhorse
std::atomic<T> is a class template with specializations for different categories of types. The primary template handles bool and user-defined types (trivially copyable, no virtual functions). Full specializations exist for every integral type (int, long, unsigned, etc.) and, since C++20, for floating-point types. Partial specializations handle raw pointers and (C++20) smart pointers like std::shared_ptr. The specialization you get determines which operations are available on top of the fundamental interface.
Every specialization shares a fundamental interface: load, store, exchange, and compare-exchange. On top of that, numeric specializations add arithmetic (fetch_add, fetch_sub), floating-point adds only addition and subtraction, pointer specializations add increment/decrement, and integral specializations additionally support bitwise operations (fetch_and, fetch_or, fetch_xor).
#include <atomic>
std::atomic<int> counter{0}; // full integral specialization
std::atomic<double> temperature{0.0}; // float specialization (C++20)
std::atomic<bool> ready{false}; // bool specialization
// All share the fundamental interface:
counter.store(10); // atomically write
int v = counter.load(); // atomically read
int old = counter.exchange(20); // atomically write 20, return old value (10)
// Integral adds arithmetic:
counter.fetch_add(1); // atomic ++, returns old value
counter.fetch_sub(1); // atomic --, returns old value
++counter; // shorthand — same as fetch_add(1)
counter += 5; // returns new value (unlike fetch_add)Core API reference
The table below lists the operations available on std::atomic<T> and which specializations support them. One subtlety worth noting: the compound assignment operators (+=, |=, etc.) return the new value, while the corresponding fetch_ operations return the old value. This matters when you need to know what the value was before the operation.
| Operation | Description | Available on |
|---|---|---|
| is_lock_free() | Checks if this atomic object is lock-free (runtime) | All (except atomic_flag) |
| T::is_always_lock_free | Compile-time check: always lock-free? | All (except atomic_flag) |
| load() | Atomically read the value | All (except atomic_flag) |
| store(val) | Atomically write the value | All (except atomic_flag) |
| exchange(val) | Write val, return old value — atomic swap | All (except atomic_flag) |
| compare_exchange_strong(exp, des) | CAS: if *this==exp, write des and return true; else write *this to exp and return false | All (except atomic_flag) |
| compare_exchange_weak(exp, des) | Like strong but may spuriously fail — use in a loop | All (except atomic_flag) |
| fetch_add / fetch_sub | Atomic add/subtract — returns old value | Integers, floats, pointers |
| fetch_and / fetch_or / fetch_xor | Atomic bitwise ops — returns old value | Integers only |
| ++, -- | Pre/post increment and decrement | Integers, pointers |
| +=, -= | Compound add/sub — returns new value | Integers, floats, pointers |
| |=, &=, ^= | Compound bitwise — returns new value | Integers only |
| wait(old) (C++20) | Block until value changes from old | All |
| notify_one/all() (C++20) | Unblock threads waiting on this atomic | All |
compare_exchange_strong: the key to lock-free algorithms
Compare-and-swap (CAS) is the primitive that makes lock-free algorithms possible. compare_exchange_strong(expected, desired) does the following atomically: if the current value equals expected, it writes desired and returns true. If not, it writes the current value into expected (updating your local copy) and returns false. The key insight is that the comparison and the swap happen as a single indivisible operation — no other thread can change the value in between.
The weak variant (compare_exchange_weak) is identical except it is permitted to fail spuriously even when the comparison succeeds, to allow more efficient implementation on architectures (like ARM) that implement CAS as a load-linked/store-conditional pair. Because of this, compare_exchange_weak must always be used inside a loop; compare_exchange_strong can be used in an if.
// Implementing atomic multiplication using CAS
// (fetch_mul doesn't exist, but we can build it)
template <typename T>
T fetch_mult(std::atomic<T>& shared, T multiplier) {
T old_value = shared.load();
while (!shared.compare_exchange_weak(old_value, old_value * multiplier))
; // loop: spurious failure or another thread changed value
return old_value; // returns old value, like other fetch_ operations
}
std::atomic<int> value{5};
int before = fetch_mult(value, 4);
// before == 5, value.load() == 20// Pattern: update only if current value is what you expect
std::atomic<int> max_seen{0};
void update_max(int candidate) {
int current = max_seen.load();
while (candidate > current) {
// If max_seen is still 'current', replace with 'candidate'
if (max_seen.compare_exchange_weak(current, candidate))
break; // success — current was updated by CAS on failure, retry
}
}Checking lock-freedom
Despite the name, std::atomic<T> is not always lock-free. If the hardware lacks a native atomic instruction for the size of T, the standard library is permitted to implement atomicity using an internal mutex. The result is correct but not truly lock-free — a thread holding that internal lock can still be preempted, causing other threads to block. Only std::atomic_flag is unconditionally lock-free. For all other types, you can check:
std::atomic<int> a_int; std::atomic<double> a_double; // Runtime check (for this specific object) a_int.is_lock_free(); // likely true on x86_64 // Compile-time check (for the type in general) std::atomic<int>::is_always_lock_free // true on x86_64 std::atomic<double>::is_always_lock_free // true since C++20 on most platforms // Practical atomic sizes on x86_64: // atomic<char/short/int/long/long long> — always lock-free (8, 16, 32, 64 bit) // atomic<__int128> — may NOT be lock-free (128-bit requires CMPXCHG16B) // atomic<large struct> — almost certainly NOT lock-free
Common patterns
Atomics shine in a handful of well-known patterns. Shared counters (page hits, events counted, work items completed) are the simplest: increment atomically, read whenever needed. Ready flags let one thread signal another without a mutex. The thread-safe lazy initialization pattern (double-checked locking, now safe in C++11) uses an atomic boolean to avoid running initialization more than once.
std::atomic<int> requests_served{0};
// Multiple threads:
++requests_served; // safe from any thread
// Main thread or monitoring:
std::println("served: {}", requests_served.load());std::atomic<bool> data_ready{false};
std::string data;
// Producer thread:
data = compute_heavy_thing();
data_ready.store(true); // signal: data is ready
// Consumer thread:
while (!data_ready.load()) // spin-wait (or use wait() in C++20)
std::this_thread::yield();
use(data); // safe: producer set data before setting flagstd::atomic<bool> data_ready{false};
// Producer:
data = compute_heavy_thing();
data_ready.store(true);
data_ready.notify_one(); // wake up one waiting thread
// Consumer: blocks efficiently (OS-level) instead of spinning
data_ready.wait(false); // wait until value != false
use(data);When atomics are not enough
Atomics are the right tool when you need to share a single value between threads and the updates are simple (increment, swap, compare-and-swap). They are the wrong tool when you need to keep multiple related values consistent together. Making each individual field of a struct atomic does not make operations on the struct atomic as a whole — there is no way to atomically update x and y of a point simultaneously with std::atomic. For compound state, you need a mutex. As a rule: if you can express the operation as a single load, store, exchange, or CAS, use an atomic. If you need a transaction over several variables, use a mutex.
One variable, simple operations — use atomic
Counter, flag, index, generation counter. fetch_add / exchange / compare_exchange cover most cases.
Multiple related variables — use mutex
A struct with x, y, z that must stay consistent; a map plus its size; any invariant spanning two fields.
Check is_always_lock_free before trusting performance
atomic<large_struct> is probably using a mutex internally. atomic_flag is the only guaranteed lock-free type.
Start with sequential consistency (the default)
Relaxed memory orderings are subtle and error-prone. Only reach for them with a profiler and expert review.