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:
- Read the current value at
location. - If current ==
expected, writenewand return true. - 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 += 1is 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.