01. Race Conditions & Critical Regions

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.

1. The Shared State Hazard

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.

The Classical Print Spooler Directory Hazard

A foundational model of race conditions is the print spooler directory:

Figure 1.1: The Print Spooler Directory Race Condition
Process A and Process B read the same slot index, causing Process A to silently obliterate Process B's print job.
Slot 5 doc_old.pdf Slot 6 (out) report.ps Slot 7 (in = 7) Proc B: "thesis.pdf" ← OVERWRITTEN BY A! Slot 8 (in becomes 8) [Unused / Skipped] Slot 9 Process A 1. Reads in = 7 3. Resumes: writes slot 7, in=8 Process B 2. Reads in = 7, writes "thesis.pdf" Updates in = 8 & completes
Step-by-Step Interleaving Breakdown
  1. T = 0: Process A decides to print "annual_report.pdf". It reads the shared variable in (which currently equals 7) and stores next_free_slot = 7 in its private local register.
  2. T = 1 (Preemption): Before Process A can write its file name to spool_dir[7], its time quantum expires. The kernel context-switches from Process A to Process B.
  3. T = 2: Process B decides to print "thesis.pdf". It reads the shared variable in, which is still 7 (because A never updated it).
  4. T = 3: Process B writes spool_dir[7] = "thesis.pdf", increments in from 7 to 8, updates the shared variable in memory, and terminates its request.
  5. T = 4 (The Disaster): Process A is rescheduled. It resumes execution right where it was paused. Its local register still holds 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.
Operational Consequences of the Race Condition:

The Shared Bank Balance Anomaly

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:

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).

Why Compiler Optimizations Amplify the Hazard

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:

2. Assembly-Level Non-Atomicity

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.

The Anatomy of the 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:

  1. The Load Phase (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.
  2. The Modify Phase (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.
  3. The Store Phase (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.
Figure 1.2: Microarchitectural State During Load-Modify-Store Interleaving
The vulnerable window between register modification inside the core and retirement to shared RAM.
CPU CORE 0 (Thread 1 Context) REGISTER FILE %eax = 1000 Private to Core 0 ALU (+100) × TIMER INTERRUPT FIRES HERE! • Involuntary Context Switch triggered by Local APIC timer. • EAX (1100) saved to PCB; Shared RAM remains stale at 1000! 1. LOAD (1000) 3. STORE (BLOCKED) SHARED PHYSICAL RAM PHYSICAL ADDRESS: 0x7FFF0040 Variable: balance Value: $1000 Cache Line State: Shared Vulnerable to uncoordinated reads from Core 1

Hardware Context Switching Across Instruction Boundaries

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.

The Preemption Vulnerability Window:
The scheduler has zero knowledge of program semantics. It cannot know that instructions 1, 2, and 3 form a logically atomic transaction. If the interrupt fires:

Why Single CISC Instructions Are Still Non-Atomic

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):

  1. μop 1: Memory read (Load into internal micro-register).
  2. μop 2: ALU addition.
  3. μ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.

Atomicity vs. Memory Visibility

Writing correct concurrent systems requires understanding two distinct hardware properties:

Interactive Stepper: The Non-Atomic Assembly Interleaving Hazard

Execution Arc Thread 1 (+100) and Thread 2 (-50) modify shared RAM address [0x7FFF0040]. Demonstrating how preemptive context switches between Load and Store instructions overwrite concurrent updates.
Timeline Clock T = 0 μs
Active Thread Thread 1 (Deposit)
Core Register EAX EAX = Uninitialized
Shared Memory [balance] $1000
Consistency State Nominal
CPU CORE 0 (Execution Engine) RUNNING: Thread 1 (+100) REGISTER %eax (Private Core State): 0x00000000 (0) CURRENT INSTRUCTION: mov eax, [balance] SYSTEM BUS SHARED PHYSICAL MEMORY (DRAM) SHARED VARIABLE: [balance] Physical Address: 0x7FFF0040 $1000 × NO LOCK (RACE HAZARD ACTIVE)
Current Step Summary Thread 1 begins execution on Core 0. It executes 'mov eax, [balance]', fetching $1000 across the system bus into its private register EAX.
What Is Happening

Core 0 issues a 32-bit read request to memory address 0x7FFF0040. The value $1000 is loaded into register EAX.

Why The System Does This

CPUs cannot perform arithmetic directly across DRAM lines; operands must be fetched into high-speed silicon registers before ALU calculation.

3. Critical Regions & Mutual Exclusion

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).

Formal Definition: The Critical Region

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.

The Canonical Four-Stage Execution Lifecycle

Any concurrent thread accessing shared state can be decomposed into four distinct execution phases:

Figure 1.3: The Canonical Four-Stage Synchronization Lifecycle
The mandatory gating sequence surrounding any operation on shared mutable state.
1. ENTRY SECTION acquire(&lock) Requests permission 2. CRITICAL REGION balance += 100; [EXCLUSIVE ACCESS] 3. EXIT SECTION release(&lock) Signals waiting threads 4. REMAINDER Non-critical work Local computations
  1. The Entry Section: The gateway protocol where a thread requests permission to access the shared resource. If another thread currently holds the critical region, the entry section must block the requesting thread (suspending it on a wait queue) or spin until the lock is vacated.
  2. The Critical Section: The isolated execution block containing the actual read-modify-write operations on the shared resource. While a thread is here, mutual exclusion is active.
  3. The Exit Section: The exit protocol executed immediately upon finishing shared state mutations. It releases ownership of the critical region, unblocks waiting threads, and updates internal lock metadata.
  4. The Remainder Section: The remainder of the program's code, performing independent computations on local variables, private stack memory, or unshared resources.

The Principle of Minimal Critical Section Scope

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:

The Naive Software Lock Paradox

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 */
}
Why This Flawed Attempt Fails Catastrophically:
Notice the fatal circular dependency: the lock variable itself is a shared mutable resource!
Look at the preemption window between the while check and setting lock = 1:
  1. Thread 1 checks lock == 1. It evaluates to false (lock is 0).
  2. Context Switch: Right before Thread 1 can execute lock = 1, the timer interrupt fires and deschedules Thread 1.
  3. Thread 2 is dispatched. It checks lock == 1. Because Thread 1 was preempted before setting the flag, lock is still 0!
  4. Thread 2 proceeds past the while loop, sets lock = 1, and enters the Critical Section.
  5. Thread 1 is rescheduled. It resumes execution at the instruction immediately following the while loop. It executes lock = 1 and enters the Critical Section.
Result: Both Thread 1 and Thread 2 are now executing inside the Critical Section simultaneously! The flawed software lock suffers from the exact same race condition it was designed to prevent.

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).

4. The Four Mandatory Conditions for Mutual Exclusion

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:

Figure 1.4: The Four Pathological Failure Modes of Broken Synchronization
How flawed synchronization algorithms fail when any single condition is compromised.
1. SAFETY VIOLATION Mutual Exclusion Failure COLLISION! P1 & P2 inside CS SIMULTANEOUSLY • Memory corrupted. • Non-atomic interleaved stores. 2. HARDWARE ASSUMPTION Speed / Clock Dependency CLOCK DRIFT delay(1000) Fails on faster CPU • CPU GHz mismatch. • Out-of-order reordering. 3. PROGRESS VIOLATION Remainder Interference STRICT ALTERNATION P0 idle in remainder BLOCKS ready P1! • Critical region is empty. • Yet waiting process blocked. 4. BOUNDED WAITING Starvation / Livelock STARVATION P1 bypassed forever By P2, P3, P4... • No upper bound on waits. • P1 hangs indefinitely.

Condition 1: Mutual Exclusion (Safety)

"No two processes may be simultaneously inside their critical regions."

This is the fundamental safety property of synchronization: nothing bad happens.

Condition 2: Hardware Independence

"No assumptions may be made about speeds or the number of CPUs."

The synchronization algorithm must be mathematically invariant to hardware performance:

Condition 3: Progress (Non-Interference)

"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.

The Classic Failure: Strict Alternation (Turn Variable)

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 */
}
Why Strict Alternation Violates Condition 3:
  1. Suppose Process 0 is very fast: it enters the critical region, leaves, sets turn = 1, and enters its long remainder_section() to compute complex physics equations.
  2. Process 1 enters the critical region, completes its work, sets turn = 0, finishes its short remainder section, and loops back to enter the critical region a second time.
  3. Process 1 checks while (turn != 1). Because Process 0 is still busy in its remainder section, turn is still 0!
  4. The Violation: Process 1 is blocked from entering the critical region—even though the critical region is completely empty!
Process 0 is running outside its critical region, yet it is actively preventing Process 1 from entering. This is a catastrophic violation of Condition 3.

Condition 4: Bounded Waiting (Starvation Immunity)

"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:

Comprehensive Criteria Comparison Matrix

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)