Core skills · Lesson 5/8

Concurrency, Locks & Deadlocks — Sharing Without Breaking

41 min 1 quiz Graded labs: 3

What you'll learn

  • What a race condition is and why counter++ is not one step
  • How a mutex protects a critical section, and which hardware instructions make locks possible
  • How condition variables let a thread sleep until something becomes true, and why you always wait in a while loop
  • How semaphores count resources, as in the classic producer-consumer problem
  • How deadlocks arise (the four Coffman conditions) and how a consistent lock order prevents them

You will build a real bounded buffer with POSIX threads in C, a semaphore simulator in Python and a lock-order checker in JavaScript.

A race on one line of code 🏁

Two threads share a global counter and both run counter++. It looks like one step, but the CPU executes it as three instructions: load the value into a register, add 1, store it back. The scheduler may switch threads between any two of them:

thread A                 thread B                 counter
load  rA = counter                                5
                         load  rB = counter       5
add   rA = rA + 1  (6)                            5
                         add   rB = rB + 1  (6)   5
store counter = rA                                6
                         store counter = rB       6   <- one increment lost

Both threads did their work, yet the counter went from 5 to 6. This is a race condition: the result depends on the timing of the threads. Races are nasty because they are rare: a program passes a thousand tests and then fails on a busy production server. In C and C++ an unsynchronised data race is even undefined behaviour (C11), so the compiler may assume it never happens.

The code that touches shared data is a critical section. A correct solution must give:

  • Mutual exclusion: at most one thread inside the critical section at a time
  • Progress: if nobody is inside, a thread that wants to enter eventually can
  • Bounded waiting: no thread waits forever while others keep entering

Count how often the race bites if every interleaving is equally likely:

Of the 20 ways to interleave the six steps, only 2 (one thread completely before the other) give the right answer 2; the other 18 lose an update. Real schedulers make bad interleavings rarer, never impossible. Try other schedules with run().

from itertools import combinations
from collections import Counter

# counter += 1 is really three steps: load into a register, add 1, store back.
STEPS = ["load", "add", "store"]

def run(schedule):
    """schedule is a list like ['A', 'B', 'A', ...]: whose next step runs."""
    counter = 0
    reg = {"A": None, "B": None}
    pc = {"A": 0, "B": 0}
    for t in schedule:
        step = STEPS[pc[t]]
        if step == "load":
            reg[t] = counter
        elif step == "add":
            reg[t] += 1
        else:
            counter = reg[t]
        pc[t] += 1
    return counter

# Every way to interleave A's three steps with B's three steps.
results = Counter()
for a_slots in combinations(range(6), 3):
    schedule = ["A" if i in a_slots else "B" for i in range(6)]
    results[run(schedule)] += 1

print("final value -> number of interleavings")
for value, n in sorted(results.items()):
    print(f"  counter = {value}: {n}")
print("one bad schedule: A B A B A B ->", run(list("ABABAB")))

Locks: one thread at a time 🔒

A mutex (mutual exclusion lock) has two operations: lock waits until the mutex is free and takes it; unlock releases it. With POSIX threads (pthreads(7)):

pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;

void increment(void) {
    pthread_mutex_lock(&m);     /* enter the critical section */
    counter++;                  /* only one thread can be here */
    pthread_mutex_unlock(&m);   /* leave it */
}

Building a lock out of ordinary loads and stores is fragile on modern CPUs, which reorder memory accesses, so the hardware provides atomic instructions that read and modify memory in one indivisible step: test-and-set, compare-and-swap (lock cmpxchg on x86) or load-linked/store-conditional pairs (ARM). A spinlock loops on such an instruction until it wins; that wastes CPU time but suits very short sections inside the kernel. A blocking mutex puts the waiter to sleep instead. On Linux, glibc's mutex is built on the futex system call: without contention, lock and unlock are a single atomic instruction in user space, and the kernel is involved only to put a thread to sleep or wake it.

Rules of thumb:

  • A lock protects data, not code: every access to that data, reads included, must hold the same lock
  • Keep critical sections short; do not do slow I/O while holding a lock
  • Unlock on every path, including early returns and errors

Waiting for a condition: condition variables ⏳

Often a thread must wait until something becomes true: a queue is no longer empty, a buffer has room. Spinning on while (count == 0) {} burns a CPU and still races. A condition variable lets a thread sleep until another thread signals it:

pthread_mutex_lock(&m);
while (count == 0)                       /* WHILE, not if */
    pthread_cond_wait(&not_empty, &m);   /* unlocks m, sleeps, relocks m on wake-up */
item = take_one();
pthread_cond_signal(&not_full);          /* someone may be waiting for room */
pthread_mutex_unlock(&m);

pthread_cond_wait releases the mutex and goes to sleep atomically, so a signal cannot slip in between the check and the sleep. Always re-check the condition in a while loop: POSIX allows spurious wake-ups, and another thread may have taken the item between the signal and the moment you run again.

This is the heart of the producer-consumer (bounded buffer) problem: a producer adds items to a buffer with a fixed number of slots and a consumer removes them; the producer must wait while the buffer is full and the consumer while it is empty. The pipes from the shell lesson work exactly like this inside the kernel. Build one:

Lab exercise 1

CGraded in Code Lab

Build a real bounded buffer with POSIX threads: one producer thread puts items in, one consumer thread takes them out.

Input: capacity n (1 ≤ capacity ≤ 64, 0 ≤ n ≤ 1000) followed by n integers.

What to write: the two buffer functions. The starter already creates the threads: the producer calls buffer_put for every item in order, the consumer calls buffer_get n times and records what it gets.

  • buffer_put(item): wait while the buffer is full (count == capacity), then store the item at slots[in], advance in around the ring and increment count
  • buffer_get(): wait while the buffer is empty, then take the item at slots[out], advance out and decrement count
  • Hold the mutex lock for every access to the shared fields, and sleep and wake with the condition variables not_full and not_empty. Never busy-wait.

Output (printed by main after both threads are joined): order: followed by the received items, then total: <sum>. With one producer and one consumer a correct buffer delivers the items in exactly the order they were produced, however the threads are scheduled.

Example: 4 6 and 5 3 8 1 9 2 print

order: 5 3 8 1 9 2
total: 28
Visible tests (5)
  • Example from the prompt
  • Capacity 1: strict hand-over
  • A buffer bigger than the input, negative items
  • No items at all
  • 100 items through 2 slots
Example run
Input
4 6
5 3 8 1 9 2
Expected output
order: 5 3 8 1 9 2
total: 28
Starter code
#include <pthread.h>
#include <stdio.h>

#define MAX_ITEMS 1000
#define MAX_CAPACITY 64

/* The bounded buffer: a ring of capacity slots shared by the two threads. */
int slots[MAX_CAPACITY];
int capacity, count, in, out;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t not_full = PTHREAD_COND_INITIALIZER;
pthread_cond_t not_empty = PTHREAD_COND_INITIALIZER;

More lines in Code Lab: 49

Try in Code Lab

Hints and instant feedback are in Code Lab. Optional practice: it does not block finishing the lesson.

JavaScript and Python also run in your browser without an account; other languages use the secure server runner and need a free account.

Semaphores: counting permits 🎫

A semaphore (introduced by Dijkstra in the 1960s) is an integer with two atomic operations:

  • wait (P, sem_wait): if the value is above 0, decrement it and continue; otherwise sleep until that becomes possible
  • signal (V, sem_post): increment the value, or hand the unit directly to one sleeping thread

Initialised to 1, a semaphore works as a lock (a binary semaphore). Initialised to N, it counts N identical resources: database connections, buffer slots, parking spaces. Unlike a mutex, a semaphore has no owner: any thread may signal it. The bounded buffer written with semaphores uses three of them:

slots = N  (free slots)     items = 0  (filled slots)     mutex = 1

producer:  wait(slots); wait(mutex); put(x);     signal(mutex); signal(items)
consumer:  wait(items); wait(mutex); x = take(); signal(mutex); signal(slots)

Order matters here too: if the consumer did wait(mutex) before wait(items) on an empty buffer, it would sleep while holding the mutex, and the producer could never get in to add an item. That is a deadlock, the topic after the next lab.

Lab exercise 2

PythonGraded in Code Lab

Simulate counting semaphores with wait queues.

Input:

  • Line 1: the semaphores and their initial values, for example slots=2 items=0 mutex=1
  • Then one operation per line: <thread> wait <sem> or <thread> signal <sem> (blank lines are ignored)

Rules:

  • wait: if the value is above 0, decrement it and print T wait S -> ok (S=<value>). Otherwise the thread blocks: it joins the end of S's queue and T wait S -> blocked is printed
  • signal: if threads are blocked on S, the first one in the queue wakes up and completes its wait (the value does not change): T signal S -> wakes U. Otherwise increment the value: T signal S -> S=<value>
  • A blocked thread cannot do anything: any line for it prints T is blocked on S, ignored
  • Values never go below 0. (Real implementations may wake waiters in any order; this simulator uses FIFO so that the output is predictable.)

Output: the line for every operation, then values: S=v ... in declaration order, then blocked: T(S) ... for every thread still asleep, in the order they blocked, or blocked: none.

Example:

mutex=1
A wait mutex
B wait mutex
A signal mutex
B signal mutex

prints

A wait mutex -> ok (mutex=0)
B wait mutex -> blocked
A signal mutex -> wakes B
B signal mutex -> mutex=1
values: mutex=1
blocked: none
Visible tests (5)
  • Example: a semaphore used as a lock
  • Producer and consumer with slots and items
  • Two threads waiting for each other: a deadlock
  • A pool of three, and a semaphore has no owner
  • Declarations only
Example run
Input
mutex=1
A wait mutex
B wait mutex
A signal mutex
B signal mutex
Expected output
A wait mutex -> ok (mutex=0)
B wait mutex -> blocked
A signal mutex -> wakes B
B signal mutex -> mutex=1
values: mutex=1
blocked: none
Starter code
import sys
from collections import deque

lines = sys.stdin.read().splitlines()
values = {}                                  # semaphore -> current value (never negative)
queues = {}                                  # semaphore -> FIFO of blocked threads
for decl in (lines[0].split() if lines else []):
    name, value = decl.split("=")
    values[name] = int(value)
    queues[name] = deque()

blocked = {}                                 # thread -> semaphore it sleeps on (insertion order)

More lines in Code Lab: 12

Try in Code Lab

Hints and instant feedback are in Code Lab. Optional practice: it does not block finishing the lesson.

JavaScript and Python also run in your browser without an account; other languages use the secure server runner and need a free account.

Deadlocks: everyone waits for everyone 🔁

A deadlock is a set of threads in which each one waits for a resource that another thread of the set holds, so none of them can ever continue. Coffman, Elphick and Shoshani (1971) showed that a deadlock needs all four of these conditions at once:

  1. Mutual exclusion: a resource is held by one thread at a time
  2. Hold and wait: a thread holds one resource while waiting for another
  3. No preemption: a resource cannot be taken away; only its holder releases it
  4. Circular wait: there is a cycle T1 → T2 → ... → T1 of threads waiting for each other

Break any one and deadlock becomes impossible. In practice the most useful target is circular wait: give every lock a place in one global order and always acquire locks in that order. If every thread that needs both accounts and audit takes accounts first, no cycle can form. Other tools:

  • Try and back off (breaks no preemption: a thread that cannot get its next lock gives up the one it holds): pthread_mutex_trylock the second lock; if that fails, release the first and retry later. (Hold and wait is broken differently: take every lock you will need at once, before you start.) Beware of livelock, where threads keep retrying in lockstep and nobody progresses
  • Avoidance: grant a request only if the system stays in a safe state (the Banker's algorithm). General-purpose OSes rarely do this because it needs to know every future request
  • Detection and recovery: build the wait-for graph, look for a cycle and abort one victim; databases do this with transactions

The Linux kernel checks its own locks this way: lockdep records which lock was taken while holding which, and warns about a possible deadlock the first time it sees two locks taken in inverted order, even if the bad interleaving never actually happened. Watch a deadlock happen, then disappear with a consistent order:

With opposite orders, each thread holds the lock the other one needs: all four Coffman conditions hold, so nobody moves again. With one global order the second thread simply waits its turn. Try three threads with plans A-B, B-C and C-A.

// Two threads, two locks. On every tick each thread tries to take its next lock.
function simulate(plans) {
  const owner = {};                       // lock -> index of the thread holding it
  const progress = plans.map(() => 0);
  for (let tick = 1; tick <= 6; tick++) {
    let moved = false;
    plans.forEach((plan, t) => {
      if (progress[t] === plan.length) return;            // already finished
      const lock = plan[progress[t]];
      const name = "T" + (t + 1);
      if (owner[lock] === undefined) {
        owner[lock] = t;
        progress[t]++;
        moved = true;
        console.log("  tick " + tick + ": " + name + " takes " + lock);
        if (progress[t] === plan.length) {
          plan.forEach((l) => delete owner[l]);           // done: release everything
          console.log("  tick " + tick + ": " + name + " finishes and releases " + plan.join(", "));
        }
      } else {
        console.log("  tick " + tick + ": " + name + " waits for " + lock + " (held by T" + (owner[lock] + 1) + ")");
      }
    });
    if (!moved && progress.some((p, t) => p < plans[t].length)) {
      console.log("  DEADLOCK: every unfinished thread waits for a lock another one holds");
      return;
    }
  }
}

console.log("Opposite order (T1: A then B, T2: B then A):");
simulate([["A", "B"], ["B", "A"]]);
console.log("One global order (both: A then B):");
simulate([["A", "B"], ["A", "B"]]);

Lab exercise 3

JavaScript (Node.js)Graded in Code Lab

Build a lock-order checker, a tiny version of the kernel's lockdep.

Input: one line per thread: <thread>: <lock> <lock> ..., the locks that thread acquires in that order, each one taken while it still holds all the earlier ones. A lock appears at most once per line, a line may list no locks, and blank lines are ignored.

Rules:

  • Every pair "lock X is held while lock Y is acquired" is an edge X → Y of the lock-order graph
  • A lock is on a cycle if, following edges from it, you can come back to it. Cycles are potential deadlocks: two or more threads can each hold one lock of the cycle and wait for the next
  • A thread is involved if one of its edges X → Y lies on a cycle, which means that Y can reach X

Output:

  • If some lock is on a cycle: potential deadlock: <locks on cycles, sorted> and then threads: <involved threads in input order>
  • Otherwise: consistent order: <every lock> in an order where each lock comes after every lock that has an edge to it; when several locks are possible, take the smallest name first (Kahn's algorithm). Print consistent order: none if there are no locks at all

Sort names with JavaScript's default sort() (code-unit order, so B comes before a).

Example:

T1: accounts audit
T2: audit accounts
T3: log

prints

potential deadlock: accounts audit
threads: T1 T2
Visible tests (5)
  • Example: two locks taken in opposite orders
  • A consistent order with an independent lock
  • A three-way cycle and a separate pair
  • The order follows the edges, not the alphabet
  • Empty input
Example run
Input
T1: accounts audit
T2: audit accounts
T3: log
Expected output
potential deadlock: accounts audit
threads: T1 T2
Starter code
const lines = require("fs").readFileSync(0, "utf8").split("\n");

const edges = new Map();   // lock -> Set of locks taken while holding it
const locks = new Set();
const threadEdges = [];    // [thread, [[held, next], ...]] in input order

for (const line of lines) {
  const colon = line.indexOf(":");
  if (colon < 0) continue;                       // blank line
  const thread = line.slice(0, colon).trim();
  const order = line.slice(colon + 1).trim().split(/\s+/).filter(Boolean);
  const mine = [];

More lines in Code Lab: 15

Try in Code Lab

Hints and instant feedback are in Code Lab. Optional practice: it does not block finishing the lesson.

JavaScript and Python also run in your browser without an account; other languages use the secure server runner and need a free account.

Quick quiz

Question 1 of 5

Two threads each run counter++ 1,000 times on a shared counter that starts at 0, without a lock. Why can the result be below 2,000?

Question 2 of 5

Why must pthread_cond_wait be called inside a while loop that re-checks the condition?

Question 3 of 5

A semaphore guards a pool of 3 database connections and its value is now 0. A fourth thread calls wait. What happens?

Question 4 of 5

Thread 1 locks A then B; thread 2 locks B then A. Which change makes a deadlock between them impossible?

Question 5 of 5

Which of these is NOT one of the four Coffman conditions for deadlock?

Summary

  • A race condition happens when the result depends on thread timing; counter++ is three instructions, so updates get lost. Code touching shared data is a critical section and needs mutual exclusion, progress and bounded waiting
  • A mutex gives mutual exclusion; it is built on atomic instructions (test-and-set, compare-and-swap), and on Linux on futex so the uncontended case stays in user space
  • A condition variable lets a thread sleep until a condition holds; pthread_cond_wait releases the mutex atomically, and you always wait in a while loop
  • A semaphore counts permits: value 1 is a lock, value N guards N resources; the bounded buffer uses slots, items and mutex
  • A deadlock needs all four Coffman conditions; a global lock order removes circular wait, and tools like lockdep check that order automatically
  • You built a real pthread bounded buffer, a semaphore simulator and a lock-order checker

Practice: read pthreads(7) and sem_overview(7), then compile the race from this lesson with real threads and gcc -fsanitize=thread on a Linux machine to watch ThreadSanitizer report it. Next: file systems, where a crash between two writes causes the same kind of inconsistency a race does.