Guide

An atomic operation happens in one indivisible step, so no other thread can see it half done. An atomic add on a counter can't lose an update the way a plain counter += 1 does. Atomics avoid the cost and blocking of a mutex, which matters for hot counters, flags and queue indexes.

They come with a catch. Atomic operations on one variable are safe, but the order in which other variables become visible is a separate question. That is what memory ordering controls.

Walking through it: compare-and-swap

The core atomic operation is compare-and-swap (CAS). It takes an expected value and a new value. If the variable still holds the expected value, it stores the new one and reports success. Otherwise it changes nothing and reports failure. The CPU does the whole thing in one step.

Node's Atomics API exposes CAS on a shared buffer. Here the cell starts at 5:

JavaScript
const cell = new Int32Array(new SharedArrayBuffer(4))
Atomics.store(cell, 0, 5)
console.log(Atomics.compareExchange(cell, 0, 5, 9), cell[0])
console.log(Atomics.compareExchange(cell, 0, 5, 7), cell[0])

Output:

5 9
9 9

The first call matches the expected 5, so it writes 9. It returns the old value, 5. The second call expects 5, but the cell holds 9, so it changes nothing and returns 9.

A lock-free update is a CAS retry loop: read the current value, compute the new one, try to swap it in, and repeat if the swap fails. Each failed attempt means some other thread succeeded, so the system as a whole keeps moving. A single thread can still starve, so lock-free is not wait-free.

Memory ordering

Compilers and CPUs reorder reads and writes, and each core has its own cache. Without synchronization, thread B may see a flag set to true before it sees the data the flag was supposed to publish.

The standard fix is a pair of ordering operations. A release store makes all earlier writes visible before the flag is set. An acquire load, on the thread that sees the flag, makes those writes visible after the read. Together they create a happens-before relationship. Everything the writer did before its release store is guaranteed to be visible to the reader after its acquire load sees that store.

The higher-level languages do this for you. Locks, channels and atomic types with their default ordering all establish happens-before. Pick the weakest ordering only when you have measured a need and understand the proof. Otherwise use seq_cst in C++ or Rust, or a mutex.

Warning

Testing does not reveal ordering bugs well. They show up on some CPUs, under load, and not on your laptop. Use the language's memory model and a race detector, not a passing test.

When not to use atomics

Atomics suit single values, such as a counter, a flag or a pointer swap. They don't protect a structure with several fields. If an invariant spans two variables, you need a lock. Clever lock-free code is also hard to get right and hard to review, so reach for it only after profiling shows a lock is the bottleneck.

Common mistakes

  • Using relaxed ordering to save a few nanoseconds, and then publishing data through a plain flag.
  • Using atomics on two related fields, which can't be updated together.
  • Writing a CAS loop that never backs off under heavy contention.

Check yourself

  • Why does a failed CAS not break a lock-free loop? Answer: the failure means another thread succeeded, so the loop retries from the new value and progress continues.
  • Why does a release store plus an acquire load fix the flag-and-data bug? Answer: the release makes the earlier data write visible before the flag, and the acquire ensures the reader sees the data after it sees the flag.

Key takeaways

  • An atomic operation is indivisible, so one word can be updated without a lock.
  • Compare-and-swap is the building block for lock-free retry loops.
  • Memory ordering controls what other threads may see. Release and acquire make writes visible across threads.
  • Use a mutex for multi-field invariants, and reach for atomics only after profiling.

Further learning

Next, see deadlock and livelock, the failure you get when threads wait on each other.