Lesson 08 / 15
Deadlock and Starvation
Deadlock's four conditions, detecting it through cycle detection on the wait-for graph, lock ordering's structural prevention, and its cost in waiting steps.
Contents
The previous lesson measured the cost of a single lock and collected the result on a single axis: waiting steps. On that axis, a bad number means a slow program. This lesson brings out a second kind of failure that produces no number on that axis at all: a deadlocked thread does not slow down, it stops.
Two locks are enough. If one thread holds the first lock and requests the second, while another thread holds the second and requests the first, neither ever advances again. This lesson counts when this situation arises, builds the structural form of prevention, and measures what prevention costs.
Four Conditions
Deadlock does not happen at random. It arises when four conditions hold at the same time, and breaking any one of them makes it impossible.
Mutual exclusion. The resource cannot be shared; only one thread can hold it at a time. Removing this condition would also remove the previous lesson’s correctness guarantee.
Hold and wait. A thread requests a new resource without releasing the one it already holds.
No preemption. A lock cannot be forcibly taken back; only the holder can release it.
Circular wait. The waiting relationship forms a ring.
CC14. The wait-for graph’s nodes are threads, and its edges are the relation “waiting for a held lock.” CC15. A lock is held by only one thread at a time; if the requested lock is free, there is no edge. CC16. Every thread holds one lock and requests another; releasing a lock cannot be forced, and recovery after detection is not modeled.
The first three conditions hold in most programs by design. The one remaining target is circular wait, and in the language of graphs that is exactly a cycle.
Cycle Detection Is Not a New Routine
The depth-first search lesson in the Data Structures course built cycle detection
on a directed graph, and the same distinction applies here unchanged: “visited”
must not be confused with “still open.” The shared definition’s has_cycle
routine carries this with three colors — 0 never seen, 1 still open, 2 branch
finished. The routine is not rebuilt; it is called directly.
The measurement below first tests five wait-for graphs, then scans a state space.
from itertools import permutations, product def has_cycle(waiting: dict[str, list[str]]) -> bool: """Cycle test on a directed graph. color 1 = still open, color 2 = branch done.""" color = {d: 0 for d in waiting} def visit(d): color[d] = 1 for h in waiting.get(d, []): if color.get(h, 0) == 1: return True if color.get(h, 0) == 0 and visit(h): return True color[d] = 2 return False return any(color[d] == 0 and visit(d) for d in list(waiting)) for name, graph in (("nobody waiting", {"T1": [], "T2": []}), ("chain T1 -> T2", {"T1": ["T2"], "T2": []}), ("two branches, shared target", {"T1": ["T3"], "T2": ["T3"], "T3": []}), ("ring T1 -> T2 -> T1", {"T1": ["T2"], "T2": ["T1"]}), ("ring T1 -> T2 -> T3", {"T1": ["T2"], "T2": ["T3"], "T3": ["T1"]})): print(f" {name:28s} deadlock {has_cycle(graph)}") def scan(lock_count: int, thread_count: int, ordered: bool) -> tuple[int, int]: """Each thread holds one lock and requests another. All states.""" locks = list(range(1, lock_count + 1)) states = deadlocked = 0 for held in permutations(locks, thread_count): for requested in product(locks, repeat=thread_count): if any(t == i for t, i in zip(held, requested)): continue # does not request the lock it already holds if ordered and any(i < t for t, i in zip(held, requested)): continue # ordered acquisition: only a higher number is requested owner = {k: f"T{j+1}" for j, k in enumerate(held)} graph = {f"T{j+1}": [] for j in range(thread_count)} for j, i in enumerate(requested): if i in owner: # if the requested lock is free, there is no edge graph[f"T{j+1}"].append(owner[i]) states += 1 if has_cycle(graph): deadlocked += 1 return states, deadlocked print() print("4 locks , each thread holds one lock and requests another") print(" threads acquisition states deadlocked deadlock ratio") for n in (2, 3, 4): for ordered in (False, True): d, k = scan(4, n, ordered) label = "ordered" if ordered else "free " ratio = f"{k / d:.4f}" if d else "no states" print(f" {n:7d} {label} {d:8d} {k:10d} {ratio:>16s}")
nobody waiting deadlock False
chain T1 -> T2 deadlock False
two branches, shared target deadlock False
ring T1 -> T2 -> T1 deadlock True
ring T1 -> T2 -> T3 deadlock True
4 locks , each thread holds one lock and requests another
threads acquisition states deadlocked deadlock ratio
2 free 108 12 0.1111
2 ordered 22 0 0.0000
3 free 648 264 0.4074
3 ordered 36 0 0.0000
4 free 1944 1944 1.0000
4 ordered 0 0 no states
The third row is the heart of the measurement: with four locks and three threads, 648 distinct waiting states can be built, and 264 of them are deadlocked. The ratio is 0.4074. The second branch matters: at two threads the ratio is 0.1111, at three it is 0.4074, at four it is 1.0000. As threads are added, deadlock stops being the exception and becomes the rule.
The 1.0000 in the row where four threads hold four locks is not a coincidence. When every thread holds one lock and requests another, the “whose lock am I waiting for” relation builds a graph with exactly one outgoing edge per node; a finite graph of this kind, with no node lacking an outgoing edge, always contains a ring.
Detection and Prevention Are Not the Same Thing
The scan above is a detection routine: given a state, it says whether it is deadlocked. Using this in a running system requires three things: continuously keeping the wait-for graph up to date, scanning the graph at regular intervals, and rolling back a thread when a cycle is found. The cost of the scan was measured in the Data Structures course and is ; it is not repeated here.
The third step is the most expensive. Rolling back means forcibly stripping a held lock and canceling the work that thread has done — that is, breaking the no-preemption condition — and the canceled work has to be redone. This lesson does not model that recovery (CC16).
Prevention, on the other hand, measures nothing and scans nothing during the run. It pays its cost at design time: locks are given an order, and that order is followed everywhere.
Lock Ordering
In the table, every ordered row has 0 deadlocked states. This is not an
improvement; it is a structural impossibility, and its justification fits in
one sentence.
CC17. Locks are placed in a fixed total order; every thread requests locks only in increasing number order.
Suppose a ring exists. Then every thread in the ring is waiting for a lock numbered higher than the one it holds. Walking around the ring, the numbers keep increasing; but the ring returns to where it started, and a number cannot be greater than itself. Contradiction. So no ring exists.
The table shows this in two separate ways. At three threads, the state space drops from 648 to 36, and none of the remaining 36 states are deadlocked. At four threads, under lock ordering, no states remain at all — that state cannot even be constructed.
The drop from 648 to 36 here also says where prevention’s cost lies: most of the eliminated states were not deadlocked. Lock ordering forbids not only rings but also many harmless forms of waiting.
The Cost of Prevention
The forbidden harmless states are paid for somewhere. A thread that needs a second lock must, to follow the order, acquire the first lock early and hold onto it; two separate critical sections collapse into a single one.
CC18. Under free acquisition, two resources have two separate locks, and two threads can be in a critical section at the same time; this gives the same numbers as a semaphore with two permits. Under ordered acquisition, the two locks are held nested, so there is a single critical section.
def locked_run(thread_count: int, critical: int, non_critical: int, permits: int = 1) -> dict: remaining = [{"critical": critical, "outside": non_critical} for _ in range(thread_count)] inside, t, waiting, busy = [], 0, 0, 0 while any(k["critical"] or k["outside"] for k in remaining): active = 0 for j, k in enumerate(remaining): if k["outside"]: k["outside"] -= 1 active += 1 continue if not k["critical"]: continue if j in inside or len(inside) < permits: if j not in inside: inside.append(j) k["critical"] -= 1 active += 1 if not k["critical"]: inside.remove(j) else: waiting += 1 busy += active t += 1 return {"time": t, "waiting": waiting, "busy": busy, "utilization": round(busy / (t * thread_count), 4)} print("two locks , critical section 10 steps total , non-critical 10 steps") print(" threads acquisition permits time waiting utilization") for n in (2, 4, 8): for label, permits in (("free ", 2), ("ordered", 1)): k = locked_run(n, 10, 10, permits) print(f" {n:7d} {label} {permits:4d} {k['time']:4d} {k['waiting']:7d}" f" {k['utilization']:8.4f}") print() for n in (2, 4, 8): s = locked_run(n, 10, 10, 1)["time"] - locked_run(n, 10, 10, 2)["time"] b = locked_run(n, 10, 10, 1)["waiting"] - locked_run(n, 10, 10, 2)["waiting"] print(f" {n} threads -> cost of ordered acquisition: time +{s} , waiting +{b}")
two locks , critical section 10 steps total , non-critical 10 steps
threads acquisition permits time waiting utilization
2 free 2 20 0 1.0000
2 ordered 1 29 9 0.6897
4 free 2 29 18 0.6897
4 ordered 1 47 54 0.4255
8 free 2 47 108 0.4255
8 ordered 1 83 252 0.2410
2 threads -> cost of ordered acquisition: time +9 , waiting +9
4 threads -> cost of ordered acquisition: time +18 , waiting +36
8 threads -> cost of ordered acquisition: time +36 , waiting +144
At four threads, lock ordering raises time from 29 to 47 and waiting steps from 18 to 54. At eight threads, the gap widens further: time from 47 to 83, waiting from 108 to 252. The cost grows with the thread count — +9, +18, +36.
These numbers make the trade explicit. Lock ordering brings the deadlock probability down from 0.4074 to 0.0000; in return, 144 extra waiting steps are paid at eight threads. Zero deadlock is bought at the price of a performance loss, and this trade cannot be defended without being counted.
Starvation
Deadlock is threads stopping together. Starvation, by contrast, is a single thread’s turn never coming while the system keeps running. Unlike deadlock, there is no cycle in the wait-for graph; the lock keeps being granted, just always to the same threads.
CC19. Threads request the critical section continuously; as soon as they finish their non-critical work, they queue again. CC20. When the lock frees up, one of the waiters is chosen; the selection rule takes either the lowest-numbered waiter or the longest-waiting one.
def continuous_run(thread_count: int, critical: int, non_critical: int, duration: int, fair: bool) -> dict: """Threads that continuously request the critical section. The lock goes to the longest waiter if fair, otherwise to the lowest-numbered waiter.""" remaining = [0] * thread_count # steps left in the critical section outside = [0] * thread_count # steps left in non-critical work request = [0] * thread_count # how long it has been waiting turns = [0] * thread_count # how many times it entered the critical section inside = None for t in range(duration): for j in range(thread_count): if outside[j]: outside[j] -= 1 elif inside != j: request[j] += 1 if inside is None: waiting = [j for j in range(thread_count) if not outside[j]] if waiting: chosen = max(waiting, key=lambda j: request[j]) if fair else min(waiting) inside, remaining[chosen], request[chosen] = chosen, critical, 0 turns[chosen] += 1 if inside is not None: remaining[inside] -= 1 if not remaining[inside]: outside[inside] = non_critical inside = None return {"turns": turns, "starved": request} # starved: steps waited since the last request print("4 threads , critical 10 , non-critical 10") print(" duration selection rule turn distribution total turns steps starved") for duration in (400, 4000): for fair in (False, True): s = continuous_run(4, 10, 10, duration, fair) label = "fair " if fair else "lowest " print(f" {duration:4d} {label} {str(s['turns']):20s} {sum(s['turns']):10d}" f" {s['starved']}")
4 threads , critical 10 , non-critical 10 duration selection rule turn distribution total turns steps starved 400 lowest [20, 20, 0, 0] 40 [0, 0, 400, 400] 400 fair [10, 10, 10, 10] 40 [20, 10, 0, 0] 4000 lowest [200, 200, 0, 0] 400 [0, 0, 4000, 4000] 4000 fair [100, 100, 100, 100] 400 [20, 10, 0, 0]
In four hundred time units, the critical section sees 40 total entries —
under both selection rules. Throughput is the same. The distribution is not:
the rule that picks the lowest-numbered waiter distributes entries as
[20, 20, 0, 0]. The third and fourth threads never enter and sit starved for
400 steps.
When the duration is multiplied by ten, the result does not change:
[200, 200, 0, 0]. Steps starved rise from 400 to 4000. This is a failure
distinct from deadlock, and it does not pass with time; it has no bound and
grows with duration.
The fair selection rule distributes the same 40 entries as [10, 10, 10, 10].
Total throughput does not change, so fairness comes free here. The reason it
comes free is that the critical section is already serialized; the cost was
already paid in the previous lesson.
The real warning in this measurement is that a throughput metric cannot see starvation. The total turn count is 40 under both rules; the only thing that reveals the failure is the distribution.
Three Numbers
| Metric | Baseline without abstraction | Setup with abstraction | Cost |
|---|---|---|---|
| Deadlocked states | single lock, 0 | four locks, three threads, 264/648 | ratio 0.4074 |
| After lock ordering | — | 0/36 | time +18, waiting +36 |
| Starvation | single thread, 0 | four threads, unfair selection | 2 threads, 4000 steps starved |
The table’s second row carries the course’s rule: eliminating deadlock is not a gain, it is a trade. At four threads, the 0.4074 deadlock ratio is zeroed out, and in return every thread waits longer.
Summary
- Deadlock arises when four conditions hold at once; in practice the only breakable condition is circular wait, which is a cycle in the wait-for graph.
- Cycle detection is not a new routine; the three-color test built in the Data Structures course’s depth-first search lesson is called unchanged.
- With four locks and three threads, 264 of 648 waiting states are deadlocked (0.4074); as the thread count grows, the ratio rises from 0.1111 to 1.0000.
- Lock ordering makes a ring structurally impossible — the state space drops from 648 to 36 and deadlocked states become 0; the cost at eight threads is 144 extra waiting steps.
- In starvation there is no cycle and throughput does not drop: in 400 time units, a total of 40 entries is the same under both selection rules; the failure shows up only in the distribution.
Next Step
Everything measured so far happened on a single core; the threads never actually advanced at the same time. The next lesson lifts that restriction. Adding a core brings the time down from 197 to 180, but a fourth core plateaus at 179, and idle core steps rise from 108 to 1355. The same lesson will also show what became of the first lesson’s quantum protection: under real parallelism, the reachable interleavings climb back to 20, and 18 of them are wrong.
To keep your progress and take notes, Log in
My notes
Log in to take notes.