bigonotes
CS ConceptsOS Fundamentals

Atomic Operations

How hardware instructions like compare-and-swap update one value without a lock, and when you still need a mutex.

4 min readLast updated OS FundamentalsListen · Watch on NotebookLM

An operation is atomic if it happens as one indivisible step: no other thread can ever see it half done. CPUs provide atomic instructions that let you update a single value safely without a lock. They are the fastest synchronization tool you have, and every lock is built on top of them.

The problem they solve#

counter += 1 is not atomic. It is three steps:

Step Action Value
1 Read counter from memory 5
2 Add 1 6
3 Write counter to memory 6

Another thread can run between any two steps. That's how a race condition loses updates.

An atomic increment does all three as one unbreakable step, guaranteed by hardware:

Thread Call Effect
Thread 1 atomicIncrement(counter) 5 becomes 6
Thread 2 atomicIncrement(counter) 6 becomes 7

There is no lost update and no lock.

Compare-and-swap (CAS)#

The workhorse instruction is compare-and-swap:

CAS(location, expected, new) does this, atomically:

  1. Read the current value at location.
  2. If current == expected, write new and return true.
  3. Otherwise, write nothing and return false.

The hardware makes the read-compare-write a single operation; no other core can touch that location in between.

Here is a trace starting from counter = 5:

Step Thread Call Current value Result
1 Thread 1 CAS(counter, 5, 6) 5, matches Writes 6, returns true
2 Thread 2 (slightly later) CAS(counter, 5, 6) 6, doesn't match No write, returns false
3 Thread 2 retries with the fresh value CAS(counter, 6, 7) 6, matches Writes 7, returns true

That retry loop is how lock-free code works in languages that expose hardware atomics directly.

Python has no atomic integer type and no CAS in the standard library. Even counter += 1 on a plain int is several bytecode steps, so threads can interleave in the middle. The idiomatic equivalent is a counter guarded by a lock:

import threading

class Counter:
    """Thread-safe counter: the lock makes read-add-write one step."""

    def __init__(self) -> None:
        self._value = 0
        self._lock = threading.Lock()

    def increment(self) -> int:
        with self._lock:
            self._value += 1
            return self._value

    @property
    def value(self) -> int:
        with self._lock:
            return self._value

counter = Counter()

def work() -> None:
    for _ in range(10_000):
        counter.increment()

threads = [threading.Thread(target=work) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()
print(counter.value)  # always 40000

The lock gives the same guarantee (no lost updates), but it is a lock, not a single hardware instruction.

The key limitation: one memory location#

One atomic instruction operates on one memory location:

Atomic can Atomic cannot
Increment one counter Update balance and append a log entry
Swap one pointer Swap two variables together
Set one flag Update a record with five fields

When several values must change together, you need a mutex:

with mutex:                     # mutex = threading.Lock()
    balance -= 100              # variable 1
    log.append(transaction)     # variable 2
    # Nobody can see the new balance without the matching log entry

With two separate atomic updates, another thread could observe the balance changed but the log not yet updated. (Some architectures offer a double-width or double compare-and-swap, but it is rare and hardware-specific. The safe rule: atomic = one variable.)

Atomic vs mutex#

Atomics are fast because they are a CPU instruction. A contended mutex may involve the OS putting threads to sleep and waking them, which costs context switches.

Atomic operation Mutex
Scope One variable, one operation Any number of operations
Speed Very fast (hardware instruction) Slower under contention (OS, sleep/wake)
Waiting Never sleeps; retries on failure Waiter sleeps
Use case Counters, flags, single swaps Multi-step critical sections
Deadlock risk None Yes

Use an atomic variable when you protect a single primitive value (a counter, a boolean flag, one reference) and want minimal latency. Use a mutex when an invariant spans more than one value.

Where you see atomics in practice#

Atomics show up wherever a single value is shared between threads.

Use Example
Counters Atomic integers for request counts and metrics
Flags An atomic boolean for "shutdown requested?" (in Python, threading.Event fills this role)
References Atomic references for lock-free data structures
Locks Spinlocks are built on CAS or test-and-set
Concurrent maps Concurrent hash maps use CAS for some bucket updates

The synchronization stack#

Each layer of synchronization is built on the one below it. From top to bottom:

Layer Tools Built on
Application threading.Lock, threading.Semaphore, asyncio.Lock OS primitives
OS Mutexes, semaphores (wait queues + kernel help) Kernel spinlocks
Kernel Spinlocks Hardware atomics
Hardware Atomic instructions (CAS, test-and-set, fetch-and-add) Nothing: this is the base

Atomic instructions are the foundation everything else rests on.

Key takeaways#

  • x += 1 is read, modify, write; an atomic instruction does it as one step.
  • CAS writes only if the value is still what you expected; on failure you reload and retry.
  • Atomics cover exactly one memory location; invariants across several values need a mutex.
  • Atomics never sleep and can't deadlock, which makes them ideal for counters and flags.
  • Spinlocks, mutexes and semaphores are all built on top of atomic instructions.

Found a mistake? Tell me on LinkedIn ↗

Further reading#