Priority Inversion and Deadlock Revisited
In July 1997 the Mars Pathfinder rover landed, sent back gorgeous pictures — and then began mysteriously
rebooting itself, again and again, tens of millions of kilometres from the nearest engineer. The bug was
not a cosmic ray or a cracked solder joint. It was a priority inversion: a textbook
concurrency fault, in flight software written by some of the most careful engineers alive. This lesson
revisits two classic hazards — priority inversion and deadlock — at
graduate depth, where they stop being undergraduate puzzles and become the reason real-time and kernel
systems are hard.
Both hazards share a root: a resource held by one task blocks another. In deadlock, the blocking
is mutual and permanent. In priority inversion, a low-priority task's grip on a lock silently
defeats the scheduler's whole purpose, letting an unrelated medium task starve the most urgent
one. Understanding both — and their protocol-level fixes, priority inheritance and the Coffman-condition
toolkit — is core to building systems that meet deadlines and never freeze.
Priority inversion: the urgent task waits on the trivial one
Take three tasks by
real-time
priority: High (H), Medium (M), Low (L).
The sequence that bit Pathfinder:
- L acquires a shared mutex (say, a data bus) and starts its short critical section;
- H wakes, wants the same mutex, and blocks — fine so far, it will only wait for
L's brief critical section;
- but now M wakes. M doesn't need the mutex, but it outranks L, so the scheduler
pre-empts L to run M. L can't release the mutex because it isn't running. And H, the highest
priority task in the system, is stuck waiting on L — which is waiting on M.
The result is an inversion: a medium-priority task effectively runs ahead of a
high-priority one, for as long as M cares to run. If M runs long enough, a watchdog notices H missed its
deadline and — on Pathfinder — resets the whole computer. The scheduler's priorities have been
stood on their head by an accident of lock ownership.
The fix: make priority follow the lock
The cure is to stop letting an unrelated M starve H through L. Two protocols do this:
- Priority inheritance protocol (PIP) — while a low task L holds a lock that a high
task H is blocked on, L temporarily inherits H's priority. Now M cannot pre-empt L, so L
finishes its critical section promptly, releases the lock, and H proceeds. L drops back to its own
priority afterwards. This is the fix NASA uploaded to Pathfinder;
- Priority ceiling protocol (PCP) — each lock is given a ceiling equal to the
highest priority of any task that can ever take it; a task acquiring the lock immediately runs at that
ceiling. Stronger than inheritance: it also prevents deadlock and bounds blocking to a single
critical section, at the cost of needing to know the ceilings in advance;
- both bound the blocking time of a high task to the length of one low task's critical
section — restoring the schedulability analysis that priority inversion had wrecked.
The deep lesson: in a real-time system, a lock silently couples the priorities of everyone who touches
it. You cannot reason about a task's worst-case timing without accounting for every lower-priority
task it might have to wait behind — which is exactly what these protocols make analysable.
Deadlock, at graduate depth: the four Coffman conditions
Deadlock is the other face of "held resources block progress" — but here the block is circular and
permanent. The classic result (Coffman, 1971) is that deadlock requires all four of these
conditions to hold simultaneously; break any one and deadlock becomes impossible:
- Mutual exclusion — at least one resource is held in a non-shareable mode;
- Hold and wait — a task holding one resource can request another while keeping the
first;
- No pre-emption — a resource cannot be forcibly taken from its holder; it must be
released voluntarily;
- Circular wait — there is a cycle of tasks, each waiting on a resource held by the
next. All four together are necessary and sufficient.
The three strategies for handling deadlock map directly onto these conditions:
- Prevention — structurally deny one condition. The most common: impose a global
lock-ordering so a cycle (circular wait) can never form — always acquire lock A before lock B.
The Linux kernel enforces exactly this, and \texttt{lockdep} catches
violations;
- Avoidance — allow the conditions but refuse any allocation that could lead to
deadlock, using advance knowledge of maximum needs. This is Dijkstra's Banker's algorithm:
grant a request only if the resulting state is still "safe" (some ordering of tasks can finish);
- Detection & recovery — allow deadlock to happen, periodically look for a cycle in
the wait-for graph, and break it (kill a task, roll back a transaction). Databases do
this constantly.
Detecting deadlock: find the cycle
Detection reduces to a graph problem you already know. Build the wait-for graph: one node
per task, a directed edge T_i \to T_j whenever T_i is
blocked waiting for a resource currently held by T_j. A cycle in this
graph is a deadlock — each task in the cycle waits on the next, forever. Detecting deadlock is
therefore just cycle detection, a depth-first search. Below, T_1 \to T_2 \to T_3 \to
T_1 forms a cycle (all three are stuck); T_4 waits into the cycle
but is not part of it, so it is blocked but not itself deadlocked in the classic sense.
// Deadlock detection = cycle detection in the wait-for graph.
// Edge i -> j means "task i is waiting for a resource held by task j".
const waitsFor: Record<string, string[]> = {
T1: ["T2"], // T1 waits on T2
T2: ["T3"], // T2 waits on T3
T3: ["T1"], // T3 waits on T1 -> cycle T1->T2->T3->T1
T4: ["T2"], // T4 waits into the cycle but is not on it
};
// DFS with colouring: WHITE unvisited, GREY on the current path, BLACK done.
// A back-edge to a GREY node is a cycle.
function findCycle(graph: Record<string, string[]>): string[] | null {
const colour: Record<string, number> = {};
const parent: Record<string, string | null> = {};
for (const n of Object.keys(graph)) colour[n] = 0; // WHITE
let cycleStart: string | null = null, cycleEnd: string | null = null;
function dfs(u: string): boolean {
colour[u] = 1; // GREY
for (const v of graph[u] ?? []) {
if (colour[v] === 0) { parent[v] = u; if (dfs(v)) return true; }
else if (colour[v] === 1) { cycleStart = v; cycleEnd = u; return true; } // back-edge!
}
colour[u] = 2; // BLACK
return false;
}
for (const n of Object.keys(graph))
if (colour[n] === 0 && dfs(n)) {
const path = [cycleEnd as string];
for (let x = cycleEnd as string; x !== cycleStart; x = parent[x] as string) path.push(parent[x] as string);
return path.reverse();
}
return null;
}
const cycle = findCycle(waitsFor);
console.log(cycle ? `DEADLOCK: cycle ${cycle.concat(cycle[0]).join(" -> ")}` : "no deadlock");
// Break the deadlock by pre-empting T3 (drop its edge), then re-check.
waitsFor.T3 = [];
console.log(findCycle(waitsFor) ? "still deadlocked" : "resolved: broke the cycle by aborting T3");
Livelock and convoys: stuck without a cycle
Deadlock is not the only way progress dies. Two cousins:
- Livelock — tasks are not blocked; they are furiously active, but
their actions keep undoing each other so no net progress is made. The two-people-in-a-corridor dance:
both step left, both step right, forever. In systems, two tasks that each detect a possible deadlock and
politely back off — then both retry in lockstep and back off again. The CPU is 100% busy achieving
nothing. Naive livelock "fixes" (retry immediately on conflict) cause it; randomised back-off breaks it;
- Lock convoy — under a fair (FIFO) lock, if the holder is descheduled while others
queue, the whole queue serializes behind it and stays clumped together long after the original stall,
like cars bunched behind a slow truck. Throughput craters even though no deadlock exists. Fixes include
back-off, lock-free structures, or letting a woken waiter re-contend rather than being handed the lock
blindly.
The unifying idea across all four — deadlock, priority inversion, livelock, convoys — is that
correctness of each task in isolation guarantees nothing about the system's liveness. Progress is
a global property that emerges (or fails) from how tasks interact through shared resources.
The Pathfinder engineers could reproduce the resets in their lab replica, and — crucially — they had left
a debugging feature enabled: the VxWorks real-time OS could be told to trace and log task scheduling. The
traces revealed the total resets coincided with the meteorological data task (low priority) holding a mutex
on the information bus while the high-priority bus-management task blocked on it — classic three-task
inversion. The fix was a one-line change: flip on priority inheritance for that mutex (a
parameter VxWorks already supported), and uplink the patched code across interplanetary space. The system
had shipped with inheritance disabled for performance. The moral, quoted ever since: turn on the
safety feature, and never disable diagnostics you might need when your computer is on Mars.
Students routinely blur these. Priority inversion is a scheduling fault: nobody
is permanently stuck, and the high task will eventually run — just far too late to meet its
deadline. Deadlock is a liveness fault: a circular wait means the involved tasks
never proceed, deadline or no deadline. They need different cures. Priority inheritance solves
inversion by lending priority, but it does nothing about a genuine circular wait — two tasks each
holding a lock the other wants will deadlock regardless of whose priority is boosted. Conversely, a
lock-ordering discipline prevents deadlock but does nothing for inversion. Diagnose which hazard
you actually have — a task that is late versus a set of tasks that are frozen — before reaching for a fix,
or you will apply the right medicine to the wrong disease. (The priority ceiling protocol is the
exception that happens to prevent both — which is precisely why hard real-time systems favour it.)