The idea
What it is
Here is the most surprising line of code in concurrent programming: count++. It looks like one thing. It is actually three things — read the value out of memory, add one to it in a CPU register, write the result back. A thread can be interrupted between any two of those steps. If two threads both read 0, both add one, and both write 1, you ran two increments and gained one. That's a lost update, and it is the single most common concurrency bug there is.
Atomic means indivisible: the whole read-modify-write happens as one CPU instruction that no other thread can split. The hardware primitive that gives us this is compare-and-swap (CAS). You hand it three things — the memory location, the value you expect to find there, and the new value you want to store. In one uninterruptible instruction the CPU checks whether memory still holds expected; if yes it writes the new value and reports success, if no it writes nothing and reports failure. On x86 that's the LOCK CMPXCHG instruction; on ARM it's a load-linked/store-conditional pair.
The mental model that makes this click is editing a wiki page (or a shared doc with version history). You open the page — that's your read, and you note that you're looking at version 7. You type your changes into your own copy — that's your modify, done privately, affecting nobody. Then you hit Save, and the save carries a claim: "this edit is based on version 7". If the server still shows version 7, your save goes through. If someone saved version 8 while you were typing, the server rejects your save and shows you the fresh page — and you redo your edit on top of it. Nobody locked the page. Nobody waited in a queue. The loser of the race just tries again.
The one sentence to remember
CAS is "write my new value only if the old one is still what I read" — one indivisible instruction. Wrap it in a loop that re-reads and retries on failure, and you get a correct counter with no lock, no blocking, and no deadlock.
Where you've already met it
AtomicInteger and LongAdder in Java, std::atomic<int> in C++, sync/atomic in Go, Interlocked.Increment in .NET — all of them are CAS wearing a friendly name. Higher up the stack, the same idea is called optimistic locking: a version column in a database row, an If-Match: "etag" header on an HTTP PUT, or a document's revision number. Same bargain everywhere: don't lock, just detect the conflict at write time and retry.
Mechanics
How it works
Why count++ breaks
Compile count++ and you get roughly three machine steps. Line them up for two threads on the unluckiest possible schedule and the damage is obvious:
- T1 reads
count→ its private copy holds0. - T2 reads
count→ its private copy also holds0. Both threads now have the same stale snapshot. - T1 adds 1 in its own copy →
1. T2 adds 1 in its own copy →1. Neither has touched shared memory yet. - T1 writes
1. T2 writes1— on top of it. Two increments happened,countsays1. One update is gone, and nothing crashed, so nothing tells you.
Python's GIL does not make += atomic
A very common belief: "Python has a Global Interpreter Lock, so my counter is safe." It isn't. The GIL guarantees only that one bytecode-ish step runs at a time — but counter += 1 compiles to several bytecodes (load, add, store), and the interpreter can switch threads between them. You get the exact same lost update. Use a threading.Lock, or an itertools.count() whose __next__ is a single C-level call.
The CAS retry loop
CAS on its own only reports a conflict — it doesn't resolve one. The pattern that resolves it is a tiny loop, and it is the same four lines in every language:
// The canonical CAS retry loop — this IS what incrementAndGet() does inside.
while (true) {
int current = count.get(); // 1. READ ("I'm on version 7")
int next = current + 1; // 2. COMPUTE (private, no sharing)
if (count.compareAndSet(current, next)) { // 3. SWAP IF UNCHANGED (atomic)
return next; // won the race — done
}
// 4. someone wrote first: the swap did nothing.
// Loop, re-read the FRESH value, redo the work on top of it.
}Read that loop against the wiki analogy and every line has a twin: get() is opening the page and noting version 7, current + 1 is typing your edit into your own copy, compareAndSet is hitting Save with "based on version 7", and the loop is being shown the fresh page and redoing your edit. The crucial detail is step 4: on failure you must re-read. Retrying with the same stale current would fail forever.
Lock-free and optimistic
This style is called optimistic because it assumes conflicts are rare and only checks for one at write time — the opposite of a lock, which pessimistically blocks everyone up front just in case. It is also lock-free: no thread is ever parked, no thread ever waits on another, and the operating system never has to suspend and re-schedule anyone. Two consequences fall straight out of that. First, no deadlock — you can't deadlock if nobody ever holds anything. Second, no priority inversion or convoying — a thread that gets descheduled mid-loop cannot block anyone else, because it holds nothing. The system as a whole always makes progress: every failed CAS means some other thread succeeded.
Lock-free ≠ wait-free
Lock-free guarantees that the system always progresses — but one unlucky thread can keep losing the race and spin for a long time. That's called starvation. Wait-free is the stronger promise that every thread finishes in a bounded number of steps; Java's LongAdder gets close by giving each thread its own cell to increment and summing them only when you read.
When CAS beats a lock — and when it loses
- CAS wins for short updates to a single variable — counters, sequence numbers, flags, a head pointer — under low-to-moderate contention. The whole operation is a handful of instructions with no trip into the kernel, so it's dramatically cheaper than acquiring a mutex.
- CAS loses under heavy contention. Every failed attempt is thrown-away work, and it burns CPU while spinning. With dozens of threads hammering one counter, a lock (which parks losers instead of spinning them) or a striped counter like
LongAdder(which spreads writes across many cells) wins comfortably. - CAS can't do multi-variable updates. It protects exactly one memory word. If you must move money from
balanceAtobalanceB, or keepheadandsizeconsistent, two atomics are not an atomic pair — another thread can observe the state between them. Use a lock, or pack the fields into one immutable object and CAS the reference to it (AtomicReference). - CAS loses when the work is long. If computing the new value is expensive, a failed retry wastes a lot of it. Locks are better when the critical section is big.
The ABA problem
CAS compares values, not histories — and that gap has a name. Suppose T1 reads a value A and then gets descheduled. While it sleeps, T2 changes the value to B, does some work, and then changes it back to A. T1 wakes up, does its compareAndSet(A, C), sees A sitting there, concludes "nothing changed", and succeeds — even though the world moved on underneath it. For a plain integer counter that's usually harmless; for a pointer in a lock-free stack it's a real bug, because the A you're looking at may be a recycled node that's already been freed and reallocated.
The fix: a version stamp
Attach a counter that increases on every write, and compare the pair (value, stamp) instead of just the value. Now A#1 and A#3 are visibly different, so the stale CAS correctly fails and retries. Java ships this as AtomicStampedReference (value + int stamp) and AtomicMarkableReference (value + boolean); C++ implementations use a double-width CAS on a tagged pointer. It's the same version column you'd add to a database row for optimistic locking.
Atomics also fix visibility
Lost updates aren't the only hazard. Without synchronisation, a value one thread writes may sit in a CPU cache or register and simply never become visible to another thread — the reader can spin forever on a stale copy. Atomic classes solve this too: in Java, reads and writes on an AtomicInteger have the same memory-ordering guarantees as volatile, so a write by one thread happens-before a subsequent read by another. In C++ you can even choose the strength (memory_order_seq_cst by default, relaxed if you only need atomicity and not ordering). So an atomic gives you two things at once: an indivisible update and a guarantee that everyone sees it.
Interactive prototype
See it. Build it. Break it.
A sandboxed, hands-on simulation — no setup, no install. Play with it as you read.
About this simulation
Two threads, one shared count box, and a deliberately unlucky interleaving. Start on ❌ count++ and press ▶ Step six times: T1 and T2 each read 0 into their own local copy, each do +1, each write 1 — and the box ends red at count: 1 with lost updates 1, even though two increments ran. Now click ⚛️ CAS retry and step through the exact same interleaving: T1's CAS(0→1) succeeds, but T2's shows expected 0 ≠ actual 1 ✗ in red, the retries counter ticks to 1, T2's lane loops back with ↻ retry — fresh read, re-reads 1, and CAS(1→2) lands green at count: 2. Nothing was lost and nobody blocked. The 🔁 ABA chip is a three-click cautionary tale — the value goes A → B → A and CAS wrongly succeeds — and #️⃣ +version re-runs it with a stamp so A#1 ≠ A#3 correctly fails. Use ▶ Run all to watch a mode play itself, ↺ Reset to start over.
Hands-on
Try these yourself
Open the prototype above, predict what happens, then verify.
Break it with ❌ count++
Stay on the ❌ count++ chip and press ▶ Step six times. Watch the local: badges — T1 reads 0, then T2 also reads 0 (same stale snapshot), both do +1 in their own copy, then both write 1. The shared box ends red at count: 1 with lost updates 1, even though two increments ran. That red box is count++ not being one operation.
Fix it with ⚛️ CAS retry
Click ⚛️ CAS retry and step through the identical interleaving. T1's chip now reads CAS(0→1) and lands ✓. T2's shows expected 0 ≠ actual 1 ✗ in red — nothing is written, retries ticks to 1, and its lane loops back with ↻ retry — fresh read. It re-reads 1, recomputes 2, and CAS(1→2) turns the box green at count: 2 with lost updates 0. Nobody blocked; the loser just retried.
Trip over 🔁 ABA, then fix it with #️⃣ +version
Click 🔁 ABA and step three times: T1 reads A and pauses, T2 does A → B → A, and T1's CAS sees A and wrongly succeeds — the box flashes amber, 'Same value, different history'. Now click #️⃣ +version and replay it. The trail reads A#1 → B#2 → A#3 and the comparison becomes expected A#1 ≠ actual A#3 ✗, so the stale CAS correctly fails. Hit ↺ Reset to try any mode again, or ▶ Run all to watch one play itself.
In practice
When to use it — and what trips people up
Reach for an atomic when...
- One variable, short update — a hit counter, a sequence/ID generator, a running total, a shutdown flag.
AtomicLong,std::atomic<int>,atomic.Int64: cheaper than a mutex and impossible to deadlock. - Contention is low to moderate — most attempts succeed on the first try, so you almost never pay for a retry.
- You need lock-free progress — real-time or latency-sensitive code where a thread being descheduled while holding a lock would stall everyone else.
- You're doing optimistic concurrency higher up — a
versioncolumn, an ETag, a document revision. Same loop, bigger scale.
Take a lock instead when...
- Several fields must change together — two atomics are not an atomic pair. Transfer between two balances, or keep
headandsizein step, and you need one critical section (or one immutable object CAS'd through anAtomicReference). - Contention is heavy — dozens of threads on one hot word means mostly wasted retries and burnt CPU. Use a lock, or stripe with
LongAdder/ per-thread counters. - The update is expensive — a long computation thrown away on every failed CAS costs far more than briefly blocking.
- Correctness depends on recycled identity — lock-free pointer structures need an ABA guard (
AtomicStampedReference, tagged pointers, hazard pointers). If that sounds fiddly, that's the signal to use a battle-tested concurrent collection instead of hand-rolling one.
What it gives you
- No blocking and no deadlock — nobody ever holds anything, so threads can't wait on each other in a cycle.
- Very cheap in the common case: a single CPU instruction, no kernel call, no thread parking or context switch.
- System-wide progress guaranteed — every failed CAS means some other thread succeeded, so the program never stalls.
- Atomics also give memory visibility (volatile-like ordering), so other threads actually see the new value.
Common mistakes
- Protects exactly one variable — multi-field invariants need a lock or an immutable object swapped by reference.
- Wasted work under contention: failed retries burn CPU spinning, and an unlucky thread can starve for a long time.
- The ABA problem — CAS compares values, not history, so an A→B→A change slips past unless you add a version stamp.
- Lock-free data structures are notoriously hard to write and review correctly; prefer the library's version over your own.
Reference
Code & further reading
A minimal reference implementation and pointers worth bookmarking.
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicStampedReference;
import java.util.concurrent.atomic.LongAdder;
public class AtomicCounter {
// ❌ BROKEN: count++ is read → add → write. Threads interleave, updates vanish.
private int plain = 0;
public void unsafeIncrement() { plain++; }
// ✅ ATOMIC: the whole read-modify-write is one indivisible instruction.
private final AtomicInteger count = new AtomicInteger(0);
public int increment() {
return count.incrementAndGet(); // library-provided CAS loop
}
// The SAME thing, written by hand — this is the canonical CAS retry loop.
public int incrementByHand() {
while (true) {
int current = count.get(); // 1. read ("version 7")
int next = current + 1; // 2. compute privately
if (count.compareAndSet(current, next)) { // 3. swap IF unchanged
return next; // won the race
}
// 4. someone wrote first → nothing was written. Loop and RE-READ.
}
}
// Any pure function of the old value fits the same loop:
public int doubleIt() {
return count.updateAndGet(v -> v * 2); // retries internally
}
// Heavy contention? Stripe the writes across cells instead of one hot word.
private final LongAdder hits = new LongAdder();
public void hit() { hits.increment(); } // scales far better
public long hitCount() { return hits.sum(); } // combine on read
// ABA fix: compare (value, stamp) so A#1 and A#3 are visibly different.
private final AtomicStampedReference<String> top =
new AtomicStampedReference<>("A", 1);
public boolean casWithStamp(String expected, String next) {
int[] stampHolder = new int[1];
String seen = top.get(stampHolder); // read value AND stamp
return top.compareAndSet(seen, next, stampHolder[0], stampHolder[0] + 1);
}
// ⚠️ Two atomics are NOT an atomic pair — another thread can see the gap.
// Need to update several fields together? Use a lock, or CAS one immutable
// object holding all of them via AtomicReference.
}References & further reading
7 sources- Docsdocs.oracle.com
java.util.concurrent.atomic — Java SE API docs
The package overview spells out the CAS contract, the volatile-like memory semantics, and when to prefer LongAdder over AtomicLong. Start here.
- Articlejenkov.com
Compare and Swap — Jenkov
The clearest beginner walkthrough of the retry loop, with the check-then-act race it fixes and hardware-vs-synchronized comparison.
- Docsen.cppreference.com
std::atomic — cppreference
Reference for compare_exchange_weak/strong (note how 'expected' is refreshed on failure) and the memory_order options C++ exposes.
- Docspkg.go.dev
sync/atomic — Go package docs
Go's atomic types and CompareAndSwap functions, plus the blunt warning that these are low-level primitives most code should avoid.
- Docsen.wikipedia.org
ABA problem — Wikipedia
Short, concrete write-up of the A→B→A trap with the classic lock-free stack example and the tagged-pointer / stamp fixes.
- Papercs.brown.edu
Wait-Free Synchronization — Maurice Herlihy (1991)
The paper that proved compare-and-swap is universal: with CAS you can build any wait-free concurrent object. The theoretical bedrock under all of this.
- Bookjcip.net
Java Concurrency in Practice — Goetz et al.
Chapter 15 ('Atomic Variables and Nonblocking Synchronization') is the definitive practical treatment of CAS, contention, and when locks still win.
Knowledge check
Did it land?
Quick questions, answers revealed on submit. Sign in to save your best score.
question 01 / 06
Why is count++ unsafe when two threads run it at the same time?
question 02 / 06
What exactly does compareAndSet(expected, newValue) do?
question 03 / 06
Your compareAndSet call returns false. What must the retry loop do next?
question 04 / 06
You need to move 100 from balanceA to balanceB. Both are AtomicIntegers. Is that safe?
question 05 / 06
A thread reads A, sleeps, and meanwhile the value goes A → B → A. The thread's CAS then succeeds. What is this called, and how is it fixed?
question 06 / 06
Forty threads hammer one shared counter in a tight loop. What happens with a plain CAS retry loop, and what's the better option?
0/6 answered