AlgoMaster Logo

Atomic Operations (std::atomic)

Medium Priority19 min readUpdated September 21, 2026
Listen to this chapter
Unlock Audio

When two threads touch the same variable without protection, the result isn't just unpredictable, it's undefined. The fix you've already seen is std::mutex, which is the right answer for anything more complex than a single read or write. But for small counters, flags, and shared scalars, locking a mutex on every update is overkill. std::atomic<T> gives you indivisible reads, writes, and updates on simple types without a mutex, and this chapter shows what it can do, what it can't, and where its sharp edges hide.

Why a Plain int Isn't Safe

Start with the simplest shared state imaginable: an order ID counter. Every time a customer places an order, the server hands out the next number. You'd think int orderId; orderId++; would be fine. It's one line of code. How wrong could it go?

Very wrong. orderId++ looks atomic in source, but the compiler turns it into three machine instructions: load the current value into a register, add one, store the new value back. If two threads execute that sequence at the same time, their loads and stores can interleave, and one of the increments vanishes.

Run it three times and one gets three different wrong answers. The exact loss depends on how the operating system schedules the threads, what your CPU's cache is doing, and how the compiler arranged the code. This isn't just a "could happen" theoretical race. The C++ standard officially says this program has undefined behavior, which means the compiler is allowed to do anything at all, including optimize the loop into something that looks nothing like what you wrote.

Compiling with -fsanitize=thread confirms it:

A data race isn't just a wrong answer. It's UB. The compiler can assume races don't happen and optimize accordingly, which means even "obviously safe-looking" patterns can break. Don't reason about races by squinting at assembly. Use atomics or mutexes.

You could fix this with a std::mutex, but that's heavy for a single integer increment. A mutex lock can take 25-100 nanoseconds when uncontended, and much more when contended, because it involves a system call and possibly putting the thread to sleep. For a single ++, that's a steep tax.

Enter std::atomic<T>

std::atomic<T> is a template in the <atomic> header (added in C++11) that wraps a type and guarantees that reads, writes, and built-in updates on it happen as a single indivisible step. No other thread can ever see a half-updated value, and the compiler isn't allowed to optimize the access away or reorder it across other atomic accesses.

Swapping int for std::atomic<int> fixes the order-counter program with almost no change:

Every run prints exactly 800000. The ++ operator on std::atomic<int> is overloaded to perform an atomic increment, which the CPU executes as a single hardware-level read-modify-write (on x86, this becomes a lock xadd instruction; on ARM, it's a load-linked / store-conditional loop). Either way, no other thread can see or interfere with the operation midway through.

Notice orderId{0}. You should always initialize an atomic explicitly. Default-constructed atomics of fundamental types are zero-initialized in C++20, but in C++17 the value is unspecified. Brace-initialization in the declaration is the safe default.

The reads use orderId.load(). You could also write int n = orderId;, which calls the implicit conversion operator and does the same thing, but load() is more explicit about what's happening. Both perform an atomic read.

What Types Can You Use?

std::atomic<T> only works for trivially copyable types. That covers built-in scalars (int, long, double, pointers, bool), small POD structs with no virtual functions or user-defined copy logic, and anything else that can be safely copied with memcpy. You cannot make std::atomic<std::string> or std::atomic<std::vector<int>>. Those types have non-trivial copy constructors and destructors, so atomic instructions can't operate on them as a single block.

For small types like int, bool, and pointers, the compiler typically emits a single hardware instruction. For larger types (a 16-byte struct, for example), the implementation may fall back to a hidden lock to fake atomicity. The is_lock_free() member tells you which.

std::atomic<T> for T larger than the machine's natural atomic width (usually 8 bytes on 64-bit platforms, sometimes 16 with cmpxchg16b on x86-64) often uses an internal lock. You get correctness, but no performance advantage over a mutex. Stick to scalars when you want lock-free behavior.

Core Operations

std::atomic<T> exposes a small set of members that cover almost every use case. Every operation on this list defaults to sequential consistency (std::memory_order_seq_cst), which is the strongest ordering and the easiest to reason about. For now, treat every operation as if it acts like a synchronized read or write that all threads agree on.

OperationWhat It Does
load()Atomically reads the current value.
store(v)Atomically writes v.
exchange(v)Atomically writes v and returns the previous value.
compare_exchange_weak(expected, desired)If the current value equals expected, replaces it with desired. Returns whether it swapped.
compare_exchange_strong(expected, desired)Same as above but never fails spuriously.
fetch_add(v)Atomically adds v and returns the old value.
fetch_sub(v)Atomically subtracts v and returns the old value.
fetch_or(v), fetch_and(v), fetch_xor(v)Bitwise atomic updates.
++, --, +=, -=, &=, `=, ^=`

A small e-commerce example showing several of them. The site tracks how many shoppers are currently browsing (live count) and accumulates page views over the lifetime of the server:

Every session bumps liveShoppers on entry and decrements it on exit. pageViews accumulates across all sessions. Neither counter ever loses an update, and the final values are exact even though twenty threads were stomping on them in parallel. The mid-traffic snapshot reads whatever value happens to be live when the main thread checks, which is fine because .load() always returns a consistent value, never a half-updated one.

A subtle but useful point about fetch_add: it returns the old value. So int oldCount = pageViews.fetch_add(1) gives you the value before the increment, which is exactly what you want when generating sequential IDs:

Every thread gets a unique ID. The threads can run in any order, but the IDs themselves don't collide and don't repeat. That's the property fetch_add guarantees.

Compare-and-Swap (CAS)

load, store, and fetch_add are enough for counters, but they can't handle conditional updates. Say you want to reserve stock for an order: subtract from availableStock only if there's enough left. There's no fetch_sub_if_at_least primitive, so you build it from a more general operation called compare-and-swap, exposed as compare_exchange_weak and compare_exchange_strong.

The idea: read the current value, compute the new value you want, then atomically swap only if the current value still matches what you read. If another thread changed it in between, the swap fails and you retry with the new value. This pattern is the foundation of lock-free programming.

The signature looks like this:

It does, atomically:

  1. If current == expected, set the atomic to desired and return true.
  2. Otherwise, write the actual current value into expected and return false.

The second part is important: on failure, expected is updated with whatever was actually there, so your retry loop already has the latest value. You don't need a separate load to refresh it.

Here's stock reservation done right:

Thirty threads each try to reserve 2 units from a pool of 50. Exactly 25 succeed (50 / 2), five fail because they arrive after the stock runs out, and the final stock count is zero. Run it again and you get the same final numbers, every time, even though the threads race wildly. That's the power of CAS: it lets you build a conditional atomic update from a primitive that the hardware supports.

The retry loop is the CAS loop, and it shows up everywhere in lock-free code. The pattern is always the same: read, compute, swap, retry on failure.

The diagram shows the retry loop. The CPU's hardware CAS instruction does the comparison and the swap as one indivisible step, so no other thread can sneak in between the comparison and the write. If the value changed since you loaded it, you don't get to write the stale-derived value; you have to recompute based on the fresh value.

Weak vs Strong

There are two flavors: compare_exchange_weak and compare_exchange_strong. They look almost identical but differ in one subtle property.

compare_exchange_strong only returns false when the values genuinely don't match. compare_exchange_weak is allowed to also fail spuriously, meaning it can return false even when the values matched. The reason is that on some architectures (ARM, PowerPC), the underlying load-linked / store-conditional sequence can be interrupted by unrelated cache events, and forcing a retry is cheaper than guaranteeing no spurious failures.

VariantSpurious Failures?When to Use
compare_exchange_weakYesInside a retry loop (preferred).
compare_exchange_strongNoWhen you're not retrying, or want a one-shot try.

Inside a while loop, you don't care about spurious failures, because you'd retry anyway. So weak is the right default in CAS loops. Use strong for a single attempt where is done something different on failure:

A CAS loop scales well when contention is low, but degrades badly when many threads hammer the same atomic, because each failed attempt forces a fresh load and recomputation. On 32+ cores fighting over one counter, a well-designed mutex can actually outperform a naive CAS loop. Atomics aren't free, they just have a different cost shape.

Lock-Free vs Wait-Free

This appears two terms in lock-free programming literature, and they're worth distinguishing.

  • Lock-free means: at least one thread is guaranteed to make progress in a finite number of steps. Some threads might spin in retry loops, but the system as a whole always advances. CAS loops are lock-free.
  • Wait-free is stronger: every thread is guaranteed to make progress in a finite number of steps, regardless of what other threads do. fetch_add on x86 is wait-free because the hardware completes it in a bounded number of cycles.

std::atomic operations are usually lock-free on common platforms, but not always. The standard requires std::atomic_flag to be lock-free, and std::atomic<bool> and most pointer-sized integers are lock-free on every mainstream CPU. Larger or unusual types may not be.

You can check at runtime with .is_lock_free():

.is_lock_free() checks at runtime, which means the answer can technically depend on alignment or platform mode. C++17 added a more useful compile-time check: is_always_lock_free. It's a static constexpr bool member that's true only if the type is lock-free on every conforming implementation. You can use it in static_assert and template metaprogramming:

The runtime check tells you what the current binary does. The compile-time check tells you what the standard guarantees. For library code that needs to make a hard guarantee, use is_always_lock_free.

std::atomic_flag

There's one atomic type that the C++ standard guarantees is always lock-free, on every conforming implementation: std::atomic_flag. It's the lowest-level atomic primitive, and it only stores a single bit. Two operations are exposed:

  • test_and_set(): atomically sets the flag to true and returns the previous value.
  • clear(): atomically sets the flag to false.

That's enough to build a spinlock, which is a lock where the waiting thread keeps checking in a tight loop instead of going to sleep. The classic textbook example:

test_and_set() returns the previous value, so the while keeps spinning as long as someone else has the flag (returns true). Once it sees false, it sets the flag to true in the same instruction and exits the loop. The thread does its work, then calls clear() to release the flag for the next waiter.

ATOMIC_FLAG_INIT is a macro that initializes the flag to the cleared state. In C++20, you can default-construct an atomic_flag instead; pre-C++20, the macro is required because there's no in-class initializer.

This example uses an atomic_flag to protect a plain int. That is the actual use case: a spinlock built from a single atomic flag, guarding more complex state that cannot be made atomic directly.

Spinlocks burn CPU while waiting. They're appropriate when the critical section is microseconds long and contention is rare. For anything longer, a mutex that can put the thread to sleep is far more efficient. In application code, prefer std::mutex and let the standard library decide when to spin and when to sleep. Use atomic_flag directly only when you're sure you need the absolute lowest overhead, which is almost never.

Atomic vs Mutex: When to Use Which

Atomics and mutexes solve the same broad problem (safe concurrent access to shared state), but they fit different shapes of problem. A rough decision guide:

Use Atomic WhenUse Mutex When
Single scalar (counter, flag, pointer)Multiple related variables that must stay consistent
Update is one operation (++, =, CAS)Critical section has several reads and writes
Lock-free or very low contentionSections last more than a few microseconds
Hot path with tight performance budgetLogic is complex enough that mutex correctness > micro-optimization

If you find yourself doing atomic1.load() and atomic2.load() and reasoning about consistency between them, you're past the point where atomics help. The two loads can return values from different "moments" in time, because each is atomic on its own but they're not atomic together. A mutex around the whole block gives you a single consistent snapshot.

Conversely, wrapping a counter in a mutex when all you do is increment it is wasteful. The mutex adds overhead and complexity for no benefit. std::atomic<int> with fetch_add is shorter, faster, and obviously correct.

The trickiest part of lock-free programming is everything between the atomic operations. When you have multiple atomic variables, the order in which other threads see their updates matters, and that's where memory ordering comes in. We've stuck with the default sequential consistency throughout this chapter, which makes everything behave intuitively but isn't always the fastest option.

Cache Line Bouncing

A cost that surprises people the first time they meet it. Two atomic variables that sit close together in memory can interfere with each other's performance, even when threads only ever touch one of them.

The reason is that CPUs move memory between cores in chunks called cache lines, typically 64 bytes. When core A writes to an atomic, the cache line containing that atomic gets invalidated on every other core. The next core that wants to read or write anywhere in that 64-byte chunk has to fetch it back, which can cost hundreds of cycles. If two unrelated atomics share a cache line, threads working on them effectively contend even though they're touching different variables. This is called false sharing, and it can make a "lock-free" design slower than a mutex-based one.

alignas(64) tells the compiler to start each field on a 64-byte boundary, which guarantees they live in different cache lines. The cost is wasted bytes, but if two threads contend on those fields, the speedup can be an order of magnitude. C++17 added std::hardware_destructive_interference_size in <new>, which is the standard's way of asking the implementation what the cache line size is, instead of hardcoding 64.

Cache line bouncing isn't theoretical. A benchmark of two threads incrementing adjacent atomics can run 5-10x slower than the same threads incrementing atomics that are 64 bytes apart. When designing high-throughput data structures, lay out shared state so contended atomics don't share cache lines.

Lock-Free Programming as a Discipline

Using atomics correctly is harder than it looks. A counter or a flag is one thing; building a lock-free queue, stack, or hash map is a different sport. You're not just guarding state, you're constructing invariants that hold across many threads simultaneously, with no central authority to enforce order.

The CAS loop is the workhorse. Almost every lock-free algorithm reduces to: "compute what I want the next state to be, then atomically swap the old state for the new one if nothing changed in the meantime." But the devil is in details like:

  • ABA problem: A thread reads value A, gets preempted, another thread changes it to B and back to A, then the original thread's CAS succeeds even though state has changed in ways that matter. The fix is usually a version counter packed into the same word.
  • Memory ordering: Atomic accesses to different variables can appear to reorder from another thread's point of view. Sequential consistency (the default in this chapter) hides this, but at a cost.
  • Reclamation: Freeing memory in a lock-free data structure is hard, because you can never be sure no other thread is still reading the object you want to delete. Solutions include hazard pointers, RCU, and epoch-based reclamation, and they're all subtle.

The takeaway: std::atomic lets you build counters, flags, and simple CAS-based updates with confidence. Use it for those. For full lock-free data structures, prefer a battle-tested library (Boost.Lockfree, Folly, Intel TBB) over rolling your own. The bugs in homegrown lock-free code are almost impossible to reproduce in tests, because they only show up under specific scheduling and cache states. Production-grade lock-free code is written by people who specialize in it.

Quiz

Atomic Operations Quiz

10 quizzes