Operating Systems · Synchronization
Priority Inversion and the Protocols That Bound It
How a low-priority lock holder can block a high-priority thread indefinitely, what inheritance and ceiling protocols each guarantee, and why both need the scheduler inside the lock.
Three threads share a lock. A low-priority thread holds it. A high-priority thread wants it and blocks. A medium-priority thread, which never touches the lock, becomes runnable and preempts the low-priority holder, because it outranks it. The high-priority thread is now waiting not for the lock but for the medium-priority thread to finish, and its wait is unbounded: it lasts as long as the medium-priority work continues to arrive. The holder is runnable but not running, and no amount of correctness in the lock implementation changes that. The defect is in the interaction between the lock and the scheduler, and the remedies have to reach into both.
The three-thread scenario in detail
The scenario is worth stating as a schedule because the failure is often described loosely. Assume a fixed-priority preemptive scheduler, three priorities, and one mutex.
The low-priority thread L acquires the mutex and begins its critical section. The high-priority thread H becomes runnable and preempts L, then tries to acquire the same mutex and blocks. The scheduler picks the highest-priority runnable thread, which is now L, and L resumes. If nothing else becomes runnable, L finishes, releases the mutex, and H proceeds. The delay is bounded by the length of L’s critical section.
Now the medium-priority thread M becomes runnable. It does not touch the mutex. The scheduler picks M over L, because M outranks L, and L stops making progress. H remains blocked on a mutex held by a thread that is not running. The duration of H’s block is now the duration of M’s runnable period, which has no relation to the critical section’s length and no bound at all if M’s work is continuously replenished. This is priority inversion: the priority order of the wait has been inverted by a third party that is not even a party to the lock.
The pathological form is unbounded inversion, where a stream of medium-priority work keeps L off the CPU. The ordinary form is bounded but still surprising: H waits longer than it would if it had used no lock at all, and the excess is the time M runs. A real-time system sizes its tasks against worst-case execution times, and this excess is not in the analysis.
A further cost is chained blocking. Under plain inheritance a high-priority thread can be delayed by a chain of holders: it waits on a lock held by a lower-priority thread, that holder is itself blocked on a second lock held by a third thread, and the boost has to be propagated along the chain before the original waiter can proceed. The waiting thread is delayed once per element of the chain rather than once in total, and the Sha, Rajkumar and Lehoczky analyses count blocking as a sum over nested critical sections for precisely this reason. The consequence is a hard constraint on the real-time code that uses inheritance: nested locks multiply the blocking term, and a design that keeps every critical section at one lock at a time keeps the analysis tractable.
Inheritance: lending priority to the holder
The priority inheritance protocol is the direct remedy. When a thread blocks on a mutex held by a lower-priority thread, the holder’s effective priority is raised to that of the blocked thread for the duration of the hold. The holder then outranks M, so M cannot preempt it, and the critical section completes at the speed it would have without interference.
The protocol has several properties worth being precise about.
The boost is temporary and transitive. When the holder releases the mutex, its priority returns to its own base priority, or to the highest priority of any other thread still blocked on a mutex it holds. If the holder is itself blocked on a second mutex, the boost propagates to that mutex’s holder, and the chain can be followed to its end. The kernel implementation maintains a list of waiters per lock and walks it, which is why priority inheritance is a kernel feature rather than a library trick: raising another thread’s priority is a scheduler operation.
Inheritance prevents unbounded inversion but does not prevent deadlock. A thread can still block on a lock held by another thread that is itself blocked, and if the chain closes into a cycle, the priority boosts follow the cycle and every thread in it waits. The protocol changes the timing of the inversion; it does not change the possibility of a deadlock caused by lock ordering. Sha, Rajkumar, and Lehoczky showed that plain inheritance does not bound blocking to a single critical section: a high-priority thread can be blocked once per critical section of each lower-priority thread that it can be pushed behind, so the worst case is a sum rather than one term. The ceiling protocol exists to collapse that sum.
Inheritance also does not prevent a thread from being preempted by a higher-priority thread that does not contend for the lock. That is correct behaviour: the high-priority thread has work to do and no reason to wait. The inversion is only a defect when the preemption is by a thread that should not have been able to delay the blocked one.
The cost is on the fast path of the lock. An uncontended acquisition must still be cheap, and a contended one must record the waiter’s priority and compare it against the holder’s, then possibly call into the scheduler. On Linux, the futex implementation provides this as a distinct lock type: the PI futex stores the owner’s thread ID in the futex word and keeps the wait list in the kernel, so that every lock and unlock is a syscall. That cost is accepted only where the scheduling hazard justifies it, which is why ordinary mutexes do not use it by default.
Ceilings: paying a static cost for a stronger bound
The priority ceiling protocol attacks the problem from the other side. Instead of raising a priority when a conflict occurs, it assigns each mutex a ceiling: the priority of the highest-priority thread that can ever lock it. A thread that acquires a mutex runs at the ceiling priority for the duration of the hold, whether or not anyone is blocked. The original form of the protocol also has a thread block at acquisition unless its own priority is higher than the ceiling of every mutex currently held by any other thread; blocking then hands the lock to that holder, which is why the protocol produces a single blocking term per critical section rather than one per lock.
The effect on the three-thread scenario is immediate. L acquires the mutex and runs at its ceiling, which is H’s priority. When H becomes runnable and tries to acquire the mutex, it finds it held and blocks; M becomes runnable and cannot preempt L, because L is running at H’s priority. The critical section completes in its own time, and H proceeds.
The stronger result is that a thread can be blocked at most once before it enters its critical section, and only by a lower-priority thread whose critical section it must wait for. That is a single blocking term, not a sum over a chain, which is exactly what a real-time schedulability analysis needs. The protocol also prevents deadlock entirely, provided the ceiling assignments are correct, and the reason is the boost rather than an extra admission rule: a thread holding a mutex runs at that mutex’s ceiling, so it outranks every thread that could contend for the mutex and cannot be preempted by one. A thread can therefore never be stopped while it holds a lock by a thread that needs a lock it holds, and without that preemption the wait-for relation cannot close into a cycle.
The price is that the ceiling must be known statically, and that the holder runs at an elevated priority even when nobody is waiting. A low-priority thread doing a long computation inside a high-ceiling critical section can delay medium-priority threads that would otherwise have run, which is a different inversion, now inflicted deliberately. The ceiling also requires that every mutex have a documented set of users, which is a design constraint on the program’s structure rather than a runtime mechanism. And the immediate ceiling protocol’s guarantee depends on priorities being static and on every mutex being assigned a ceiling before the system starts; a dynamically created lock has no ceiling, and a dynamically raised priority changes the set of threads that can lock it.
The stack resource policy extends the idea to a system-wide ceiling: a thread’s effective priority is the maximum of its own and the ceilings of every resource it holds, and preemption is allowed only when the new thread’s priority exceeds the current ceiling. One rule then covers nested locks and dynamic task creation.
What the scheduler must expose
Both protocols need information that a user-space lock cannot obtain on its own.
Inheritance needs the ability to change another thread’s effective priority, and to restore it exactly when the boost ends. That requires a per-thread base priority separate from the effective priority, and a data structure that records which boosts are active. A library that raises a thread’s priority without that bookkeeping either loses the base priority or restores the wrong one after a nested boost.
Inheritance also needs the priority of the blocked thread and the identity of the holder at the moment of blocking, so the lock word must be able to hold a thread ID rather than a single bit. That is why the PI futex word encodes the owner’s TID, and why its value is not a simple zero-or-one.
Ceilings need even more: the scheduler must enforce the ceiling rule against global state, atomically with respect to the acquisition. That is naturally a kernel operation, and it is the reason ceiling protocols are implemented in real-time kernels rather than in portable threading libraries.
POSIX exposes both as mutex attributes. pthread_mutexattr_setprotocol selects among PTHREAD_PRIO_NONE, PTHREAD_PRIO_INHERIT, and PTHREAD_PRIO_PROTECT, where the last is the ceiling protocol and requires the ceiling to be set with pthread_mutexattr_setprioceiling. A mutex with the protect protocol is held at the ceiling for its duration; a mutex with inherit lends priority on contention. Both require that the threads using the mutex are scheduled under a fixed-priority policy such as SCHED_FIFO or SCHED_RR, because under a fair-share policy the concept of priority is not well defined and the protocols have nothing to work with. A PI mutex used by threads on SCHED_OTHER is accepted by the API and provides no guarantee.
/* Declaring a mutex that lends priority to its holder under contention. */
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_setprotocol(&attr, PTHREAD_PRIO_INHERIT);
/* Threads must run under SCHED_FIFO or SCHED_RR for this to mean anything. */
pthread_mutex_t m;
pthread_mutex_init(&m, &attr);
pthread_mutexattr_destroy(&attr);
Where the protocols are the wrong answer
Inheritance and ceilings both assume that priority is a meaningful, static ordering of threads and that the scheduler is preemptive and priority-driven. When those assumptions fail, the protocols add cost without adding a bound.
Under a fair-share scheduler, priority is a weight that allocates a share of the processor rather than a total order, so raising a holder’s weight does not make it unbeatable by a thread that would otherwise preempt it. Under a deadline scheduler the ordering is by deadline, and the protocol has no field to boost: what the analysis needs is a bound on how long a task may be delayed by another task’s resource hold, which is expressed as a deadline adjustment or handled by admission control rather than by priority inheritance. The equivalent of the inversion is a task holding a resource past its deadline while another task misses its own, and the remedy is resource reservation and admission control rather than a lock attribute.
In a system with no real-time requirement, the inversion is a latency anomaly rather than a correctness failure, and the cost of the kernel round trip on every lock operation exceeds the benefit. The ordinary mutex is the right choice, and the anomaly is managed by keeping critical sections short.
In a lock-free design, the problem does not arise in the same form, because no thread waits for another to release anything. The equivalent hazard is a thread that is descheduled in the middle of a retry loop, which delays only itself. That is one of the arguments for lock-free structures in latency-sensitive paths, though the argument is weaker than it first appears: a lock-free structure still has to make progress, and a stalled thread in the middle of an update can hold up reclamation, as described in lock-free reclamation and the ABA problem.
The kernel’s implementation of inheritance is documented in the RT-mutex design notes, which also state the limit plainly: the mechanism handles the inversion between a waiter and a holder, and it relies on the rest of the kernel not introducing new inversions through its own locks. The same caveat applies to any system that layers protocols on top of a scheduler it does not fully control.
For the futex operations that carry the owner’s identity, see futex wait and wake. For the spinlock discipline that keeps a holder runnable in kernel context, see spinlock contention and queued locks. For the interface that wakes a waiter without regard to its priority, see condition variables and lost wakeups.
References
- Priority Inheritance Protocols: An Approach to Real-Time Synchronization — L. Sha, R. Rajkumar, and J. P. Lehoczky, IEEE Transactions on Computers, 1990
- POSIX.1-2017 — Base Specifications, Issue 7, pthread_mutexattr_setprotocol — The Open Group
- futex(2) — Linux manual page — Linux man-pages project
- Linux kernel documentation: RT-mutex implementation design — Linux kernel project