Why count++ Is Not Atomic: An Assembly and Micro-Operation Deep Dive
In concurrent programming, every software engineer learns that when two threads update the same shared variable concurrently without synchronization, the final result can be inconsistent. If two threads each execute count++ starting from 0, the expected result is 2, yet the actual outcome is often 1.
While high-level programming languages represent count++ as a single statement or operator, it is fundamentally not atomic. To understand why, we have to look beneath language syntax and examine the generated assembly instructions and underlying hardware micro-operations.
The High-Level Concurrency Problem
Consider a basic C program where a global counter is incremented by a thread function:
#include <stdio.h>
int count = 0;
int main() {
count++;
return 0;
}
If two threads execute this increment logic simultaneously:
Initial State: count = 0
Thread 1: count++
Thread 2: count++
Expected Final State: count = 2
Possible Actual State: count = 1
Even though count++ appears to be a single source-level statement, the underlying system does not execute it as an indivisible, atomic transaction.
Inspecting Assembly Output with GCC
To observe how the C compiler translates count++, compile the program to assembly instead of a machine executable using the -S flag:
gcc -S main.c
This generates an assembly source file main.s. Inside the generated assembly for main, the relevant instructions typically look like this (x86_64 architecture):
movq count@GOTPCREL(%rip), %rax
addl $1, (%rax)
xorl %eax, %eax
ret
Dissecting the Assembly Instructions
movq count@GOTPCREL(%rip), %rax:
- Reads the memory address of the global variable
count from the Global Offset Table (GOT) using RIP-relative addressing.
- Loads the address into the 64-bit general-purpose register
%rax.
addl $1, (%rax):
- Dereferences
%rax (the memory address of count).
- Increments the 32-bit integer value stored at that location by
1.
xorl %eax, %eax:
- Bitwise XORs register
%eax with itself, clearing it to 0 (the standard x86 idiom for setting the return value of main to 0).
ret:
- Returns from the function.
The Fallacy: Why Assembly Instructions Can Mislead
Looking at the assembly sequence above, it might appear that the increment happens in a single instruction:
addl $1, (%rax)
If a context switch occurs, CPU thread scheduling saves the thread’s architectural register state (including %rax, the instruction pointer %rip, and flags) into memory (e.g., Thread Control Block or kernel stack) and restores the other thread’s state. When Thread 1 yields after loading %rax, Thread 2 loads the same address into its restored %rax. If addl $1, (%rax) were truly atomic, both threads would sequentially increment the memory location, yielding 2.
However, assembly language is itself an abstraction. Human-readable assembly instructions do not always map 1:1 to single, indivisible hardware events.
The Underlying Reality: CISC Instructions and Micro-Operations (μops)
Modern x86 processors are CISC (Complex Instruction Set Computer) architectures on the outside, but internally decode complex instructions into a series of simpler RISC-like micro-operations (extμops).
An instruction like addl $1, (%rax) operates directly on memory (a memory operand). Because the CPU arithmetic logic unit (ALU) cannot operate directly on external RAM or cache lines, the instruction decoder breaks addl $1, (%rax) into three discrete micro-operations:
graph TD
A["addl $1, (%rax)"] --> B["Micro-op 1: READ / LOAD<br/>Load memory value at [%rax] into temporary register"]
B --> C["Micro-op 2: MODIFY / ALU<br/>Increment temporary register by 1"]
C --> D["Micro-op 3: WRITE / STORE<br/>Store result from temporary register back into [%rax]"]
Internally, this read-modify-write cycle operates roughly as:
- Load (Read):
movl (%rax), %temp — Fetch the current value of count from memory into an internal CPU staging register.
- Modify (ALU):
addl $1, %temp — Add 1 to the value inside the CPU execution core.
- Store (Write):
movl %temp, (%rax) — Flush the incremented value back out to the memory hierarchy (cache/RAM).
While each individual micro-operation is atomic, the sequence of three micro-operations is not atomic by default.
The Interleaving Scenario: Step-by-Step Race Condition
When two threads run concurrently on separate cores or are time-sliced on a single core, their micro-operations can interleave arbitrarily:
sequenceDiagram
autonumber
participant Mem as Memory (count = 0)
participant T1 as Thread 1 (Core 1)
participant T2 as Thread 2 (Core 2)
T1->>Mem: 1. LOAD: Read count (0) into Temp Register A
Note over T1: Context Switch or Core Interleaving
T2->>Mem: 2. LOAD: Read count (0) into Temp Register B
T1->>T1: 3. MODIFY: Temp Register A = 0 + 1 = 1
T2->>T2: 4. MODIFY: Temp Register B = 0 + 1 = 1
T1->>Mem: 5. STORE: Write Temp Register A (1) to count
Note over Mem: Memory count is now 1
T2->>Mem: 6. STORE: Write Temp Register B (1) to count
Note over Mem: Memory count overwritten with 1 (Lost Update!)
Execution Trace
- State:
count = 0 in memory.
- Thread 1 reads
count (0) into its internal temporary register.
- A context switch occurs, or Thread 2 concurrently reads
count (0) into its temporary register.
- Thread 1 increments its temporary register (
0 + 1 = 1).
- Thread 2 increments its temporary register (
0 + 1 = 1).
- Thread 1 writes
1 back to count in memory.
- Thread 2 writes
1 back to count in memory, overwriting Thread 1’s write.
Result: Despite two increment operations, count ends up as 1 instead of 2. This is a classic lost update race condition.
Achieving True Atomicity at the Hardware Level
To make an increment atomic on x86 architectures, the read-modify-write pipeline must be guarded so that no other processor or bus agent can access that memory location during the operation.
This is done using the hardware LOCK prefix or compiler intrinsics:
1. The Assembly LOCK Prefix
lock addl $1, (%rax)
The lock prefix asserts hardware-level bus/cache locking (via cache coherency protocols such as MESI/MOESI). It ensures that the core modifying the memory line holds exclusive ownership throughout the read, modify, and write phases, preventing any intervening reads or writes from other cores.
2. High-Level C11 / C++11 Atomics
In modern standard C and C++, atomics should be used instead of raw increments:
#include <stdatomic.h>
atomic_int count = 0;
void increment() {
atomic_fetch_add(&count, 1); // Emits atomic instructions (e.g., lock xadd or lock addl)
}
Key Takeaways
- Single lines of code are not single units of execution: A single statement like
count++ translates into assembly that accesses memory, which subsequently decomposes into hardware micro-operations.
- Assembly is an abstraction layer: An instruction like
addl $1, (%rax) looks like a single step, but on modern CISC processors, memory-modifying instructions are broken down into Read-Modify-Write (Load-ALU-Store) micro-operations.
- Atomicity requires hardware guarantees: Because individual micro-operations can interleave across threads and cores, atomic read-modify-write behavior requires explicit hardware primitives, such as the x86
LOCK instruction prefix or atomic memory builtins.