Atomic Operations

By the end of this lesson you'll be able to build a thread-safe counter with no mutex, update shared values lock-free with compare-and-swap, write a spinlock from an atomic_flag , and choose the right memory ordering for the job — the toolkit behind high-performance concurrent C++.

Learn Python, JavaScript, Java and more with free interactive lessons, real projects and AI-powered help. Beginner-friendly.

Part of the free C++ course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

A normal counter++ is three steps: read the number, add one , write it back . Picture two people sharing one whiteboard tally. Both read "5", both write "6" — and one count vanished. That's a data race. An atomic operation is like a turnstile counter: each click is one sealed, indivisible action that the hardware guarantees can't overlap another. No locking, no waiting — just a count that's always exactly right. A mutex , by contrast, is the deli ticket queue: safe, but you wait your turn. Atomics skip the queue for the small jobs.

1. Atomic Counters vs Data Races

An atomic<int> guarantees every read and write is indivisible — no other thread can ever catch it half-finished. A plain int shared across threads has no such promise: counter++ compiles to read-add-write, and two threads can interleave those steps and lose updates. The worked example below races eight threads at both kinds of counter so you can see the difference. Read every comment, then run it.

Your turn. The program below counts hits across four threads — fill in the two blanks marked ___ to make the counter atomic and read its final value.

2. load, store, exchange & fetch_add

You don't read an atomic with plain = — you call methods so the intent (and the ordering) is explicit. load() reads, store(x) writes, and exchange(x) writes a new value while handing you the old one, all atomically. fetch_add(n) and fetch_sub(n) add or subtract and return the value before the change. These five cover almost everything you'll do with a single atomic.

3. Compare-and-Swap (CAS) for Lock-Free Updates

fetch_add only handles simple arithmetic. For anything else — "double it", "set it only if it's still what I last saw" — you need compare-and-swap . compare_exchange_strong(expected, desired) swaps in desired only if the value still equals expected ; otherwise it loads the real current value back into expected and returns false . That refresh-on-failure is why CAS lives in a loop: read, compute, try to swap, and retry if someone beat you to it. Use compare_exchange_weak inside that loop (it can fail spuriously but is faster); use strong when you aren't looping.

Now you try. Fill in the three blanks to swap in a value with exchange , then bump it once with a CAS.

4. Spinlocks & Memory Ordering

A std::atomic_flag is the simplest atomic — just set or clear, and the only type guaranteed lock-free on every platform. With its test_and_set / clear pair you can build a spinlock : a lock that busy-loops instead of sleeping, ideal for critical sections so short that sleeping would cost more than spinning.

Every atomic operation also takes a memory ordering that controls how it's ordered against other memory accesses across threads. The three you'll meet: memory_order_relaxed (atomic, but no cross-thread ordering — fine for a lone counter), acquire / release (a paired handshake: a release store publishes everything written before it to whoever does an acquire load — this is what the spinlock uses), and seq_cst (the default: one global order, easiest to reason about, slightly slower). Default to seq_cst and only relax when profiling demands it.

acquire on a load pairs with release on the matching store — that pairing is what safely publishes data from one thread to another. Mismatch them and the compiler/CPU is free to reorder your code.

No blanks this time — just a brief and an outline. Use an atomic<bool> to tell a worker thread when to stop and an atomic<int> to count how far it got. This is the everyday pattern for cleanly shutting a thread down. Build it, run it, and check the shape of your output against the example.

Practice quiz

Why is a plain 'int' shared across threads and incremented with counter++ unsafe?

  • int is too small
  • int cannot be shared at all
  • counter++ is read-add-write, so threads can interleave and lose updates
  • It is only unsafe on one core

Answer: counter++ is read-add-write, so threads can interleave and lose updates. The non-atomic read-modify-write can interleave between threads, losing increments — a data race and undefined behaviour.

What guarantee does an atomic<int> operation provide?

  • Each operation is indivisible; no thread sees it half-done
  • It is faster than a normal int always
  • It locks all other threads out of the program
  • It orders all memory in the program

Answer: Each operation is indivisible; no thread sees it half-done. Atomic operations are indivisible, so concurrent reads/writes never collide mid-operation.

What does exchange(x) on an atomic do?

  • Reads x without changing it
  • Adds x to the value
  • Compares and swaps
  • Writes the new value x and returns the OLD value, atomically

Answer: Writes the new value x and returns the OLD value, atomically. exchange writes the new value and hands back the previous one in a single atomic step.

fetch_add(1) on an atomic returns:

  • The new value after adding
  • The value BEFORE the addition
  • Always 1
  • void

Answer: The value BEFORE the addition. fetch_add (and fetch_sub) return the value as it was before the change.

compare_exchange_strong(expected, desired) when the value does NOT equal expected:

  • Writes the actual current value into 'expected' and returns false
  • Throws an exception
  • Sets the value to desired anyway
  • Blocks until they match

Answer: Writes the actual current value into 'expected' and returns false. On failure it refreshes 'expected' with the real current value and returns false — which is why CAS lives in a loop.

Why prefer compare_exchange_weak inside a retry loop?

  • It never fails
  • It is the only one that compiles in a loop
  • It may fail spuriously but is faster, and you are looping anyway
  • It locks the atomic

Answer: It may fail spuriously but is faster, and you are looping anyway. weak can fail for no real reason on some hardware, but is cheaper; in a loop the spurious failure just retries.

What is the ABA problem?

  • Two atomics with the same name
  • A value goes A→B→A, so a CAS succeeds even though state changed and changed back
  • Adding before subtracting
  • A deadlock between atomics

Answer: A value goes A→B→A, so a CAS succeeds even though state changed and changed back. CAS only checks the value still equals 'expected', not that it never changed; a version counter is the classic fix.

What does memory_order_relaxed guarantee?

  • A single global order across threads
  • Nothing at all
  • Acquire-release semantics
  • The operation is atomic, but gives no cross-thread ordering against other variables

Answer: The operation is atomic, but gives no cross-thread ordering against other variables. Relaxed makes the op itself atomic but adds no ordering — fine for a standalone counter, wrong for publishing other data.

In a spinlock, lock() uses memory_order_acquire and unlock() uses release. Why?

  • For speed only
  • The acquire/release pair publishes the protected data safely between threads
  • To make the lock recursive
  • Because relaxed would not compile

Answer: The acquire/release pair publishes the protected data safely between threads. The release on unlock pairs with the acquire on the next lock, so writes inside the critical section become visible.

Which type is guaranteed lock-free on every platform and is used to build a spinlock?

  • std::atomic<int>
  • std::mutex
  • std::atomic_flag
  • std::atomic<bool>

Answer: std::atomic_flag. std::atomic_flag with test_and_set / clear is the only atomic type guaranteed lock-free everywhere.