wisconsin
Lecture 12 - Locks

Lecture 12 - Locks

Curated by Last modified: 6 min read

Instructor: Tej Chajed Topics: Locks, mutual exclusion, atomic operations, spinlocks, test-and-set, compare-and-swap, ticket locks


Why Locks?

Without locks, concurrent threads accessing shared data produce incorrect results due to race conditions. Locks ensure mutual exclusion — only one thread executes a critical section at a time.

Timeline View with Locks

Consider two threads both incrementing a shared variable *balance:

Thread 1                    Thread 2
mutex_Lock()                mutex_Lock()

mov 0x123, %eax
add $0x1, %eax
mov %eax, 0x123

mutex_unlock()
                            mov 0x123, %eax
                            add $0x2, %eax
                            mov %eax, 0x123

                            mutex_unlock()

How much is added to shared variable? 3: correct!

Lecture Note

With locks, mutex_Lock() / *balance += 1 / mutex_Unlock() and mutex_Lock() / *balance += 2 / mutex_Unlock() run sequentially, producing the correct result.


Lock Implementation Goals

Correctness

  • Mutual exclusion — only one thread in the critical section at a time
  • Progress (deadlock-free) — if several simultaneous requests, must allow one to proceed
  • Bounded (starvation-free) — must eventually allow each waiting thread to enter

Fairness

Each thread waits for the same amount of time.

Performance

CPU is not used unnecessarily.


Implementing Synchronization

Atomic operation: No other instructions can be interleaved.

Three approaches to implementing locks:

  1. Disable interrupts
  2. Locks using loads/stores
  3. Using special hardware instructions

Approach 1: Disable Interrupts

Turn off interrupts for critical sections:

  • Prevents the dispatcher from running another thread
  • Code between interrupts executes atomically
void acquire(lockT *l) {
    disableInterrupts();
}

void release(lockT *l) {
    enableInterrupts();
}

Disadvantages:

  • Only works on uniprocessors (other CPUs are unaffected by disabling local interrupts)
  • Process can keep control of CPU for arbitrary length
  • Cannot perform other necessary work (e.g., handling I/O)

Approach 2: Locks with Load + Store

Use a single shared lock variable:

// shared variable
boolean lock = false;

void acquire(Boolean *lock) {
    while (*lock) /* wait */ ;
    *lock = true;
}

void release(Boolean *lock) {
    *lock = false;
}

Lecture Note

false means free, true means held. On release, you could check if (*lock == false) and throw an error for “misuse of lock API.”

Race Condition with Load and Store

Does this work? No — testing the lock and setting the lock are not atomic.

*lock == 0 initially

Thread 1              Thread 2
while(*lock == 1)
                      while(*lock == 1)
                      *lock = 1
*lock = 1

Both threads grab the lock! The problem is that the check and the set are two separate operations that can be interleaved.


Atomic Hardware Instructions

To implement synchronization correctly, we need hardware support. Examples of atomic operations:

  • Code between interrupts on uniprocessors (disable timer interrupts, don’t do any I/O)
  • Special hardware instructions:
    • xchg (exchange) — also called Test&Set
    • CompareAndSwap (CAS)
    • FetchAndAdd (FAA)

Lecture Note

Loads and stores of single words are atomic individually, but combining them (test-then-set) is not atomic. The special hardware instructions solve this.


Test-and-Set (xchg)

The xchg instruction atomically swaps a value at an address and returns the old value:

// xchg(int *addr, int newval)
// return what was pointed to by addr
// at the same time, store newval into addr
int xchg(int *addr, int newval) {
    int old = *addr;
    *addr = newval;
    return old;
}

In x86 assembly, this is a single instruction:

movl 4(%esp), %edx
movl 8(%esp), %eax
xchgl (%edx), %eax
ret

Spinlock with xchg

typedef struct __lock_t {
    int flag;
} lock_t;

void init(lock_t *lock) {
    lock->flag = 0;
}

void acquire(lock_t *lock) {
    while (xchg(&lock->flag, 1) == 1)
        ; // spin-wait (do nothing)
}

void release(lock_t *lock) {
    lock->flag = 0;
}

Lecture Note

flag = 0 means free, flag = 1 means held. When xchg returns 0, we know we own the lock (we atomically set it to 1 and got back 0). When xchg returns 1, the lock was already held — we learn we do not own it, but the value stays 1 (no harm done), so we retry.


Compare-and-Swap (CompareAndSwap)

int CompareAndSwap(int *addr, int expected, int new) {
    int actual = *addr;
    if (actual == expected) {
        *addr = new;
        return (1);  // swap happens
    }
    return 0;         // nothing happens
}

Lecture Note

If the value matches expected, the swap happens and returns 1. Otherwise nothing changes and it returns 0.

Spinlock with CompareAndSwap

void acquire(lock_t *lock) {
    while (CompareAndSwap(&lock->flag, 0, 1) == 0)
        ; // spin-wait (do nothing)
}

This tries to atomically change flag from 0 to 1. If flag is already 1 (held), CompareAndSwap returns 0 and the thread spins.


Basic Spinlocks are Unfair

With a basic spinlock (using xchg or CAS), thread A can repeatedly acquire the lock while thread B spins and waits:

Time:  0    20   40   60   80  100  120  140  160
       [A] [B]  [A]  [B]  [A] [B]  [A]  [B]
            spin      spin      spin      spin
  • A unlocks and immediately re-locks before B can acquire
  • B wastes its entire time slice spinning
  • The scheduler is unaware of locks/unlocks!

Ticket Locks (Fair Spinlock)

Idea: Reserve each thread’s turn to use a lock. Each thread spins until their turn.

Uses a new atomic primitive, FetchAndAdd:

int FetchAndAdd(int *ptr) {
    int old = *ptr;
    *ptr = old + 1;
    return old;
}

Lock state consists of two fields: ticket and turn.

  • Acquire: Grab a ticket (via FAA); spin while thread’s ticket != turn
  • Release: Advance to next turn

Lecture Note

FAA gives each thread its own ticket number. Example: A calls lock() and gets ticket 0 (owner). B calls lock() and gets ticket 1. C calls lock() and gets ticket 2. When A calls unlock(), turn advances to 1, so B runs next. This guarantees FIFO ordering.

Ticket Lock Implementation

typedef struct __lock_t {
    int ticket;
    int turn;
} lock_t;

void lock_init(lock_t *lock) {
    lock->ticket = 0;
    lock->turn = 0;
}

void acquire(lock_t *lock) {
    int myturn = FetchAndAdd(&lock->ticket);
    // spin
    while (lock->turn != myturn);
}

void release(lock_t *lock) {
    FetchAndAdd(&lock->turn);
}

Ticket Lock Example

A lock():    gets ticket 0, spins until turn = 0 -> runs
B lock():    gets ticket 1, spins until turn = 1
C lock():    gets ticket 2, spins until turn = 2
A unlock():  turn++ (turn = 1)
B           runs
A lock():    gets ticket 3, spins until turn = 3
B unlock():  turn++ (turn = 2)
C           runs
C unlock():  turn++ (turn = 3)
A           runs
A unlock():  turn++ (turn = 4)
C lock():    gets ticket 4, runs

Ticket locks guarantee bounded waiting and fairness — threads are served in FIFO order.


Summary: Lock Implementations Compared

ImplementationMutual ExclusionProgressBounded WaitingFairnessPerformance
Disable interruptsYes (uniprocessor only)YesYesN/APoor (blocks all interrupts)
Load/Store spinlockNoN/AN/ANoN/A
xchg spinlockYesYesNoNoWastes CPU spinning
CAS spinlockYesYesNoNoWastes CPU spinning
Ticket lockYesYesYesYes (FIFO)Wastes CPU spinning

Linked to this page