Deadlock, Livelock & Starvation

Priority Inversion

A high-priority task waits on a lock held by a low-priority task, and a medium-priority task — which needs neither the lock nor anything else — preempts the holder and keeps it off the CPU. The highest-priority work in the system is now blocked behind the priority it outranks.

The question this answers

The question

Why is my highest-priority task waiting on work that a medium-priority task keeps interrupting?

The work

A control loop at high priority reading a shared configuration structure, a telemetry writer at low priority that updates it under a mutex, and a batch compressor at medium priority that is CPU-bound.

What is shared

One configuration structure guarded by a mutex. The high-priority reader and the low-priority writer share it; the medium-priority compressor shares nothing at all — which is what makes this counter-intuitive.

The invariant — what must stay true under every interleaving

A ready task at priority p runs before any ready task at priority below p. Priority inversion breaks that invariant transitively: the high-priority task is not ready (it is blocked), but the thing it is blocked *on* is being denied the CPU by a lower priority, so the effective priority of the high-priority work has silently become the priority of the lock holder.

Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.

WorkCan it overlap?Can it parallelise?What is shared?What ordering?What synchronization?Where is contention?What can deadlock?What can race?What is gained?What complexity?

Three tasks, one lock, and the one that shares nothing causes the problem

Read the trace with one question in mind: what does the medium-priority compressor have to do with the configuration lock? Nothing. It never touches it. It is nevertheless the reason the control loop misses its deadline, and that is why this failure is so hard to reason about from a code review — the culprit does not appear in the code you are reviewing.

The chain is: L takes the lock, H becomes ready and blocks on it, and M becomes ready. The scheduler correctly picks M over L, because M has higher priority than L and H is not runnable. L cannot release the lock while it is not running, so H cannot run, so H waits for as long as M is willing to run — which for a batch compressor is "until it finishes".

The critical distinction from Deadlock: there is no cycle. The wait-for graph is a chain, not a loop. Every thread will eventually make progress, and the system will recover on its own. It is a *bounded* problem, but the bound is the medium-priority task's CPU burst, and in the classic case — the Mars Pathfinder rover in 1997 — that bound exceeded a watchdog timer and rebooted the spacecraft repeatedly.

Priority inversion in eight steps. Note that the medium-priority task never touches the lock.ILLUSTRATIVE
Invariant · A ready task at priority p runs before any ready task below p — including transitively, through anything it is waiting on.
#H — control loop (high priority)M — batch compressor (medium, CPU-bound, shares nothing)L — telemetry writer (low priority)State
1··lock(L_cfg) — acquired; begins a 200 µs updaterunning=L L_cfg=held by L H state=sleeping
2wakes on its 1 ms timer; preempts L (correct)··running=H L_cfg=held by L
3lock(L_cfg) — blocks··running=L L_cfg=held by L H state=blocked on L_cfg
4·becomes runnable; preempts L (correct by strict priority)·running=M L_cfg=held by L H state=blocked L state=ready, preempted
✕ Priority invariant, transitively: H (high) is now waiting on L (low), and L is denied the CPU by M (medium). The system is executing medium-priority work while high-priority work is blocked on it.
5·runs its full 40 ms compression burst·running=M H waited=40 ms H deadline=1 ms — missed
6·finishes or is preempted·running=L H waited=40 ms
7··completes the update; unlock(L_cfg)running=L L_cfg=free
8wakes, acquires, runs — 40 ms late··running=H H waited=40 ms deadline misses=1
9--- with priority inheritance: at step 3, L is boosted to H's priority··running=L (boosted to high) M state=ready, cannot preempt
Priority inversion is not a cycle and not a bug in any single task. It is the scheduler correctly applying priorities to a set of threads whose *effective* priorities have been silently changed by a lock. The fix must therefore live in the lock, not in the tasks.

The graph is a chain, not a cycle

Drawing this alongside a deadlock graph is the fastest way to make the difference stick. In Deadlock the edges close into a loop and no scheduling decision can help. Here the edges form a path — H waits on the lock, the lock is held by L — and there is a perfectly good schedule that resolves it: run L. The scheduler simply is not choosing that schedule, because from its point of view L is the lowest-priority runnable thread and M outranks it.

That observation is the whole basis of the fix. Since the problem is that the scheduler does not know about the H → lock → L path, the fix is to tell it: priority inheritance temporarily raises the lock holder's priority to that of the highest-priority waiter, for as long as it holds the lock. L is boosted to high, M can no longer preempt it, L finishes in 200 microseconds, and H waits 200 microseconds instead of 40 milliseconds.

The alternative, priority ceiling, raises the holder to a statically configured ceiling — the highest priority of any task that can ever take this lock — at acquisition time, without needing to detect a waiter. It is cheaper and more predictable but requires knowing the full set of users of each lock in advance, which is realistic in an embedded system and not in a general application. Both are OS/runtime mechanisms; see The Scheduling Problem for the scheduler side.

A chain, not a cycle. M appears in the scheduler but not in the graph — which is why the graph alone does not explain the delay.ILLUSTRATIVE
● H — high, blocked▢ Mutex: config● L — low, holds the lock, preempted● M — medium, running, wants nothing
H — high, blockedwaits forMutex: config· waits for
Mutex: configwaits forL — low, holds the lock, preempted· held by
M — medium, running, wants nothingwaits forL — low, holds the lock, preempted· preempts (scheduler edge, not a wait edge)

It is not only a real-time problem

Priority inversion is usually taught as an embedded/real-time topic, and the classic reference case is spacecraft. The pattern is far more common than that framing suggests, because "priority" appears in many disguises: an operating-system nice level, a thread-pool with a priority queue, a foreground/background split in a UI, and — most commonly in modern systems — a runtime where some work is on a critical path and some is not.

The application-level version usually looks like this: a request-serving thread pool holds a lock while a batch job at the same OS priority saturates every core. The request thread holding the lock cannot get scheduled, and every other request thread queues behind it. There is no explicit priority anywhere in the code — the "medium priority" work is simply *more numerous*, so the holder gets a small share of the CPU. Oversubscription produces priority inversion without any priorities at all; see Oversubscription.

The timeline below shows the two regimes side by side. The mitigation in an application context is not usually inheritance (most language-level locks do not offer it) but the same structural moves as everywhere else in this module: hold the lock for less time, do not hold it across anything that can be preempted for long, and do not let unbounded background parallelism compete with latency-critical work for cores. See Bounding Concurrency.

The same three tasks with and without inheritance. The boost is what stops M from preempting the holder.SIMULATED
Without inheritance — H (high)
sleeping
blocked on L_cfg
runs
Without inheritance — M (medium, unrelated)
ready
compressing — preempts the lock holder
ready
Without inheritance — L (low, holds the lock)
holds lock, updating
READY, preempted by M while holding the lock
finishes, releases
With inheritance — L boosted to high on H's arrival
holds lock, updating
boosted: M cannot preempt; finishes and releases
back to low priority
With inheritance — H
sleeping
blocked (critical section only)
runs — on time
↑ H blocks; M becomes runnable↑ with inheritance: H is already running↑ without inheritance: H finally runs
runningreadywaitingblockedidle1 unit ≈ 5 ms

Key points

  • The delay is caused by a task that shares nothing with either participant — which is why it is invisible in a review of the locking code.
  • It is a chain in the wait-for graph, not a cycle: it always resolves, bounded by the medium-priority task's CPU burst.
  • Priority inheritance boosts the lock holder to the highest waiter's priority for the duration of the hold, bounding the wait by the critical-section length instead.
  • Priority ceiling boosts unconditionally at acquisition to a statically known ceiling — cheaper and more predictable, but requires knowing every user of the lock.
  • The application-level version needs no explicit priorities: oversubscription alone starves a lock holder of CPU and produces the same shape.

The loop, answered

Every field is required, which is why no lesson here can recommend concurrency without naming the interleaving that breaks it, the complexity it adds, and the simpler thing to consider first.

How it works
  • A low-priority task acquires a lock that a high-priority task will need.
  • The high-priority task becomes runnable, requests the lock, and blocks; the scheduler falls back to the lock holder.
  • A medium-priority task becomes runnable and legitimately preempts the low-priority holder, because the scheduler ranks by the holder's own priority.
  • The holder cannot release what it is not running to release, so the high-priority task waits for the medium task's entire burst.
  • With inheritance, the block in step 2 triggers a boost of the holder to the waiter's priority; the medium task can no longer preempt it, and the boost is removed on release.
Interleavings that matter
  • No M: L holds, H blocks, scheduler runs L (nothing outranks it among runnables), L releases in 200 µs, H proceeds. No inversion — which is why it does not reproduce on an idle test machine.
  • With M: L holds, H blocks, M preempts L for 40 ms, H waits 40 ms. Inversion, and the delay is set by an unrelated task.
  • With inheritance: L holds, H blocks and boosts L to high, M is ready but outranked, L finishes in 200 µs, H proceeds and L drops back to low.
  • Chained inversion: L holds lock 1 and is blocked on lock 2 held by another low-priority task. Inheritance must propagate transitively down the chain or the boost stops at the first hop and achieves nothing.
  • Application analogue with no priorities: one request thread holds a cache lock while 200 batch threads saturate 8 cores. The holder gets ~4% of a core, the critical section takes 25× longer than it should, and every request thread queues behind it. Same shape, no priority API involved.
What it guarantees — and does not
  • Priority inheritance guarantees that a high-priority task's blocking time is bounded by the critical-section length rather than by unrelated tasks' bursts. It does not eliminate blocking.
  • It does not prevent deadlock. Inheritance is orthogonal to cycles — a boosted thread in a cycle is still deadlocked, just at a higher priority.
  • Priority ceiling guarantees a bound without needing to detect waiters, and additionally prevents deadlock among ceiling-protected locks — but only if the ceilings are configured correctly for every user of every lock.
  • Neither guarantees anything if the boost is not transitive: a chain of nested locks requires propagation, and an implementation that only boosts the direct holder gives a false sense of safety.
  • Ordinary application mutexes guarantee nothing here. std::mutex, Java monitors and Python locks have no inheritance protocol at all; the mechanism must come from the OS primitive underneath.
Where contention appears
  • The contention is not on the lock — it is on the CPU, and the lock merely transmits it to a task that never asked for a core.
  • The longer the critical section, the wider the window in which a medium-priority task can arrive, so hold time is again the dominant lever.
  • Inheritance moves contention rather than removing it: the boosted holder now preempts the medium-priority task, which is exactly the intent and is also a latency cost that task pays.
  • In oversubscribed application servers this compounds with Lock Convoys: the starved holder produces a queue, and the queue keeps the lock hot long after the CPU pressure passes.
How it fails
  • Deadline miss in a control loop or a real-time path — the classic case, and the one that rebooted Mars Pathfinder until inheritance was enabled remotely.
  • Watchdog-triggered restart, where the inversion exceeds a liveness timer and a supervisor kills a perfectly healthy process.
  • Unbounded inversion chains, where the boost is not propagated through nested locks and each hop reintroduces the problem.
  • Application-level inversion from oversubscription with no priorities present at all — the most common modern instance and the least likely to be named correctly.
  • Inheritance-induced latency for the medium-priority task, which now gets preempted by boosted low-priority work and misses its own (softer) targets.
When it helps
  • Priority inheritance helps wherever a genuine priority hierarchy exists and shared state crosses it — control loops, audio and video pipelines, UI thread against background workers.
  • Priority ceiling helps in closed systems where every task that can touch a lock is known at build time, which is the normal situation in embedded software.
  • Simply *knowing* the pattern helps in ordinary services, because it converts "the p99 spikes when the batch job runs" into a specific, fixable mechanism.
When it hurts
  • Inheritance adds cost to every lock operation — the holder's priority must be tracked and restored, and the boost must propagate — so it is not free on hot locks.
  • Ceilings are brittle: a new caller at a higher priority than the configured ceiling silently reintroduces unbounded inversion, and nothing detects the misconfiguration.
  • Applying real-time thinking to a throughput-oriented service usually produces complexity with no benefit; there the fix is scheduling separation and shorter holds, not a protocol.
How you would know
  • Blocking time per high-priority task, measured as time between requesting a lock and acquiring it. If that exceeds the holder's critical-section length, you have an inversion.
  • Correlate high-priority latency spikes against unrelated medium-priority activity. The signature is a p99 spike on the critical path that lines up with a batch job and shares no resource with it.
  • Run-queue and preemption counts for the lock-holding thread. A thread that is ready rather than running while holding a lock is the definitive observation, and pidstat/perf sched will show it. See Reading a Thread Dump.
  • In application servers: compare lock hold time distribution against CPU saturation. Hold time that scales with system load rather than with work done is CPU starvation of the holder, not lock contention.
  • Where the platform supports it, enable and then verify inheritance — PTHREAD_PRIO_INHERIT set is not the same as effective, and a chained lock will show it.
Complexity it introduces
  • Inheritance protocols are runtime machinery with real edge cases: nested locks, transitive boosts, and correct restoration when multiple waiters at different priorities come and go.
  • Ceilings require a global table mapping locks to priorities, maintained as the system grows — the same decay problem as a lock ordering, with a worse failure mode.
  • Reasoning about the system now requires the priority assignments as well as the lock structure, and the two are usually owned by different people.
  • In application code the honest complexity cost is often zero, because the fix is not a protocol — it is shorter critical sections and bounded background parallelism.
Simpler alternatives
  • Shorten or eliminate the shared critical section so the inversion window closes before any medium-priority task can arrive. Highest leverage, always available. See Lock Scope: What You Hold It Across.
  • Avoid sharing across priority boundaries entirely: give the high-priority path its own copy, updated by an atomic pointer swap the writer performs without excluding readers. See Copy-on-Write as a Concurrency Strategy.
  • Message passing across the boundary instead of shared state — the high-priority task reads a lock-free single-producer queue and never blocks. See Channels.
  • CPU isolation: pin latency-critical work to reserved cores or a reserved scheduling class so unrelated load cannot preempt it. See Thread Affinity: Pinning, and What It Costs You.
  • Bound background parallelism so the "medium priority" pressure cannot saturate every core in the first place. See Bounding Concurrency.

What people believe, and what is true

Claim

Priority inversion is a kind of deadlock.

Reality

The wait-for graph is a chain, not a cycle. It always resolves on its own — the problem is that the delay is bounded by an unrelated task's CPU burst rather than by the critical section.

Claim

It only matters in real-time and embedded systems.

Reality

Any system where some work is latency-critical and some is not has the same shape. The common modern version has no priority API at all: a lock holder starved of CPU by hundreds of competing threads. See Oversubscription.

Claim

Raising the important task's priority fixes it.

Reality

That makes it worse. H is already the highest priority and is blocked; the task that needs boosting is the low-priority *holder*. Fixes act on the holder, never on the waiter.

Go deeper

Overview

High-priority work waits for a lock. The lock is held by low-priority work. Medium-priority work keeps the holder off the CPU, so the highest-priority task in the system waits on the lowest.

Practical

Look for a latency spike on a critical path that correlates with an unrelated background job and shares no resource with it. Check whether the lock holder is ready rather than running. The fix that always works is a shorter critical section; the fix that names the problem is inheritance.

Advanced

The general statement is that a lock silently rewrites effective priority: while holding it, a thread carries the priority of the highest-priority thread that will need it, and any scheduler unaware of that will make wrong decisions. Inheritance makes the scheduler aware; ceilings make it aware statically. Everything else — shorter holds, copies, message passing, core isolation — removes the shared dependency so the rewrite never happens.

Internals

Mars Pathfinder in 1997 is the canonical case: a high-priority bus-management task blocked on a mutex held by a low-priority meteorological task while medium-priority communications work ran, a watchdog fired, and the spacecraft reset. The fix was to enable priority inheritance on that mutex — uploaded to the spacecraft after the fact. The engineering lesson usually drawn is not about the protocol but about the tracing: the team could reproduce it on the ground only because they had kept a full trace facility enabled in flight.

Apply it