The puzzle
The high-priority control task misses its 10 ms deadline about once an hour. Its own code takes 2 ms, it shares one small data structure with a low-priority logging task, and that structure is protected by a lock. The trace shows the control task finishing 12 ms after it became ready, and for most of that time neither the control task nor the logger was running: a medium-priority task that never touches the structure was. How can a task that uses no shared data delay one that is more important than it?
STEP 1
Races between tasks
Preemption can switch tasks between any two instructions, so every shared variable that more than one task writes has the same problem as the handler and main loop of unit 9, lesson 5: count++ is a load, an add and a store, and a switch in the middle loses one update. Two patterns cause most task races:
- read-modify-write of shared data: counters, flags packed into one word, linked lists;
- check-then-act:
if (buffer_has_space()) buffer_put(x);where another task fills the buffer between the check and the act.
The fixes are the ones from lesson 4: make the whole sequence one critical section or one mutex-protected region, give the data a single owner (a gatekeeper task), or pass it through a queue so no two tasks ever touch the same copy. Declaring the variable volatile fixes none of them.
STEP 2
Priority inversion
A mutex makes a task wait for the lower-priority task that holds it. That is priority inversion, and a short amount of it is unavoidable: the high-priority task H waits for the rest of the low-priority task L’s critical section. The Linux documentation accepts that “most of the time it can't be helped” and names the dangerous case unbounded priority inversion: while H waits for L, a medium-priority task M becomes ready. M does not use the resource, but it is more urgent than L, so it preempts L; and since L cannot run, H cannot run either. H now waits for as long as M, and every other medium-priority task, wants to run.
↑ This step uses the figure at the top of the page.
With a plain binary semaphore, H’s blocking time contains M’s entire execution. Nothing in H’s or L’s code is wrong; the failure appears only when the three tasks line up, which is why it shows up rarely and in the field.
STEP 3
Priority inheritance
Priority inheritance removes the medium task from the picture: while H waits for a mutex, the owner L runs at H’s priority, so M can no longer preempt it. When L releases the mutex it drops back to its own priority, and H takes the mutex and runs. H’s blocking is then bounded by the rest of L’s critical section, whatever M does.
FreeRTOS mutexes (xSemaphoreCreateMutex()), Zephyr mutexes and Linux rt-mutexes all use it, with differences in detail:
- FreeRTOS raises the holder to the priority of the waiting task and, on release, returns it to its base priority only when it holds no other mutexes; binary semaphores get no inheritance at all.
- Zephyr tracks every mutex a thread holds and recalculates its priority from the remaining waiters on each unlock, and propagates the boost along chains of owners up to a limit.
- Linux rt-mutexes propagate the boost when a boosted owner itself blocks on another rt-mutex, and remove it “immediately” on unlock.
Inheritance bounds the inversion; it does not make it small. H still waits for the longest critical section of any lower-priority task that shares a lock with it, possibly several in a chain. As the Linux documentation puts it, priority inheritance “is not a magic bullet for poorly designed applications”. Keep critical sections short, and do not block on anything else while holding a lock.
A priority ceiling protocol goes further: each lock is given the priority of its most urgent user, and a task that takes it runs at that priority at once. H can then be blocked by at most one lower-priority critical section, and lock-order deadlocks between those tasks cannot occur. (Zephyr’s CONFIG_PRIORITY_CEILING only caps how far inheritance may raise a priority; it is not this protocol.)
A third option in the figure is to disable preemption (or interrupts) inside L’s critical section: no other task can run while L holds the resource, so M cannot preempt it and the inversion is bounded too. The price is that every task, including ones that never touch the resource, waits for the whole critical section. It suits sections of a few microseconds, not milliseconds.
STEP 4
Deadlock
A deadlock is a set of tasks each waiting for something another member holds, so none of them can ever continue. The most common embedded form needs just two locks taken in opposite orders:
Task T1 locks A (say the SPI bus), works 2 ms, then also locks B (the log buffer). Task T2, more urgent, needs both too. If T2 takes them in the same order, it simply waits for A. If it takes B first and arrives while T1 holds A, each task holds the lock the other needs and neither can continue: a deadlock. The graph shows who holds what and who waits for what; a cycle means deadlock.
Draw a wait-for graph: an arrow from each lock to the task that owns it, and from each waiting task to the lock it wants. A cycle means deadlock. Linux’s lock validator (lockdep) checks exactly this rule: two locks must never be taken in inverse order, because the acquisitions “form a circle”. The design rules:
- Global lock order. Number the locks and always take them in increasing order. A cycle is then impossible.
- Hold one lock at a time, or copy the data out and release before taking the next.
- Timeouts. Take the second lock with a timeout; on failure release the first, wait, and retry. The system recovers, at the cost of wasted work, and the failure can be logged.
- Gatekeeper tasks (lesson 4) remove the locks entirely.
Two self-inflicted deadlocks need only one lock. A task that takes a non-recursive mutex it already holds waits for itself (the pico-sdk documents exactly this for mutex_t). And a lock that an interrupt handler also takes, acquired by a task with interrupts enabled, deadlocks when the handler interrupts the task that holds it: lockdep calls it lock recursion. Unit 9, lesson 6 gave the rule: a handler must not take a lock that the code it interrupted may hold; it signals a task instead.
STEP 5
Worked example: the 12 ms wait
L takes the lock at t = 1 ms and needs 3 ms of work inside it. H becomes ready at t = 2 ms, runs for 1 ms, and requests the lock at t = 3 ms, when L still has 2 ms to go. M, which needs 8 ms, becomes ready at t = 3 ms. With a binary semaphore:
a response of 14 − 2 = 12 ms after H became ready, past its 10 ms deadline. With a priority-inheritance mutex, L runs at H’s priority from t = 3 to t = 5, H finishes at 6 ms, and M runs afterwards:
H’s blocking is now 2 ms, the remaining part of L’s critical section, whatever M does. The scheduler-lock version gives the same 4 ms here, but H also cannot start its first, unrelated millisecond until L leaves the critical section.
MYTHS AND FACTS
Common misconceptions
Priority inversion is a scheduler bug
A short, bounded inversion is inherent in sharing a lock; the bug is the unbounded kind, which inheritance or design prevents.
Priority inheritance makes the waiting short
It makes it bounded by the lower-priority critical sections; long critical sections still mean long waits.
Binary semaphores and mutexes are interchangeable for locking
Only the mutex has an owner to boost; a binary semaphore used as a lock allows unbounded inversion.
Deadlocks would show up in testing
A lock-order deadlock needs a switch at exactly the wrong moment; prove the order instead of waiting to hit it.
Check yourself
Answer in your head, then open the card.
In the three-task example, why is M’s running time part of H’s response with a binary semaphore but not with a priority-inheritance mutex?
With the semaphore, L keeps its low priority, so M preempts it while H waits for L. With inheritance, L runs at H's priority while H waits, so M cannot preempt it until L has released the lock.
A FreeRTOS task holds mutexes X and Y and has inherited a higher priority through a waiter on X. It gives X back. Does its priority drop immediately?
Not in FreeRTOS: it disinherits only when it holds no other mutexes, so it keeps the raised priority until Y is given back too. Zephyr instead recalculates from the waiters on the mutexes still held.
Task 1 takes the SPI lock and then the flash lock; task 2 takes the flash lock and then the SPI lock. Describe the deadlock and one fix.
Task 1 holds SPI and waits for flash while task 2 holds flash and waits for SPI: a cycle. Fix: both take the locks in the same global order (SPI before flash), or take the second lock with a timeout and back off.
A task increments a shared counter under a mutex; an interrupt handler increments the same counter by taking the same mutex. What goes wrong?
Mutexes must not be used in handlers at all; and if a handler could take a lock the interrupted task holds, it would wait for a task that cannot run until the handler returns: a deadlock. Let the handler signal a task, or protect the counter with a short interrupt-masking critical section.
Sources (5)
- Linux kernel, Documentation/locking/rt-mutex-design.rst — “Priority inversion is when a lower priority process executes while a higher priority process wants to run … What we want to prevent is something called unbounded priority inversion”; the A/B/C example: “B executes, and since B is of a higher priority than C, it preempts C, but by doing so, it is in fact preempting A”; with PI “C would inherit the priority of A … As soon as C releases the lock, it loses its inherited priority”
- Linux kernel, Documentation/locking/rt-mutex.rst — “A low priority owner of a rt-mutex inherits the priority of a higher priority waiter until the rt-mutex is released. If the temporarily boosted owner blocks on a rt-mutex itself it propagates the priority boosting”; “Priority inheritance is not a magic bullet for poorly designed applications”
- Zephyr Project documentation, doc/kernel/services/synchronization/mutexes.rst — priority inheritance: the owner’s priority is elevated while a higher-priority thread waits; on unlock “the kernel rescans the thread's remaining held mutexes and recalculates its priority … This gives correct results regardless of the order in which held mutexes are unlocked”; boosts propagate along ownership chains “up to an implementation-defined limit”
- FreeRTOS Kernel, tasks.c (xTaskPriorityInherit, xTaskPriorityDisinherit) — “If the holder of the mutex has a priority below the priority of the task attempting to obtain the mutex then it will temporarily inherit the priority”; on give: “Only disinherit if no other mutexes are held”; “If the mutex is taken by an interrupt, the mutex holder is NULL. Priority inheritance is not applied”
- Linux kernel, Documentation/locking/lockdep-design.rst — “two locks can not be taken in inverse order … because this could lead to a deadlock - referred to as lock inversion deadlock - as attempts to acquire the two locks form a circle”; a lock used in interrupt context taken with interrupts enabled can be taken again by the interrupting context: “lock recursion deadlock”