UNIT 13 · LESSON 5 OF 6

Race Conditions, Deadlocks, and Priority Inversion

How can a task that uses no shared data delay one that is more important than it?

INTERACTIVEPriority inversion, and what priority inheritance does about it
A timeline of three tasks showing priority inversion and priority inheritanceHML051015202530msH finishes 12 ms after it arrived (deadline 10 ms)orange: holding the resource; thin orange bar: H blocked; red: L running at H’sinherited priorityH waits for L’s critical section and also for all 8 ms of M, a task with lowerpriority that does not even use the resource: unbounded priority inversion.
A timeline of three tasks showing priority inversion and priority inheritanceHML051015202530msH finishes 12 ms after it arrived (deadline 10 ms)orange: holding the resource; thin orange bar: Hblocked; red: L running at H’s inherited priorityH waits for L’s critical section and also for all 8ms of M, a task with lower priority that does noteven use the resource: unbounded priority inversion.

Try this

The resource is protected by
H responds in 12 ms (beyond its 10 ms deadline).

Low-priority task L locks a shared resource at 1 ms and needs it for 3 ms of work. High-priority H arrives at 2 ms, runs 1 ms, then needs the same resource and must wait for L: a short, bounded inversion. Medium-priority M, which never touches the resource, becomes ready at 3 ms. Without priority inheritance M preempts L, and H waits for all of M as well. With a mutex that uses priority inheritance, L runs at H’s priority until it unlocks. H’s deadline is 10 ms after it arrives.

What you will be able to do
  • Identify races between tasks, including lost updates and check-then-act sequences, and protect them.
  • Explain bounded and unbounded priority inversion with a three-task example and compute the high-priority task’s blocking.
  • Explain how priority inheritance bounds the inversion, and its limits in FreeRTOS, Zephyr and Linux.
  • Recognise a lock-order deadlock from a wait-for graph and prevent it with a global lock order or timeouts.
  • Avoid self-deadlock with non-recursive mutexes and locks shared with interrupt handlers.
Before you start
  • Mutexes, semaphores and critical sections (lesson 4).
  • The lost update between a handler and the main loop (unit 9, lesson 5).
Steps in this lesson
  1. Races between tasks
  2. Priority inversion
  3. Priority inheritance
  4. Deadlock
  5. Worked example: the 12 ms wait
  6. Common misconceptions

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:

INTERACTIVETwo locks taken in opposite orders
Two tasks and two locks with the wait-for graphT1T2both blocked for ever0246810msT1lock AT2lock Bgraph at t = 3 ms: an arrow from a lock points to its owner; an arrow from a taskpoints to the lock it waits forDeadlock at 3 ms: T1 → B → T2 → A → T1 is a cycle. Neither task can continue. Takelocks in one global order, or give up and release after a timeout.
Two tasks and two locks with the wait-for graphT1T2both blocked for ever0246810msT1lock AT2lock Bgraph at t = 3 ms: an arrow from a lock points toits owner; an arrow from a task points to the lockit waits forDeadlock at 3 ms: T1 → B → T2 → A → T1 is a cycle.Neither task can continue. Take locks in one globalorder, or give up and release after a timeout.
T2 takes
T2 becomes ready at
Deadlock at 3 ms.

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:

tH done=3+8⏟M+2⏟rest of L+1⏟H inside=14 mst_{\text{H done}} = 3 + \underbrace{8}_{\text{M}} + \underbrace{2}_{\text{rest of L}} + \underbrace{1}_{\text{H inside}} = 14\ \text{ms}

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:

RH=1+2+1=4 msR_{\text{H}} = 1 + 2 + 1 = 4\ \text{ms}

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)
  1. 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”
  2. 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”
  3. 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”
  4. 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”
  5. 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”