Intermediate13 min readConcurrency & Thread Safetylive prototype

Atomic operations & CAS

An atomic operation is one that no other thread can cut in half — the read, the change and the write happen as a single indivisible step. Compare-and-swap (CAS) is the hardware instruction that makes it possible: "if this value is still what I last read, write my new one; otherwise tell me it changed." Think of editing a wiki page: you copy it, edit your copy, and hit Save with "I based this on version 7". If someone already saved version 8, your save is rejected and you re-open the fresh page and redo your edit. Nobody ever locked the page — you just retried.

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 holds 0.
  • T2 reads count → its private copy also holds 0. 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 writes 1 — on top of it. Two increments happened, count says 1. 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:

java
// 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 balanceA to balanceB, or keep head and size consistent, 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.

try 01

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.

try 02

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.

try 03

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 version column, 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 head and size in step, and you need one critical section (or one immutable object CAS'd through an AtomicReference).
  • 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

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