Atomics & C++ Memory Model
Mutexes make shared data safe by making threads take turns — atomics make individual operations on shared data safe without a lock at all, and the C++ memory model is the precise set of rules that says exactly what other threads are guaranteed to see when they do.
A mutex protects a whole region of code by forcing threads to take turns; std::atomic protects a single variable by making individual operations on it — a read, a write, an increment, a compare-and-swap — indivisible, so no other thread can ever observe them half-done. That is faster than locking for simple cases (a counter, a flag, a pointer swap) and it is the foundation lock-free data structures are built on. But atomics only solve half the problem: the other half is memory ordering — the rules for what a write on one thread guarantees a read on another thread will actually see, and in what order. This page covers std::atomic's core operations, std::atomic_flag, the formal idea of a data race and how synchronization prevents one, the C++ memory model's ordering options from relaxed to sequentially consistent, and a first taste of writing genuinely lock-free code.
Why Atomics Exist — Beyond Mutexes
As the concurrency page showed, ++counter on a plain int shared between threads is a data race, because it is really three separate steps (read, add, write) that another thread can interleave with. Wrapping it in a mutex fixes that, but locking and unlocking a mutex has real cost — even an uncontended lock is typically many times slower than a single machine instruction, and a contended one can put a thread to sleep entirely. std::atomic<T> gives certain operations on T — load, store, and a handful of read-modify-write operations — hardware-level indivisibility, often compiling down to a single CPU instruction, with no OS-level locking involved at all for the common cases.
++counter here calls std::atomic<int>::operator++, which performs the entire read-add-write sequence as one indivisible hardware operation (typically a single locked instruction like x86's LOCK XADD). No two threads can ever observe or interleave with a partially completed increment, so the final total is reliably 400000, matching the mutex-protected version from the concurrency page but without an explicit lock object anywhere.
#include <atomic>
#include <thread>
#include <vector>
#include <iostream>
std::atomic<int> counter{0};
void increment_many()
{
for (int i = 0; i < 100000; ++i)
++counter; // atomic read-modify-write, no data race
}
int main()
{
std::vector<std::thread> threads;
for (int i = 0; i < 4; ++i)
threads.emplace_back(increment_many);
for (auto& t : threads)
t.join();
std::cout << counter << "\n"; // always exactly 400000
}Core Operations: load, store, exchange
Every std::atomic<T> supports the three most basic operations directly: load() reads the current value, store() writes a new one, and exchange() writes a new value while atomically returning what the old value was — useful whenever you need both the write and the previous value in one indivisible step.
value.load() and value.store(20) are the atomic equivalents of reading and assigning a plain variable, guaranteed not to tear even if another thread is doing the same concurrently. value.exchange(30) atomically sets value to 30 and returns whatever value held immediately beforehand (20) — no other thread can observe a state where the store has happened but the old value has already been lost, which a separate load() followed by a separate store() could not guarantee.
value = 20; and int x = value; also work directly on std::atomic<int> — operator= and the conversion operator are shorthand for store() and load() respectively, using the default memory ordering (memory_order_seq_cst, covered later on this page).
#include <atomic>
#include <iostream>
int main()
{
std::atomic<int> value{10};
int current = value.load(); // read: 10
value.store(20); // write: value is now 20
int previous = value.exchange(30); // previous = 20, value is now 30
std::cout << "current=" << current
<< " previous=" << previous
<< " now=" << value.load() << "\n";
}Compare-and-Exchange
compare_exchange_weak and compare_exchange_strong are the workhorse of lock-free programming: atomically compare the atomic variable's current value against an expected value, and if they match, replace it with a desired value; if they don't match, write the atomic's actual current value into expected instead. The whole compare-and-maybe-replace happens as one indivisible operation.
The first call succeeds because value (5) equals expected (5), so value becomes 100 and the function returns true. The second call fails because value is now 100 but expected is still the stale 5 from before the first call updated it — compare_exchange_strong writes the atomic's real current value (100) back into expected on failure, which is exactly what lets a retry loop pick up the true current value and try again.
compare_exchange_weak may fail spuriously — return false even when the values did match — on some hardware, in exchange for being cheaper to execute in a loop; compare_exchange_strong never fails spuriously. The weak form is preferred inside a retry loop (where a spurious failure just costs one extra iteration); the strong form is preferred for a one-shot comparison outside a loop.
#include <atomic>
#include <iostream>
int main()
{
std::atomic<int> value{5};
int expected = 5;
bool success = value.compare_exchange_strong(expected, 100);
std::cout << std::boolalpha << success << " " << value.load() << "\n";
// true 100 -- value matched expected (5), so it was replaced with 100
success = value.compare_exchange_strong(expected, 200);
std::cout << success << " " << value.load() << " expected_now=" << expected << "\n";
// false 100 expected_now=100 -- value (100) no longer matches the OLD
// expected (5), so no write happens, and expected is updated to 100
}Fetch Operations
For integral and pointer types, std::atomic also provides fetch_add, fetch_sub, fetch_and, fetch_or, and fetch_xor — each atomically applies the operation and returns the value that was there immediately before it was applied.
fetch_add(5) returns 10 (the value before the add) while leaving counter at 15 — the return value matters whenever code needs to know exactly what slot or index it claimed, such as a lock-free counter handing out unique ticket numbers. counter += 1 is shorthand for fetch_add(1) that simply discards the returned old value, which is all ++counter and counter += n need.
#include <atomic>
#include <iostream>
int main()
{
std::atomic<int> counter{10};
int old_value = counter.fetch_add(5); // counter becomes 15
std::cout << "old=" << old_value << " new=" << counter.load() << "\n";
// old=10 new=15
counter.fetch_sub(3); // counter becomes 12
std::cout << counter.load() << "\n"; // 12
counter += 1; // shorthand for fetch_add(1), discarding the old value
std::cout << counter.load() << "\n"; // 13
}std::atomic_flag
std::atomic_flag is the one atomic type the standard guarantees is always genuinely lock-free on every platform (std::atomic<T> for other types is only lock-free if is_always_lock_free is true, which depends on the platform and T). It represents exactly one bit of state — set or clear — and is the building block for a minimal spinlock.
test_and_set() atomically sets the flag and returns whatever it was before — if it returns true, the flag was already set (someone else holds the spinlock), so the loop keeps retrying; if it returns false, this thread just claimed it and can enter the critical section. clear() releases it. This is called a spinlock because a waiting thread burns CPU cycles retrying in a tight loop instead of sleeping, which makes it appropriate only for very short critical sections where the cost of an OS-level mutex sleep/wake would be relatively larger.
#include <atomic>
#include <thread>
#include <iostream>
std::atomic_flag lock_flag = ATOMIC_FLAG_INIT;
int shared_value = 0;
void critical_section(int add)
{
while (lock_flag.test_and_set(std::memory_order_acquire))
; // spin: someone else holds the 'lock'
shared_value += add; // protected region
lock_flag.clear(std::memory_order_release);
}
int main()
{
std::thread t1(critical_section, 10);
std::thread t2(critical_section, 20);
t1.join();
t2.join();
std::cout << shared_value << "\n"; // always 30
}The C++ Memory Model and Memory Ordering
Atomicity alone only guarantees that one operation on one variable is indivisible — it says nothing about how operations on different variables, possibly by different threads, become visible to each other, or in what order. Both compilers and CPUs are free to reorder ordinary memory operations for performance, as long as a single thread cannot tell the difference; the memory model is the set of rules for exactly how much of that reordering is still allowed to be visible across threads, and every atomic operation takes a memory_order argument to control it.
memory_order_relaxed guarantees the operation itself is atomic (no torn reads/writes) but gives no guarantee about ordering relative to any other memory access — use it only for something like an independent counter that nothing else's correctness depends on. memory_order_release on a store, paired with memory_order_acquire on the load that observes it, guarantees that everything written by the releasing thread before the store becomes visible to the acquiring thread after the load. memory_order_acq_rel is used on read-modify-write operations (like fetch_add) that need both directions of that guarantee at once. memory_order_seq_cst — the default when no ordering is specified — additionally guarantees a single total order that every thread agrees on for all seq_cst operations, which is the easiest to reason about but also the most expensive on some hardware.
#include <atomic>
std::atomic<int> x{0};
x.store(1, std::memory_order_relaxed); // weakest: only atomicity, no ordering guarantee
x.store(1, std::memory_order_release); // pairs with acquire loads elsewhere
int v = x.load(std::memory_order_acquire); // pairs with release stores elsewhere
x.fetch_add(1, std::memory_order_acq_rel); // both acquire and release semantics
x.store(1, std::memory_order_seq_cst); // default: strongest, globally orderedHappens-Before and Synchronizes-With
Happens-before is the formal relation the C++ memory model uses to decide whether one operation is guaranteed to be visible to another. Within a single thread, each statement happens-before the next one in program order. Across threads, a release operation on one thread synchronizes-with an acquire operation on another thread that observes it, and that synchronizes-with relationship extends happens-before across the thread boundary.
(1) happens-before (2) because they run in that order on the same thread. Once (3) observes the true written by (2), (2) synchronizes-with (3), which extends happens-before across threads: (1) happens-before (2) happens-before (3) happens-before (4), so message is guaranteed to be fully written and visible by the time (4) reads it — even though message itself is an ordinary, non-atomic std::string. This is the entire point of acquire/release pairing: it lets one atomic flag carry an ordering guarantee for arbitrary surrounding non-atomic data.
#include <atomic>
#include <thread>
#include <iostream>
#include <string>
std::string message;
std::atomic<bool> ready{false};
void producer()
{
message = "payload is ready"; // (1) ordinary, non-atomic write
ready.store(true, std::memory_order_release); // (2) release
}
void consumer()
{
while (!ready.load(std::memory_order_acquire)) // (3) acquire
;
std::cout << message << "\n"; // (4) reads message safely
}
int main()
{
std::thread p(producer);
std::thread c(consumer);
p.join();
c.join();
}Data Races and What Synchronization Actually Fixes
A data race, formally, is two conflicting accesses to the same memory location (at least one a write) with no happens-before relationship ordering them. Making a single variable std::atomic does not automatically make a whole algorithm correct — it only guarantees that operation itself cannot tear and gives you the vocabulary (memory orderings) to establish happens-before relationships for everything else.
Even though a is atomic, memory_order_relaxed gives no synchronizes-with guarantee at all — Thread 2 seeing a == 1 does not guarantee it also sees Thread 1's write to plain_data, because relaxed operations do not establish happens-before across threads. Reading plain_data here is still a data race and still undefined behavior. Fixing it requires an ordering that actually synchronizes, such as making the store a release and the load an acquire, exactly as the producer/consumer example above did.
#include <atomic>
std::atomic<int> a{0};
int plain_data = 0; // NOT atomic
// Thread 1:
// plain_data = 42;
// a.store(1, std::memory_order_relaxed);
// Thread 2:
// if (a.load(std::memory_order_relaxed) == 1)
// use(plain_data); // STILL a data race on plain_data!A Taste of Lock-Free Programming
Lock-free code guarantees that the overall system makes progress even if some individual thread is paused or preempted at an arbitrary point — no thread can block another indefinitely by holding a lock and stalling. The compare-and-exchange retry loop is the fundamental building block: attempt an update, and if another thread changed the value in the meantime, retry with the new value instead of overwriting it.
Each thread reads the current shared_max, and if its candidate is bigger, tries to swap it in with compare_exchange_weak. If another thread updated shared_max first, the swap fails, current is refreshed to the real current value by compare_exchange_weak itself, and the while condition re-checks whether candidate is still bigger than that fresh value before retrying. No thread ever blocks another; the maximum across all five candidates (9) always ends up in shared_max regardless of the order the five threads actually run in.
#include <atomic>
#include <thread>
#include <vector>
#include <iostream>
std::atomic<int> shared_max{0};
void propose(int candidate)
{
int current = shared_max.load(std::memory_order_relaxed);
while (candidate > current &&
!shared_max.compare_exchange_weak(current, candidate,
std::memory_order_relaxed))
{
// compare_exchange_weak updated 'current' to the real value on
// failure -- loop condition re-checks candidate against it
}
}
int main()
{
std::vector<std::thread> threads;
for (int v : {3, 7, 2, 9, 5})
threads.emplace_back(propose, v);
for (auto& t : threads)
t.join();
std::cout << shared_max.load() << "\n"; // 9
}Best Practices
Default to memory_order_seq_cst (which is what you get by not specifying an ordering at all) until you have a specific, measured reason to relax it — it is the easiest ordering to reason about correctly, and the performance difference is often smaller than it seems. Reach for relaxed ordering only for truly independent counters or statistics where no other memory access depends on their value. Use acquire/release pairs specifically to protect a handshake between threads, like the ready-flag pattern, where one thread's non-atomic writes need to become visible to another thread after an atomic signal. Prefer compare_exchange_weak inside retry loops and compare_exchange_strong for one-shot checks. And remember that std::atomic<T> only makes individual operations on T atomic — building a correct multi-step algorithm out of several atomic operations still requires reasoning carefully about the happens-before relationships between them, exactly the way ordinary mutex-based code requires reasoning about what a critical section protects.
Why does making a shared counter a std::atomic<int> eliminate the data race that a plain shared int has under concurrent ++counter, without needing an explicit mutex?
std::atomic<int>::operator++ performs the entire read-modify-write sequence as a single indivisible hardware operation (often one locked CPU instruction), so no other thread can ever observe or interleave with a partially completed increment. A plain int's ++counter is three separate steps that can be interleaved between threads; an atomic increment cannot be split apart at all.
What is the difference in behavior between compare_exchange_weak and compare_exchange_strong, and why is the weak version generally preferred inside a retry loop?
compare_exchange_strong never fails spuriously — it only returns false when the atomic's actual value genuinely did not match expected. compare_exchange_weak is permitted to fail spuriously on some hardware even when the values did match, in exchange for being cheaper to implement and execute. Inside a retry loop, a spurious failure just costs one harmless extra iteration, so the weak form's lower cost per attempt makes it the better default there; the strong form is preferred for a single one-shot comparison where a spurious failure would need separate handling.
In the producer/consumer example using memory_order_release on the store and memory_order_acquire on the load, why is it safe for the consumer to read the plain (non-atomic) std::string message after observing ready == true?
The release store to ready happens-before it in program order relative to the write of message, and once the acquire load observes that release store, the release synchronizes-with the acquire, extending happens-before across the thread boundary. That chain (write message -> release store -> acquire load -> read message) guarantees message is fully written and visible to the consumer by the time it reads it, even though message itself carries no atomicity of its own.
Why is using memory_order_relaxed on both the store and load of a flag insufficient to safely publish an unrelated plain variable to another thread, even if the flag's value itself is read correctly?
memory_order_relaxed guarantees only that operations on that specific atomic variable are individually atomic — it establishes no synchronizes-with relationship and therefore no happens-before ordering with any other memory access. A thread observing the relaxed flag's new value has no guarantee that it also observes writes to other variables that happened before the flag was set on the writing thread, so reading that other variable remains a genuine data race and undefined behavior.
What guarantee does 'lock-free' actually promise about a concurrent algorithm, and why is a compare-and-exchange retry loop still considered lock-free even though a thread can retry many times?
Lock-free means the overall system is guaranteed to keep making progress even if an individual thread is paused, preempted, or killed at any point — no thread can indefinitely block others just by holding something. A CAS retry loop is still lock-free because a thread only retries when another thread's operation actually succeeded in between (meaning the system as a whole made progress); no thread can be stalled forever by another thread that is itself stuck or asleep, unlike a mutex, where a thread holding the lock genuinely can block every other thread waiting on it.
What does this program print, and why does the compare-and-exchange fail here?
It prints "false 50 50". compare_exchange_strong compares value (50) against expected (40); since they do not match, no write happens (value stays 50) and the call returns false, but as required by the standard it also writes the atomic's actual current value (50) back into expected — which is why expected ends up as 50, not its original 40.
Write a C++ program that uses std::atomic<int> and fetch_add to hand out unique, sequential ticket numbers to four concurrently launched threads, and print each thread's assigned ticket.
fetch_add(1) atomically increments next_ticket and returns the value it held immediately before the increment, so each of the four threads is guaranteed to receive a distinct ticket number (some permutation of 0, 1, 2, 3, in whatever order the threads actually execute the fetch_add). Because fetch_add is atomic, no two threads can ever receive the same ticket even though all four call it concurrently, and next_ticket.load() at the end always reports 4.
Write a C++ program implementing a minimal spinlock using std::atomic_flag (acquire on test_and_set, release on clear) protecting a shared integer, and use it from two threads that each add to the shared value 10,000 times.
test_and_set(acquire) spins until it returns false, at which point this thread holds the spinlock and the acquire ordering guarantees it sees every write made before the previous holder's clear(release). shared_total += amount then runs with no other thread able to enter the critical section at the same time, and clear(release) hands the lock off, making this thread's write visible to whichever thread acquires next. Because every modification to shared_total happens strictly one at a time, the final result is exactly 10000*1 + 10000*2 = 30000, with no lost updates.
Why can this program print 0, 1, 10, or 11 depending on the run, and what does that reveal about memory_order_relaxed?
With memory_order_relaxed there is no ordering guarantee between the store to one flag and the load of the other, on either thread, and no happens-before relationship links the two threads' operations at all. Depending on how the operations actually interleave and how the compiler/hardware reorder them, thread1 may or may not see flag_b already set to true, and independently thread2 may or may not see flag_a already set to true — all four combinations (neither sees the other's flag, only one does, or both do) are legal outcomes, producing result values of 11, 1, 10, or 0 respectively. This is exactly why relaxed ordering is unsuitable whenever one operation's visibility needs to be guaranteed relative to another's — that requires acquire/release or seq_cst ordering instead.
#include <atomic>
#include <iostream>
int main()
{
std::atomic<int> value{50};
int expected = 40;
bool ok = value.compare_exchange_strong(expected, 99);
std::cout << std::boolalpha << ok << " " << value.load() << " " << expected << "\n";
}#include <atomic>
#include <thread>
#include <iostream>
std::atomic<bool> flag_a{false};
std::atomic<bool> flag_b{false};
int result = 0;
void thread1()
{
flag_a.store(true, std::memory_order_relaxed);
if (!flag_b.load(std::memory_order_relaxed))
result += 1;
}
void thread2()
{
flag_b.store(true, std::memory_order_relaxed);
if (!flag_a.load(std::memory_order_relaxed))
result += 10;
}
int main()
{
std::thread t1(thread1);
std::thread t2(thread2);
t1.join();
t2.join();
std::cout << result << "\n";
}