Abstract

A Lock is a fundamental synchronization object providing two paired operations: acquire() and release(). Between these calls, a thread holds the lock and executes inside a critical section. To implement locks safely, the lock’s internal operations must be atomic. This evolution spans low-level hardware primitives (disabling interrupts, atomic Test-And-Set spinlocks) to efficient multicore Guarded Blocking Locks that sleep waiting threads.

  • Category: Synchronization Primitives & Implementation
  • Core Interface: acquire() (or lock()) and release() (or unlock()).
  • Hardware Primitives: Disable Interrupts, Atomic test_and_set() instruction.
  • Key Invariant: Minimize busy-waiting while keeping interrupts enabled inside long critical sections.

1. The Lock Abstraction

A Lock is a memory object used to enforce mutual exclusion around critical sections.

lock_t my_lock;
 
void worker() {
    acquire(&my_lock);  // Enter Critical Section (Blocks if held by another thread)
    
    /* --- CRITICAL SECTION (Shared State Access) --- */
    
    release(&my_lock);  // Exit Critical Section
}

Operational Rules

  • Pairing: Every acquire() call must be strictly paired with a release() call.
  • Mutual Exclusion: Only one thread can hold a lock at any given time.
  • Blocking Behavior: acquire() will not return until any previous holder has called release().
  • Unpaired Failure Mode: If a thread acquires a lock and fails to release it (or hangs), all subsequent threads trying to enter the critical section will wait indefinitely (starvation/deadlock).

2. Low-Level Implementation Attempts (Spinlocks)

Attempt 1: Naive Software Lock (Broken)

A naive attempt to build a lock using a boolean flag in user memory:

struct lock {
    bool held = false;
};
 
void acquire(struct lock* lock) {
    while (lock->held); // Busy-wait (spin) until held is false
    lock->held = true;  // Mark as held
}
 
void release(struct lock* lock) {
    lock->held = false;
}

Why Attempt 1 Fails

A context switch can occur after the while loop evaluates to false but before lock->held = true executes. Two threads can both observe held == false and simultaneously enter the critical section. The lock implementation itself contains a race condition!


Attempt 2: Disabling Interrupts (Kernel-Only)

Involuntary context switches on a single CPU core are triggered by hardware interrupts (e.g., timer ticks). We can attempt atomicity by disabling interrupts:

struct lock {
    // No state needed
};
 
void acquire(struct lock* lock) {
    disable_interrupts(); // One hardware instruction
}
 
void release(struct lock* lock) {
    enable_interrupts();
}

Limitations of Disabling Interrupts

  1. User-Space Inaccessible: Disabling interrupts is a privileged instruction. Allowing user applications to disable interrupts would allow buggy or malicious code to seize the machine permanently.
  2. Fails on Multicore Systems: Disabling interrupts only prevents context switches on the local CPU core. Threads running on other physical cores can still access shared memory simultaneously.
  3. Missing Events: Disabling interrupts for extended periods can cause the OS to miss or delay crucial hardware I/O and timer events.

Attempt 3: Hardware Spinlock (test_and_set)

To build multicore-safe locks, modern CPUs provide dedicated atomic instructions executed directly at the hardware bus/cache level.

The Test-And-Set instruction reads a memory location and sets it to true in a single, indivisible hardware cycle:

// Hardware executes this entire routine ATOMICALLY
bool test_and_set(bool* flag) {
    bool old = *flag;
    *flag = true;
    return old;
}

Using test_and_set(), we can construct a correct, multicore-safe Spinlock:

struct lock {
    bool held = false;
};
 
void acquire(struct lock* lock) {
    // Spin until test_and_set returns false
    while (test_and_set(&lock->held));
}
 
void release(struct lock* lock) {
    lock->held = false;
}
  • Correctness: Works on multicore CPUs and can be safely invoked in user space.
  • The Busy-Waiting Deficit: If Thread holds the lock and Thread attempts to acquire it, Thread loops continuously in the while check (busy-waiting). This wastes CPU cycles that could be used by other productive threads.

3. High-Level Implementation Attempts (Blocking Locks)

When critical sections are long (e.g., File I/O, database updates), spinlocks waste massive amounts of CPU cycles. High-level locks move waiting threads to a sleep queue.

PrimitiveMechanismPerformance CharacteristicBest Use Case
SpinlockBusy-wait loop (while(test_and_set))Wastes 100% CPU on waiting coreVery short critical sections (e.g., OS scheduler state)
Blocking LockMoves waiting TCB to queue & sleepsReleases CPU to run other threadsLong critical sections (e.g., File I/O, Database writes)

Attempt 4: Blocking Lock with Interrupt Disabling (Flawed)

To prevent busy-waiting, waiting threads are moved to a sleep queue Q:

struct lock {
    bool held = false;
    queue Q;
};
 
void acquire(struct lock* lock) {
    disable_interrupts();
    if (lock->held) {
        put_current_thread_on(lock->Q);
        block_current_thread(); // Yields CPU & switches thread
    }
    lock->held = true;
    enable_interrupts();
}
 
void release(struct lock* lock) {
    disable_interrupts();
    if (is_empty(lock->Q)) {
        lock->held = false;
    } else {
        move_waiting_thread_to_ready_queue(lock->Q);
    }
    enable_interrupts();
}

Drawbacks of Attempt 4

  1. Fails on multicore CPUs because disabling interrupts on Core 0 does not prevent Core 1 from modifying lock->held.
  2. Cannot be called by user-level applications.

Attempt 5: Multicore Guarded Blocking Lock (Correct)

To combine multicore safety with thread sleeping, we introduce a short-duration atomic guard spinlock that protects access to the lock’s internal data structures (held flag and wait queue Q):

struct lock {
    bool held = false;
    bool guard = false; // Short-duration spinlock protecting internal lock state
    queue Q;
};
 
void acquire(struct lock* lock) {
    disable_interrupts();
    while (test_and_set(&lock->guard)); // Acquire guard spinlock
    
    if (lock->held) {
        put_current_thread_on(lock->Q);
        lock->guard = false;            // Release guard BEFORE sleeping!
        block_current_thread();         // Sleep (CPU context switches)
    } else {
        lock->held = true;
        lock->guard = false;            // Release guard
    }
    enable_interrupts();
}
 
void release(struct lock* lock) {
    disable_interrupts();
    while (test_and_set(&lock->guard)); // Acquire guard spinlock
    
    if (is_empty(lock->Q)) {
        lock->held = false;
    } else {
        move_waiting_thread_to_ready_queue(lock->Q);
    }
    
    lock->guard = false;                // Release guard
    enable_interrupts();
}

Key Architectural Benefits

  • Multicore Safe: Uses test_and_set(&lock->guard) so multiple cores cannot corrupt the lock queue.
  • Minimal Spinning: Threads spin on guard for only a few assembly instructions (to update the queue), not for the duration of the critical section.
  • Interrupt-Enabled Critical Section: Interrupts remain enabled while executing code inside the main critical section.

4. Specialized Lock Variants

Operating systems optimize locking overhead by employing specialized lock designs tailored to specific workload access patterns:

  1. Reader-Writer Locks (RW Locks): Allows multiple concurrent reader threads to execute inside the critical section simultaneously, but grants exclusive access to a single writer thread.
  2. Read-Copy-Update (RCU): Optimized for read-dominated data structures (e.g., Linux routing tables). Readers execute with zero lock overhead; writers copy the structure, update it, and swap pointers atomically.
  3. Distributed Locks: Designed to avoid cache line bouncing and bus contention across multi-socket NUMA systems.

Related Notes