02. Hardware Primitives & Spinlocks

In Module 01, we established that software flags fail to enforce mutual exclusion because the flag check and flag set operations are inherently non-atomic. A preemptive context switch occurring between the test and the store destroys the synchronization barrier.

To build bulletproof mutual exclusion, operating systems rely on specialized architectural features provided directly by CPU silicon: hardware interrupt controls, formal algorithmic coordination, and atomic read-modify-write instructions.

1. Disabling Interrupts (Hardware Masking)

The earliest and most straightforward technique devised to guarantee mutual exclusion on single-core computer systems was to eliminate the source of preemption at the hardware level: masking the CPU's interrupt mechanism.

An operating system's scheduler cannot preempt a thread on its own initiative. The CPU is purely an instruction-execution engine; it switches control to the operating system dispatcher only when an external hardware interrupt (such as the periodic timer interrupt) or a software trap (such as a system call or page fault) forces the processor into privileged supervisor mode. If hardware interrupts are disabled, the scheduler cannot gain control of the CPU.

Microarchitectural Mechanics: The EFLAGS Register

On x86 and x86-64 architectures, maskable hardware interrupts are gated by a single bit within the CPU's architectural status register: the Interrupt Flag (IF), located at bit position 9 of the EFLAGS / RFLAGS register.

Figure 2.1: Hardware Interrupt Gating via the EFLAGS Register
How the CLI instruction isolates the execution core from timer ticks, and why it fails to protect shared RAM on multi-core systems.
CPU CORE 0 (CLI Executed) LOCAL APIC TIMER 1000 Hz Ticks EFLAGS.IF = 0 [INTERRUPTS MASKED] EXECUTION CORE (Ring 0) cli; /* IF cleared */ critical_section(); /* Preemption impossible */ sti; /* IF set */ CPU CORE 1 & SHARED DRAM INTERCONNECT CPU CORE 1 (Active) EFLAGS.IF = 1 (Unchanged!) mov eax, [shared_mem] × Bypasses Core 0 CLI! SHARED MEMORY Addr: 0x7FFF0040 RACE HAZARD ACTIVE Core 1 reads concurrently

The Reentrancy & Nesting Defect (pushf / popf)

In production operating system kernels, synchronization functions are frequently nested. A naive implementation that simply executes cli on entry and sti on exit introduces a severe bug:

/* BROKEN: Naive CLI/STI Fails Under Nesting */
void update_queue() {
    asm volatile("cli");     /* Interrupts disabled */
    log_event();             /* Calls helper function */
    /* BUG: Interrupts are now ENABLED here prematurely! */
    queue_head = queue_head->next; /* Vulnerable to preemption! */
    asm volatile("sti");
}

void log_event() {
    asm volatile("cli");     /* Disables interrupts */
    write_log_buffer();
    asm volatile("sti");     /* Blindly re-enables interrupts! */
}

When log_event() returns, it blindly executes sti, enabling interrupts before update_queue() has completed its critical queue manipulation.

To support safe nesting, the kernel must save the previous interrupt flag state onto the stack before disabling interrupts, and restore the saved state upon exit:

/* CORRECT: Nested Interrupt Preservation (x86 pushf / popf) */
static inline unsigned long save_flags_and_cli() {
    unsigned long flags;
    asm volatile(
        "pushf\n\t"           /* Push EFLAGS onto architectural stack */
        "pop %0\n\t"          /* Pop EFLAGS value into C variable 'flags' */
        "cli"                 /* Clear IF bit: mask interrupts */
        : "=r"(flags)
        :
        : "memory"
    );
    return flags;
}

static inline void restore_flags(unsigned long flags) {
    asm volatile(
        "push %0\n\t"         /* Push saved flags onto architectural stack */
        "popf"                /* Restore previous IF state to EFLAGS */
        :
        : "r"(flags)
        : "memory"
    );
}

This matches the canonical Linux kernel pattern: local_irq_save(flags) and local_irq_restore(flags).

The Two Fatal Architectural Flaws

Despite its elegance on simple hardware, disabling interrupts is fundamentally inadequate as a general-purpose synchronization mechanism:

1. Protection Ring Violation

If user-space processes (Ring 3) were permitted to execute cli, an untrusted program could enter an infinite loop (while(1);) with interrupts masked.

Because the timer interrupt could never fire, the OS scheduler would be completely powerless to regain control. The entire physical system would freeze instantly.

Consequently, hardware architectures enforce that cli and sti are privileged instructions; attempting to execute them in user mode raises a General Protection Fault (#GP(0)).

2. Multiprocessor (SMP) Ineffectiveness

The cli instruction affects only the specific execution core running the instruction.

On a modern 8-core or 64-core symmetric multiprocessor (SMP), Core 0 clearing its local IF bit does nothing to interrupt controllers on Core 1 through Core 63.

Threads executing concurrently on other physical cores continue fetching, modifying, and retiring memory lines across the shared interconnect bus, completely bypassing the attempted lock and corrupting shared state.

2. Peterson's Algorithm (Software-Only Coordination)

In the early decades of computing, hardware designers did not provide atomic instructions like Test-and-Set or Compare-and-Swap. Computer scientists were faced with a profound theoretical and engineering challenge: Can provably correct mutual exclusion be achieved using only ordinary reads and writes to shared memory?

The Evolution of Software Synchronization

Before Gary Peterson published his breakthrough in 1981, researchers explored several flawed software approaches:

Peterson's Insight (1981)

Gary L. Peterson published a remarkably compact two-process mutual exclusion algorithm that united the intent array (from Attempt 2) with a polite tie-breaker scalar (from Attempt 1):

/* Peterson's Two-Process Mutual Exclusion Algorithm */
#include <stdbool.h>

static volatile bool flag[2] = {false, false}; /* flag[i] == true: Process i wants to enter */
static volatile int turn = 0;                  /* Whose turn is it to yield in a tie? */

void enter_region(int process) {
    int other = 1 - process;     /* The competing process ID (0 or 1) */
    flag[process] = true;        /* 1. Declare intent to enter */
    turn = other;                /* 2. Politely yield tie-breaker priority to peer */

    /* 3. Wait while peer wants to enter AND peer holds the priority */
    while (flag[other] == true && turn == other) {
        /* Busy wait (spin) */
    }
}

void leave_region(int process) {
    flag[process] = false;       /* Withdraw intent upon exit */
}

The genius of Peterson's algorithm lies in line 2: turn = other;. Rather than selfishly claiming "It is my turn!", each process politely declares "I grant you the turn!". The process that executes this assignment last overwrites the turn variable, effectively surrendering priority and forcing itself to wait while the earlier process proceeds into the critical region.

Figure 2.2: Peterson's Tie-Breaker Arbitration Flow
Accurate bounding boxes showing how intent flags and the polite turn variable resolve contention in physical memory.
PROCESS 0 (T0) 1. SET INTENT flag[0] = true 2. YIELD PRIORITY (First) turn = 1 3. WHILE CONDITION EVALUATION turn == 1 is FALSE (overwritten!) ✓ ENTERS CRITICAL REGION SHARED PHYSICAL MEMORY INTENT ARRAY: flag[2] flag[0]=true  |  flag[1]=true TIE-BREAKER SCALAR: turn 1. P0 writes: turn = 1 2. P1 writes: turn = 0 (FINAL) Winner: turn == 0 satisfies P0 turn == 0 forces P1 to spin! PROCESS 1 (T1) 1. SET INTENT flag[1] = true 2. YIELD PRIORITY (Second) turn = 0 3. WHILE CONDITION EVALUATION turn == 0 is TRUE & flag[0]==true ✕ SPINS UNTIL P0 LEAVES

Formal Mathematical Proof of Correctness

We prove that Peterson's algorithm satisfies all criteria for mutual exclusion:

1. Proof of Mutual Exclusion (Safety)

We prove Condition 1 by contradiction. Assume both Process 0 and Process 1 are simultaneously inside their critical sections at time t:

2. Proof of Progress (Non-Interference)

Suppose Process 1 is in its remainder section and has no desire to enter. By definition, flag[1] == false.

When Process 0 arrives at enter_region(0), it sets flag[0] = true and turn = 1. It evaluates:

while (flag[1] == true && turn == 1)

Because flag[1] is false, the compound expression evaluates to false immediately. Process 0 enters the critical region with zero delay. Process 1 running outside its critical section cannot block Process 0. Condition 3 is satisfied.

3. Proof of Bounded Waiting (Starvation Immunity)

Suppose Process 0 is waiting in its while loop while Process 1 is inside the critical region.

When Process 1 exits, it sets flag[1] = false in leave_region(1). This immediately unblocks Process 0, allowing it to enter.

Even if Process 1 immediately loops around and attempts to re-enter, its entry protocol executes:

flag[1] = true;
turn = 0; /* Process 1 yields turn to Process 0! */

By setting turn = 0, Process 1 surrenders priority. Because flag[0] is still true, Process 1 blocks on its own while loop, guaranteeing that Process 0 enters next. Process 0 can be bypassed at most once. Starvation is impossible. Condition 4 is satisfied.

The Modern Silicon Reality: Store Buffers & Memory Fences

Despite its mathematical elegance, Peterson's algorithm fails completely on modern processors (x86, ARM, Apple Silicon, RISC-V) if implemented naively in C or C++.

Why does a mathematically proven algorithm fail in practice? Because Peterson assumed Sequential Consistency (SC): the hardware assumption that all reads and writes across all cores execute in the exact linear order specified by the programmer.

The Hardware Reordering Hazard

Modern CPUs achieve extraordinary performance by utilizing Store Buffers and Out-of-Order (OOO) execution:

The Solution: Explicit Memory Barriers (Fences)

To restore correctness on modern hardware, we must insert architectural Memory Barriers (or compile with strict atomic acquire-release semantics) to force the CPU store buffer to drain before the load executes:

/* Correct Peterson's Algorithm on Modern Out-of-Order Silicon */
#include <stdatomic.h>

atomic_bool flag[2];
atomic_int turn;

void enter_region(int process) {
    int other = 1 - process;
    atomic_store_explicit(&flag[process], true, memory_order_relaxed);
    atomic_store_explicit(&turn, other, memory_order_relaxed);

    /* Full hardware memory barrier (x86 'mfence', ARM 'dmb ish') */
    atomic_thread_fence(memory_order_seq_cst);

    while (atomic_load_explicit(&flag[other], memory_order_relaxed) &&
           atomic_load_explicit(&turn, memory_order_relaxed) == other) {
        /* Busy wait */
    }
}

void leave_region(int process) {
    atomic_store_explicit(&flag[process], false, memory_order_release);
}

The memory_order_seq_cst fence emits an architectural fence instruction (such as mfence on x86 or dmb ish on ARM) that stalls the core until all pending stores in the store buffer have been committed to cache and made visible to all other cores.

Interactive Stepper: Peterson's Algorithm & Store-Buffer Reordering

Arbitration Arc Core 0 (P0) and Core 1 (P1) simultaneously execute Peterson's protocol. Under Sequential Consistency, the polite assignment to turn breaks the tie deterministically.
Timeline Clock T = 0 ns
P0 State & Flag flag[0] = false
P1 State & Flag flag[1] = false
Shared Memory 'turn' turn = 0
Critical Section State Empty (Safe)
CPU CORE 0 (P0) STAGE: IDLE CURRENT INSTRUCTION: enter_region(0) STORE BUFFER (FIFO): [Buffer Empty: Drained] Writes commit immediately SHARED DRAM CELLS SHARED ARRAY: flag[2] flag[0]=F  |  flag[1]=F TIE-BREAKER SCALAR: turn turn = 0 ✓ CRITICAL REGION: VACANT CPU CORE 1 (P1) STAGE: IDLE CURRENT INSTRUCTION: enter_region(1) STORE BUFFER (FIFO): [Buffer Empty: Drained] Writes commit immediately
Current Step Summary Initial State: Both processes are in their remainder sections. Both flags are false. Critical region is completely empty.
What Is Happening

Both processes are idle. Memory holds flags=false, turn=0.

Why The System Does This

Baseline state before concurrent contention begins.

3. Hardware-Assisted Atomic Instructions

Rather than relying on intricate multi-variable software proofs, hardware architects introduced instructions that execute the entire Load-Modify-Store sequence as a single, indivisible hardware transaction.

1. Test-and-Set-Lock (TSL / XCHG)

The classic instruction proposed for mutual exclusion is TSL (or XCHG on x86):

; Conceptual Operation of TSL:
TSL REG, LOCK_VAR
; 1. Reads the value of LOCK_VAR from memory into register REG.
; 2. Stores a non-zero value (typically 1) into LOCK_VAR.
; 3. Operations 1 and 2 occur ATOMICALLY without interruption.

In hardware, the CPU core executing TSL locks the memory bus or enforces exclusive cache ownership (via the MESI cache coherency protocol) so that no other CPU core can read or write the memory word during the instruction's execution.

2. Compare-and-Swap (CAS / CMPXCHG)

The dominant primitive in modern systems (x86 cmpxchg, ARM64 cas) is Compare-and-Swap (CAS). It takes three operands: a memory address (V), an expected old value (A), and a desired new value (B):

bool CAS(int *addr, int expected, int new_val) {
    /* Executes atomically in CPU silicon */
    if (*addr == expected) {
        *addr = new_val;
        return true;  /* Swap succeeded */
    }
    return false;     /* Memory did not match expected value */
}

If the memory contents equal expected, CAS stores new_val and returns success. If another thread intervened and changed the value, CAS leaves memory untouched and returns failure, signaling the caller to retry.

Interactive Stepper: Dual-Core Hardware Atomic Execution (TSL & CAS)

Hardware Scenario Core 0 (Thread 0) and Core 1 (Thread 1) simultaneously compete to acquire a spinlock at memory address [0x80001000]. Tracking bus arbitration, cache coherency, and register state.
Timeline Clock T = 0 ns
Core 0 Register EAX = 1 (Request)
Core 1 Register EBX = 1 (Request)
Physical Lock Memory 0 (UNLOCKED)
MESI Coherence Shared (S)
CPU CORE 0 (Thread 0) REGISTER FILE: %eax = 1 INSTRUCTION: xchg eax, [lock] L1/L2 DATA CACHE: Line 0x80001000 [S] SYSTEM BUS & RAM BUS STATUS: IDLE SHARED LOCK ADDRESS: 0x80001000 0 (FREE) LOCK STATUS: AVAILABLE CPU CORE 1 (Thread 1) REGISTER FILE: %ebx = 1 INSTRUCTION: xchg ebx, [lock] L1/L2 DATA CACHE: Line 0x80001000 [S]
Current Step Summary Initial State: The spinlock at memory address [0x80001000] is 0 (unlocked). Both Core 0 and Core 1 have the cacheline cached in Shared (S) state.
What Is Happening

Both cores prepare to acquire the lock. Core 0 loads 1 into EAX; Core 1 loads 1 into EBX.

Why The System Does This

Lock acquisition requires atomically swapping 1 into memory. The value returned tells the caller whether it acquired the lock (0) or failed (1).

3. The ABA Hazard in Lock-Free Data Structures

While Compare-and-Swap (CAS) enables lock-free queues, stacks, and hash tables, it introduces a subtle vulnerability: The ABA Problem:

  1. Thread 1 reads memory location V, observing value A.
  2. Thread 1 is preempted while preparing to execute CAS(&V, A, C).
  3. Thread 2 runs and modifies V from A to B.
  4. Thread 3 runs and modifies V from B back to A (e.g., freeing a node and reallocating it to the same heap address).
  5. Thread 1 resumes and executes CAS(&V, A, C). The memory contains A, exactly matching expected! CAS succeeds and writes C.

The Defect: Although the memory word matched A, the underlying semantic state of the data structure changed completely during the intervening steps.

Solution: Tagged Pointers. Modern systems pair pointers with a monotonic version counter (e.g., 64-bit pointer + 64-bit counter using 128-bit double-word CAS: CMPXCHG16B on x86-64). Every mutation increments the version counter, transforming A-0 → B-1 → A-2 so that A-0 != A-2, causing CAS to fail safely.

4. Spinlocks & Microarchitectural Costs

Equipped with hardware atomic primitives like TSL, XCHG, and CMPXCHG, we can construct the simplest and most fundamental locking primitive in computer systems: the Spinlock.

A spinlock operates on a straightforward principle: when a thread attempts to enter a critical region and discovers that the lock is already held, it does not yield the CPU or enter a sleep state. Instead, it executes an active, tight polling loop (it spins) repeatedly testing the lock variable until it becomes free.

/* Fundamental Spinlock Implementation (Test-and-Set) */
typedef struct {
    volatile int lock; /* 0 = Unlocked, 1 = Locked */
} spinlock_t;

void spin_lock(spinlock_t *sl) {
    /* Atomically swap 1 into sl->lock; spin while previous value was 1 */
    while (__atomic_test_and_set(&sl->lock, __ATOMIC_ACQUIRE)) {
        /* Active busy-wait loop */
        asm volatile("pause"); /* Architectural spin-wait hint */
    }
}

void spin_unlock(spinlock_t *sl) {
    /* Release lock with release memory ordering */
    __atomic_clear(&sl->lock, __ATOMIC_RELEASE);
}

The Fundamental Trade-Off: Spinning vs. Blocking

Why would an operating system designer intentionally choose busy waiting over putting a thread to sleep? The answer lies in the microsecond latency cost of context switches:

Figure 2.4: The Microarchitectural Cacheline Bouncing Storm
How naive spinlocks flood the multi-core interconnect with MESI invalidation broadcasts, destroying bus throughput.
CORE 0 [Lock Holder] CRITICAL SECTION balance += 100; L1 CACHE INVAL! Stalled by peer writes • Needs to write release • Bus saturated by spinners SHARED INTERCONNECT BUS (RING / MESH) × CACHELINE BOUNCING SATURATION Every atomic XCHG broadcasts Read-For-Ownership (RFO) requests Bus Latency Jumps from 10ns → 250ns+ ↔ Contended 64-byte Cacheline [0x80001000] ↔ MESI Coherence Overhead: O(N²) bus transactions with N spinning cores! CORES 1 & 2 [Spinning] SPIN LOOP (Active): while (xchg(&lock, 1)) • Atomic write every loop • Invalidates peer caches! Cacheline Ping-Pong: Core 1 snatches line (M) Core 2 snatches line (M) Caches thrash continuously

Microarchitectural Contention: Cacheline Bouncing

On modern multi-core processors, CPU cores communicate over a shared high-speed interconnect bus or mesh network using a cache coherency protocol (such as MESI or MOESI).

When a thread uses a basic Test-and-Set spinlock:

Algorithmic Optimization 1: Test-and-Test-and-Set (TTAS)

To eliminate the cacheline bouncing storm, computer scientists developed the Test-and-Test-and-Set (TTAS) optimization.

The core insight is simple: reading memory does not invalidate other cores' caches. Under the MESI protocol, multiple cores can simultaneously hold a cache line in the Shared (S) state without issuing any bus transactions.

/* Optimized Test-and-Test-and-Set (TTAS) Lock */
void spin_lock_ttas(spinlock_t *sl) {
    while (1) {
        /* Phase 1: Spin locally on shared read-only cache line (ZERO bus traffic!) */
        while (sl->lock == 1) {
            asm volatile("pause"); /* De-pipeline & reduce core power */
        }

        /* Phase 2: Attempt atomic acquisition ONLY when lock appears free */
        if (__atomic_test_and_set(&sl->lock, __ATOMIC_ACQUIRE) == 0) {
            break; /* Successfully acquired lock! */
        }
        /* If acquisition failed, loop back to read-only spinning */
    }
}

In TTAS, while the lock is held, all spinning cores read from their local L1/L2 caches in the Shared state. Bus traffic drops to zero. Only when the lock holder writes 0 (unlock) is an invalidation sent, signaling the waiting cores to attempt a single atomic acquisition.

Algorithmic Optimization 2: The x86 PAUSE Instruction

Inside modern spin loops, compilers emit the architectural pause instruction (or yield on ARM). The pause instruction solves two critical hardware problems:

  1. Pipeline Flush Prevention on Loop Exit: Modern out-of-order processors aggressively speculate down the loop branch. When the lock is finally freed, the processor detects a memory order violation in its speculative load queue, forcing a massive pipeline flush that wastes ∼40 clock cycles. The pause instruction introduces a brief sub-nanosecond architectural delay that prevents speculative pipeline overrun.
  2. SMT / Hyper-Threading Resource Sharing: On processors with Simultaneous Multithreading (Intel Hyper-Threading / AMD SMT), two logical threads share the execution units of a single physical core. A thread in a naked spin loop monopolizes the execution pipeline. The pause instruction yields core execution slots to the sibling hardware thread, allowing the thread doing real work to run faster.

Algorithmic Optimization 3: Exponential Backoff

When a TTAS lock is released, all spinning cores detect the free state simultaneously and rush to execute atomic_test_and_set. This creates a temporary Thundering Herd problem, where N cores flood the bus at the exact same instant.

To smooth out contention, algorithms incorporate Exponential Backoff: if a thread fails an atomic acquisition, it pauses for a delay that doubles on each successive failure:

/* Exponential Backoff Loop */
int delay = 1;
while (atomic_acquire(&lock) == FAILED) {
    for (int i = 0; i < delay; i++) {
        asm volatile("pause");
    }
    if (delay < MAX_BACKOFF) delay *= 2;
}

Scalable Queued Spinlocks (Ticket Locks and MCS Locks)

Basic spinlocks have no concept of fairness. A newly arrived core might snatch a released lock immediately, while another core has been spinning for millions of cycles. This lack of FIFO ordering violates Condition 4 (Bounded Waiting) and can lead to starvation.

The Priority Inversion Disaster

Spinlocks introduce a notorious systems failure when combined with priority-based preemptive scheduling: Priority Inversion.

The Classical Mars Pathfinder / Real-Time Priority Inversion Failure:
  1. A low-priority thread L enters a critical region and acquires a spinlock.
  2. A high-priority thread H wakes up (e.g., in response to an urgent sensor interrupt). The scheduler preempts L and dispatches H.
  3. Thread H attempts to acquire the spinlock held by L. Finding it locked, H begins busy waiting (spinning).
  4. The Inversion Trap: Because H has higher priority than L, the scheduler guarantees H 100% of CPU execution time. Thread L is never scheduled to run.
  5. Because L never runs, it can never finish its critical section and can never release the lock.
  6. Result: Immediate System Hang. Thread H spins forever waiting for L, while L is prevented from running by H. If a medium-priority thread M exists, it can preempt L as well, delaying the high-priority task indefinitely.

The Solution: Operating systems resolve this pathology through two strict design mandates:

  1. Disabling Local Preemption During Spinlocks: In the Linux kernel, calling spin_lock() automatically increments the thread's preemption disable counter (preempt_disable()). The local CPU scheduler is forbidden from preempting the lock holder until spin_unlock() is executed.
  2. Priority Inheritance Protocol (PIP): If a high-priority thread H blocks on a lock held by a low-priority thread L, the operating system temporarily elevates L's priority to match H's priority. This ensures that medium-priority threads cannot preempt L, allowing L to finish its critical section rapidly, release the lock, and drop back to its original priority.

Interactive Stepper: MESI Cacheline Bouncing & TTAS Optimization

Hardware Topology Core 0 holds spinlock [0x80001000]. Core 1 and Core 2 compete to acquire it. Under naive TSL, every spin issues an atomic write, causing continuous cacheline invalidations and bus saturation.
Timeline Clock T = 0 ns
Core 0 Cache State Modified (M)
Core 1 Cache State Invalid (I)
Bus Traffic Saturation 0 MB/s (Idle)
Interconnect State Nominal
CORE 0 [Lock Holder] CRITICAL SECTION balance += 100; L1 CACHE (MESI): MODIFIED [M] BUS ACTIVITY: Holds line exclusively SHARED INTERCONNECT BUS (RING / MESH) INTERCONNECT STATUS: QUIET No invalidation storm active Bandwidth: 0 GB/s (Clean) PHYSICAL MEMORY LINE: [0x80001000] Value: 1 (LOCKED BY CORE 0) CORE 1 [Spinner] SPINNING CODE: while (xchg(&lock, 1)) L1 CACHE (MESI): INVALID [I] MICRO-OP STATE: Cache line invalidated
Current Step Summary Core 0 holds the spinlock in its L1 cache with MESI state Modified (M). Core 1 is waiting for the lock.
What Is Happening

Core 0 holds the lock line exclusively. Core 1 prepares to test the spinlock.

Why The System Does This

Baseline state showing how MESI coherency establishes single-core exclusivity.