In isolated process models, every application possesses a private virtual address space and executes independently. However, high-performance computing requires processes and threads to collaborate, communicate, and share common resources: heap structures, database records, kernel tables, and I/O buffers.
When multiple concurrent entities access shared mutable data without coordination, the execution outcome becomes dependent on the microsecond timing of the hardware scheduler. This non-deterministic vulnerability is known as a race condition.
In preceding modules, we explored how the operating system virtualizes physical memory to give each running program its own isolated address space. When processes run in complete isolation, one program cannot corrupt another's memory lines.
However, modern operating systems are inherently collaborative and concurrent. Multi-threaded applications share a single unified address space (heap, global variables, open file descriptors), while cooperating processes frequently map shared memory segments (via POSIX shmget(2) or mmap(2)) or coordinate via kernel buffers.
The moment multiple concurrent execution flows share mutable state without deterministic synchronization, the execution outcome becomes a function of physical thread interleaving, interrupt timings, and core bus arbitration. This non-deterministic vulnerability is a race condition.
A foundational model of race conditions is the print spooler directory:
spool_dir[0], spool_dir[1], ..., spool_dir[N-1].out: points to the next file slot to be printed by the printer daemon.in: points to the next available empty slot where a client process may deposit a file name."annual_report.pdf". It reads the shared variable in (which currently equals 7) and stores next_free_slot = 7 in its private local register.spool_dir[7], its time quantum expires. The kernel context-switches from Process A to Process B."thesis.pdf". It reads the shared variable in, which is still 7 (because A never updated it).spool_dir[7] = "thesis.pdf", increments in from 7 to 8, updates the shared variable in memory, and terminates its request.next_free_slot = 7. Process A blindly executes spool_dir[7] = "annual_report.pdf", completely overwriting Process B's file name. Process A then calculates 7 + 1 = 8 and writes in = 8 back to memory.out, prints Process A's file at slot 7, advances to slot 8, and never sees Process B's job. Process B sits waiting indefinitely for output that will never emerge.The same hazard manifests in financial transaction systems, database row updates, and operating system kernel accounting tables.
Consider two concurrent threads executing in a banking daemon sharing an account variable initialized to $1000:
balance += 100;balance -= 50;Under serial execution, addition and subtraction are commutative:
Balanceinitial = $1000
Order 1 (T1 then T2): $1000 + $100 = $1100 → $1100 − $50 = $1050
Order 2 (T2 then T1): $1000 − $50 = $950 → $950 + $100 = $1050
Yet under concurrent multi-threaded execution, if a context switch intervenes between reading balance and storing the result, the ledger balance can permanently end up as 1100 (the withdrawal is lost) or 950 (the deposit is lost).
A common student question is: "If modern multi-core processors have gigabytes of RAM, why can't the compiler just keep shared variables synchronized automatically?"
Optimizing compilers make the problem significantly worse unless explicitly instructed otherwise:
%ebx). Any modifications made by another core in physical RAM are completely ignored by the loop.data = 42; ready = 1; may be retired by the processor as ready = 1; data = 42;, exposing partially constructed memory to other threads.volatile Misconception: In C and C++, the volatile keyword tells the compiler not to optimize away reads and writes to a memory location. However, volatile does NOT provide atomic synchronization, does NOT issue CPU memory bus locks, and does NOT prevent hardware core reordering. True synchronization requires mutual exclusion primitives.
At the high-level language level (C, C++, Rust, Java, or Python), statements like counter++ or balance += 100 appear as single, indivisible operations. This syntactic simplicity fosters a dangerous cognitive trap: programmers assume that because an operation occupies a single line of code, the hardware executes it as an indivisible, atomic transaction.
In physical computer hardware, this assumption is false. Central Processing Units (CPUs) do not perform arithmetic directly across silicon DRAM capacitors. Arithmetic Logic Units (ALUs) operate strictly on internal registers located within the processor core. Consequently, every mutation of shared memory requires a multi-stage Load-Modify-Store sequence.
When an optimizing compiler lowers counter++ into target machine code, it decomposes the operation into three distinct assembly instructions:
; --- Disassembly of counter++ (x86-64 Architecture) ---
mov eax, [0x7fff0040] ; 1. LOAD: Read 32 bits from shared memory address into register EAX
add eax, 1 ; 2. MODIFY: ALU increments the private value in EAX by 1
mov [0x7fff0040], eax ; 3. STORE: Write the updated EAX register back across the bus to RAM
Each of these three instructions executes at a distinct point in physical time:
mov eax, [addr]): The CPU core issues a read request across the system interconnect. The memory controller fetches the cache line containing the target address into the core's private L1/L2 data cache, and the 32-bit integer is loaded into the general-purpose register %eax.add eax, 1): The core's execution unit passes the contents of %eax through the ALU, computes the addition, and latches the incremented result back into %eax. At this instant, the updated value exists only inside the core's private register. Physical RAM still holds the old, unincremented value.mov [addr], eax): The core issues a write request, flushing the contents of %eax through its internal store buffer into the L1 cache, eventually writing it back to shared physical memory.An operating system's preemptive scheduler uses a hardware timer (such as the x86 Local APIC timer) programmed to generate periodic interrupts at fixed frequencies (typically 100 Hz to 1000 Hz).
When the timer interrupt fires, the CPU hardware completes whichever single assembly instruction is currently in-flight, saves the current Instruction Pointer (%rip) and Processor Flags (%rflags) onto the kernel stack, and transfers execution to the kernel interrupt handler.
%eax holds the old value. The thread is descheduled before the ALU can even compute the update.%eax holds the newly calculated value, but physical RAM has not been updated. The kernel saves %eax to the thread's PCB. If a second thread is dispatched and reads the same memory address, it reads the stale, pre-incremented value from RAM.Students often point out that the x86 architecture features CISC instructions that operate directly on memory addresses, such as:
add dword ptr [counter], 1 ; Single x86 instruction increments memory directly!
Is this single instruction atomic? No.
Even though it appears as a single assembly line, the CPU microarchitecture decodes this instruction into multiple micro-operations (μops):
μop 1: Memory read (Load into internal micro-register).μop 2: ALU addition.μop 3: Memory write (Store).On a single-core system, a timer interrupt will not interrupt a single instruction midway through its micro-operations. However, on modern multi-core SMP systems, multiple execution cores share the same memory bus. Core 1 can execute a memory read simultaneously while Core 0 is between μop 1 and μop 3, reading stale data.
To make even a single instruction atomic across multiple hardware cores, the instruction must be explicitly prefixed with the x86 hardware bus lock:
lock add dword ptr [counter], 1 ; ATOMIC: Locks cache line across all CPU cores
The lock prefix asserts a hardware lock on the processor's memory cache line (via cache coherency protocols like MESI), forcing all other CPU cores to stall if they attempt to access that memory line until the entire Load-Modify-Store operation completes.
Writing correct concurrent systems requires understanding two distinct hardware properties:
Core 0 issues a 32-bit read request to memory address 0x7FFF0040. The value $1000 is loaded into register EAX.
CPUs cannot perform arithmetic directly across DRAM lines; operands must be fetched into high-speed silicon registers before ALU calculation.
The discovery of non-atomic assembly execution reveals an unavoidable systems dilemma: to avoid race hazards, concurrent execution flows must be prevented from reading or writing shared mutable data simultaneously.
This requirement leads directly to the core abstraction of concurrent programming: the Critical Region (often termed the Critical Section).
A Critical Region is a contiguous segment of code in which a thread or process accesses one or more shared mutable resources—such as global variables, heap structures, hardware device registers, or shared file pointers—whose consistency depends on atomic execution.
The operational objective of synchronization is to enforce Mutual Exclusion:
The Mutual Exclusion Invariant: At any physical instant in time t, if Process A is executing within its critical region, all other processes must be strictly barred from entering their critical regions for that same shared resource.
Any concurrent thread accessing shared state can be decomposed into four distinct execution phases:
A cardinal rule of operating system engineering is to keep the critical section as small as humanly possible.
Critical sections serialize execution: while Thread A is inside, no other core can make progress on that shared data. If an application holds a lock while performing disk I/O, network requests, or heavy cryptographic hashing:
Speedup ≤ 1 / Serial_Fraction
Why can't we solve mutual exclusion with a simple software integer flag?
Consider the most intuitive attempt by a programmer to protect a critical region using a shared variable lock (initialized to 0, meaning unlocked):
/* NAIVE ATTEMPT: Flawed Software Lock Variable */
int lock = 0; /* 0 = unlocked, 1 = locked */
void worker() {
/* Entry Section */
while (lock == 1) {
/* Spin and wait until lock becomes 0 */
}
lock = 1; /* Claim the lock */
/* Critical Section */
balance += 100;
/* Exit Section */
lock = 0; /* Release the lock */
}
while check and setting lock = 1:
lock == 1. It evaluates to false (lock is 0).lock = 1, the timer interrupt fires and deschedules Thread 1.lock == 1. Because Thread 1 was preempted before setting the flag, lock is still 0!while loop, sets lock = 1, and enters the Critical Section.while loop. It executes lock = 1 and enters the Critical Section.This fundamental bootstrapping problem proves that software flags alone cannot guarantee mutual exclusion without underlying hardware atomic primitives or formal coordination algorithms (such as Peterson's Algorithm).
Designing a mechanism to prevent race conditions is deceivingly difficult. A naive synchronization protocol might successfully keep two processes from colliding inside a critical region, but introduce subtle pathologies: deadlocking the system, freezing fast processes while slow processes compute unrelated code, or starving a process indefinitely.
To be considered correct, robust, and general-purpose, any candidate solution to the mutual exclusion problem must satisfy all four of the following mandatory conditions:
"No two processes may be simultaneously inside their critical regions."
This is the fundamental safety property of synchronization: nothing bad happens.
"No assumptions may be made about speeds or the number of CPUs."
The synchronization algorithm must be mathematically invariant to hardware performance:
for (int i = 0; i < 100000; i++); expecting another thread to finish an operation. On a faster processor or under heavy interrupt load, the delay finishes too early, causing the synchronization barrier to collapse."No process running outside its critical region may block other processes."
This is a foundational liveness property: if no process is currently in the critical region, and some processes want to enter, only those processes competing to enter may participate in the decision, and the selection cannot be postponed indefinitely.
To understand how Condition 3 is violated, examine the classic flawed attempt known as Strict Alternation:
/* FLAWED ATTEMPT: Strict Alternation via Turn Variable */
int turn = 0; /* turn == 0 → P0 runs; turn == 1 → P1 runs */
/* --- Process 0 Code --- */
while (true) {
while (turn != 0); /* Entry section: wait for turn */
critical_region();
turn = 1; /* Exit section: pass turn to P1 */
remainder_section(); /* Remainder: non-critical work */
}
/* --- Process 1 Code --- */
while (true) {
while (turn != 1); /* Entry section: wait for turn */
critical_region();
turn = 0; /* Exit section: pass turn to P0 */
remainder_section(); /* Remainder: non-critical work */
}
turn = 1, and enters its long remainder_section() to compute complex physics equations.turn = 0, finishes its short remainder section, and loops back to enter the critical region a second time.while (turn != 1). Because Process 0 is still busy in its remainder section, turn is still 0!"No process should have to wait forever to enter its critical region."
A process requesting entry to a critical region must eventually be granted admission. There must exist a deterministic upper bound on the number of times other processes are allowed to enter their critical sections after a process has declared its intent to enter:
| Synchronization Candidate | Cond 1: Mutex | Cond 2: Hardware Ind. | Cond 3: Progress | Cond 4: Bounded Wait |
|---|---|---|---|---|
| Disabling Interrupts | Yes (Uniprocessor only) | NO (Fails on SMP) | Yes | NO (User code can hang OS) |
| Naive Lock Variable | NO (Race on lock itself) | Yes | Yes | NO (Unbounded race) |
| Strict Alternation | Yes | Yes | NO (Remainder blocks entry) | NO (Blocked if peer halts) |
| Peterson's Algorithm | Yes | Yes | Yes | Yes |
| Hardware TSL / CAS | Yes | Yes | Yes | Yes (With fair queueing) |