wisconsin
Lecture 14 - Deadlock

Lecture 14 - Deadlock

Curated by Last modified: 6 min read

Instructor: Mike Swift & Tej Chajed Topics: Deadlock, four conditions for deadlock, deadlock prevention, lock ordering, lock-free programming


What is Deadlock?

Deadlock: A set of threads is blocked indefinitely because each thread is waiting for a resource another thread holds.

No progress can be made because two or more threads are waiting for the other to take some action and thus neither ever does.

Real-world analogy: Gridlock at an intersection — cars from all four directions block each other and none can proceed.


Deadlock vs Starvation vs Livelock

Deadlock: A set of threads is blocked indefinitely because each thread is waiting for a resource another thread holds.

Starvation: A thread is blocked indefinitely because it is waiting for resource(s) held by other thread(s). (Differs from deadlock: the other threads are not necessarily waiting for the starved thread.)

Livelock: A set of threads are active but make no progress. (Threads keep running and changing state, but no useful work gets done.)


Deadlock Code Example

Consider two threads acquiring two locks in opposite order:

// Thread 1:              // Thread 2:
lock(&A);                 lock(&B);
lock(&B);                 lock(&A);
// ...                    // ...
unlock(A);                unlock(A);
unlock(B);                unlock(B);

Lecture Note

If Thread 1 holds lock A and waits for lock B, while Thread 2 holds lock B and waits for lock A, neither can proceed — this is deadlock.

Circular Dependency

This creates a circular dependency between threads and locks:

graph LR
    T1((Thread 1)) -->|holds| LA[Lock A]
    LA -->|wanted by| T2((Thread 2))
    T2 -->|holds| LB[Lock B]
    LB -->|wanted by| T1

Fixing the Deadlock

Ensure both threads acquire locks in the same order:

// Thread 1:              // Thread 2:
lock(&A);                 lock(&A);
lock(&B);                 lock(&B);

This breaks the circular dependency — both threads try to acquire A first, so one will succeed and the other will wait without holding any conflicting lock.


Set Intersection Example

A more realistic example where deadlock hides inside a function:

set_t *set_intersection(set_t *s1, set_t *s2) {
    set_t *rv = malloc(sizeof(*rv));
    mutex_lock(&s1->lock);
    mutex_lock(&s2->lock);
    for (int i = 0; i < s1->len; i++) {
        if (set_contains(s2, s1->items[i]))
            set_add(rv, s1->items[i]);
    }
    mutex_unlock(&s2->lock);
    mutex_unlock(&s1->lock);
}

Lecture Note

The loop body (checking s2 and adding to rv) is the critical section that requires both locks.

The deadlock arises when two threads call the function with swapped arguments:

Thread 1: rv = set_intersection(setA, setB);  // locks A then B
Thread 2: rv = set_intersection(setB, setA);  // locks B then A

Modularity / Encapsulation

Modularity can make it harder to see deadlocks — the lock ordering is hidden inside a function.

Solution: Compare lock addresses to enforce a consistent ordering:

if (m1 > m2) {
    // grab locks in high-to-low address order
    pthread_mutex_lock(m1);
    pthread_mutex_lock(m2);
} else {
    pthread_mutex_lock(m2);
    pthread_mutex_lock(m1);
}

Lecture Note

If m1 == m2, this causes immediate deadlock (locking the same mutex twice). Must handle that case separately.


Practice: Which Can Deadlock?

Given a function that acquires three locks in order:

void foo(pthread_mutex_t *t1, pthread_mutex_t *t2, pthread_mutex_t *t3) {
    pthread_mutex_lock(t1);
    pthread_mutex_lock(t2);
    pthread_mutex_lock(t3);
    do_stuffs();
    pthread_mutex_unlock(t1);
    pthread_mutex_unlock(t2);
    pthread_mutex_unlock(t3);
}
ScenarioThread callsCan deadlock?
1T1 foo(a,b,c), T2 foo(b,c,a), T3 foo(c,a,b)Yes — circular ordering (a→b→c→a)
2T1 foo(a,b,c), T2 foo(a,b,c), T3 foo(a,b,c)No — all same order
3T1 foo(a,b,c), T2 foo(b,c,e), T3 foo(f,e,a)No — no circular dependency among shared locks

Four Conditions for Deadlock (Deadlock Theory)

Deadlocks can only happen when all four of these conditions hold simultaneously:

  1. Circular wait — a circular chain of threads, each holding a resource needed by the next
  2. Hold-and-wait — threads hold resources while waiting for additional resources
  3. No preemption — resources cannot be forcibly taken from threads holding them
  4. Mutual exclusion — threads claim exclusive control of resources

Key Insight

Eliminating any one condition prevents deadlock. Each prevention strategy targets one of these four conditions.


Prevention Strategy 1: Lock Ordering (Eliminates Circular Wait)

Problem: Circular chain of threads such that each thread holds a resource (e.g., lock) being requested by the next thread in the chain.

Strategy: Decide on a lock ordering and always acquire locks in that order.

  • If A comes before B in the ordering, never acquire A if B is already held
  • Document the ordering and write code accordingly

Lecture Note

Works well if the system has distinct layers. The ordering can follow the layer hierarchy (e.g., filesystem lock before buffer cache lock before disk lock).


Prevention Strategy 2: Avoid Waiting While Holding Lock (Eliminates Hold-and-Wait)

Problem: Threads hold resources allocated to them while waiting for additional resources.

Strategy: Acquire all locks atomically once. Can release locks over time, but cannot acquire again until all have been released.

Use a meta lock:

lock(&meta);
lock(&L1);
lock(&L2);
// ...
unlock(&meta);

// Critical section code

unlock(/* ... */);

Disadvantages:

  • Must know ahead of time which locks will be needed
  • Must be conservative (acquire any lock possibly needed)
  • If too many locks are acquired, degenerates to just having one big lock

Prevention Strategy 3: Preempt Lock Owners (Eliminates No-Preemption)

Problem: Resources (e.g., locks) cannot be forcibly removed from threads that are holding them.

Strategy: If a thread can’t get what it wants, release what it holds and retry.

top:
    lock(A);
    if (trylock(B) == -1) {
        unlock(A);
        goto top;
    }
    // ...

Disadvantage: This can cause livelock — threads keep retrying but make no progress because they keep releasing and re-acquiring in lockstep.

Classic solution: Exponential back-off — add increasing random delays between retries:

delay = 1;
top:
    lock(A);
    if (trylock(B) == -1) {
        unlock(A);
        sleep(delay); delay *= 2;
        goto top;
    }
    // ...

Prevention Strategy 4: Write Lock-Free Code (Eliminates Mutual Exclusion)

Problem: Threads claim exclusive control of resources that they require.

Strategy: Eliminate locks! Replace locks with atomic primitives.

CompareAndSwap

int CompareAndSwap(int *address, int expected, int new) {
    if (*address == expected) {
        *address = new;
        return 1; // success
    }
    return 0; // failure
}

Lock-Free Linked List Insert

With locks:

void insert(int val) {
    node_t *n = malloc(...);
    n->val = val;
    lock(&m);
    n->next = head;
    head = n;
    unlock(&m);
}

Lecture Note

The malloc and value assignment are outside the critical section. Only the pointer manipulation (setting n->next and updating head) needs protection.

Lock-free version:

void insert(int val) {
    node_t *n = malloc(...);
    n->val = val;
    do {
        n->next = head;
    } while (!CompareAndSwap(&head, n->next, n));
}

Lecture Note

CompareAndSwap(&head, n->next, n) atomically checks if head still equals the stored address (n->next is the expected value). If another thread modified head in the meantime, the CAS fails and the loop retries with the updated head value.


Linked to this page