Sign in or create a free account to continue.

ROB-320 · Lesson 6 of 12 · 30 min

Concurrency, synchronization and priority inversion

As soon as two tasks share anything, a sensor bus, a buffer of joint targets, a log file, a counter, the order in which they touch it starts to matter. Preemptive scheduling, which lesson 5 relied on to give high-priority tasks fast responses, is exactly what makes sharing dangerous: a task can be interrupted in the middle of updating a structure, and another task can read it half-written. Synchronization primitives prevent that, but they introduce their own timing hazards, the most famous being priority inversion, which nearly ended NASA's Mars Pathfinder mission in 1997. This lesson covers race conditions, critical sections, mutexes and semaphores, priority inversion and the protocols that bound it, deadlock, and lock-free alternatives used in robot software.

Race conditions

A race condition is a bug whose effect depends on the timing of concurrent activities. The textbook case is incrementing a shared counter. 'count = count + 1' compiles to three steps: load count into a register, add one, store it back. If task A loads the value 41, is preempted, task B loads 41, adds one and stores 42, and then A resumes and stores its own 42, one increment is lost. On a robot, the shared item might be a joint target written by the trajectory task and read by the control task: if the control task reads three of six joint values from the new trajectory point and three from the old one, the arm receives a target that was never planned. Such bugs may appear once a week, never in the lab, and leave no trace.

The cure is to make each access to shared data atomic, meaning it completes as one indivisible step from the point of view of other tasks. A sequence of operations that must be atomic is a critical section. Critical sections can be protected by disabling interrupts briefly (suitable only for a few instructions, and covered in lesson 7), by a mutex, or by designs that avoid sharing mutable data at all.

Worked problem

Spotting a torn read

A control task at 1 kHz reads a 6-element joint target array; a trajectory task at 250 Hz writes all six elements, taking 4 µs. Without protection, how often might the control task start reading during a write?

Show solution
  1. Writes occur 250 times per second, each lasting 4 µs: the array is mid-write for \( 250 \times 4 = 1000 \) µs per second, or 0.1 % of the time.
  2. The control task reads 1000 times per second, so on average about \( 1000 \times 0.001 = 1 \) read per second could overlap a write, if their timing were independent.

Answer: Roughly one torn target per second if the timing drifts freely, or never, or every cycle, if the two tasks happen to be phase-locked. That unpredictability is what makes races dangerous; the access must be protected or redesigned.

Mutexes and semaphores

A mutex (mutual exclusion lock) is a kernel object that only one task can hold at a time. A task takes the mutex before entering a critical section and gives it back afterwards; if another task holds it, the requester blocks until it is released. Mutexes have an owner, which lets the kernel apply priority inheritance (below) and detect errors such as a task releasing a mutex it does not hold. A semaphore is a counter with take and give operations; a binary semaphore is a signaling device (an interrupt gives it, a task waits for it), and a counting semaphore tracks a pool of identical resources. A common mistake is to use a binary semaphore as a lock: it works functionally, but without ownership the kernel cannot apply priority inheritance.

A mutex-protected shared structure: hold the lock only long enough to copy
/* Protecting a shared joint target with a FreeRTOS mutex */
static SemaphoreHandle_t target_mtx;      /* created with xSemaphoreCreateMutex() */
static joint_target_t shared_target;

void trajectory_publish(const joint_target_t *t)          /* 250 Hz task */
{
    if (xSemaphoreTake(target_mtx, pdMS_TO_TICKS(1)) == pdTRUE) {
        shared_target = *t;                                /* short copy   */
        xSemaphoreGive(target_mtx);
    } else {
        fault_report(FAULT_TARGET_LOCK_TIMEOUT);
    }
}

void control_get_target(joint_target_t *out)              /* 1 kHz task */
{
    xSemaphoreTake(target_mtx, portMAX_DELAY);
    *out = shared_target;                                  /* copy, then release */
    xSemaphoreGive(target_mtx);
}

Two rules keep mutex use safe in real-time code. Keep critical sections short and bounded: copy data in and out under the lock, and do the computation outside it. And use timeouts where blocking indefinitely would be worse than reporting a fault. The longest critical section of each lower-priority task becomes the blocking term \( B_i \) in the response-time analysis of lesson 5, so long critical sections directly lengthen high-priority response times.

Priority inversion

Priority inversion happens when a high-priority task waits for a resource held by a low-priority task, and a medium-priority task, which needs neither, preempts the low-priority holder. The high-priority task is now effectively waiting for the medium-priority task, for as long as the medium-priority task runs. If several medium-priority tasks are active, the delay is unbounded.

Two timelines with three rows labeled H, M and L. In the upper timeline without inheritance, L takes a lock, H arrives and blocks on the lock, then M arrives and runs for a long stretch while H waits, and only after M finishes does L release the lock so H can run. In the lower timeline with priority inheritance, L inherits H's priority when H blocks, so M cannot preempt it; L finishes its short critical section, H runs immediately, and M runs afterwards.
Unbounded priority inversion (top) and its prevention by priority inheritance (bottom)

The Mars Pathfinder lander, which touched down on 4 July 1997 running the VxWorks RTOS, suffered exactly this. A low-priority meteorological data task held a mutex protecting a shared information bus; a high-priority bus management task needed the same mutex; and medium-priority communications tasks preempted the low-priority holder. The high-priority task could not finish in time, a watchdog detected the missed deadline, and the spacecraft reset itself repeatedly, losing data. Engineers at the Jet Propulsion Laboratory reproduced the fault on a ground replica using the system's trace facility, found that the mutex had been created with priority inheritance turned off, and fixed it by uploading a small change that switched inheritance on. The episode became the standard illustration of why real-time engineers treat locking and priorities as one design problem.

Priority inheritance and priority ceiling

Two protocols bound priority inversion.

  • Priority inheritance: when a high-priority task blocks on a mutex, the task holding it temporarily inherits the blocked task's priority until it releases the mutex. Medium-priority tasks can no longer preempt the holder, so the high-priority task waits only for the remainder of the critical section. FreeRTOS and Zephyr mutexes inherit priority by default; POSIX mutexes do so when created with the PTHREAD_PRIO_INHERIT protocol.
  • Priority ceiling: each mutex is given a ceiling equal to the highest priority of any task that uses it. In the immediate ceiling variant, a task that locks the mutex is raised to the ceiling at once, before anyone else asks. This prevents deadlock between ceiling-protected mutexes and limits each task to being blocked at most once, for the length of one critical section, per job. POSIX offers it as PTHREAD_PRIO_PROTECT, and the Ada and OSEK/AUTOSAR worlds use it widely.

With either protocol, the blocking term for task i is bounded: under the ceiling protocol, \( B_i \) is the longest single critical section of any lower-priority task that uses a mutex whose ceiling is at least task i's priority. Under basic inheritance the bound is a little larger, because a task can be blocked once per mutex it needs.

Simulating the Pathfinder pattern: response times in ticks with and without priority inheritance
# Discrete-time simulation of priority inversion with and without inheritance
def simulate(inherit):
    # name: (priority, release, plan); plan steps: 'c' compute, 'k' needs lock
    tasks = {"L": (1, 0, "kkkkc"), "M": (2, 2, "cccccccc"), "H": (3, 1, "ckkc")}
    pos = {n: 0 for n in tasks}; done = {}; owner = None
    for t in range(40):
        def prio(n):
            p = tasks[n][0]
            if inherit and owner == n:
                waiting = [tasks[m][0] for m in tasks if m != n and m not in done
                           and tasks[m][1] <= t and tasks[m][2][pos[m]] == "k"]
                p = max([p] + waiting)
            return p
        ready = [n for n in tasks if n not in done and tasks[n][1] <= t
                 and not (tasks[n][2][pos[n]] == "k" and owner not in (None, n))]
        if not ready:
            continue
        run = max(ready, key=prio)
        if tasks[run][2][pos[run]] == "k":
            owner = run
        pos[run] += 1
        plan = tasks[run][2]
        if pos[run] == len(plan) or plan[pos[run]] != "k":
            if owner == run:
                owner = None
        if pos[run] == len(plan):
            done[run] = t + 1
    return {n: done[n] - tasks[n][1] for n in tasks}

for inherit in (False, True):
    r = simulate(inherit)
    print("inheritance" if inherit else "no protocol", " response times:",
          ", ".join("%s=%d" % (n, r[n]) for n in "HML"))
Output
no protocol  response times: H=15, M=8, L=17
inheritance  response times: H=7, M=14, L=17

Without a protocol, the high-priority task takes 15 ticks, because its wait includes the whole of the medium task's execution. With inheritance it takes 7: its own work plus the rest of the low task's critical section. The medium task, which does not need the resource, finishes later instead, rising from 8 ticks to 14, and the low task is unaffected. That trade, making unrelated lower-priority work wait so that urgent work is bounded, is exactly what fixed-priority scheduling is supposed to do.

Worked problem

Bounding the blocking term

A 1 kHz joint task (highest priority) shares a mutex with a 100 Hz logging task, whose critical section lasts up to 40 µs, and another mutex with a 50 Hz configuration task, whose critical section lasts up to 120 µs. Using the immediate priority ceiling protocol, what is the joint task's blocking term?

Show solution
  1. Under the ceiling protocol a job is blocked at most once, by one lower-priority critical section.
  2. The blocking term is the longest such section: \( B = \max(40, 120) = 120 \) µs.

Answer: B = 120 µs, added to the joint task's response time. Shortening the configuration task's critical section, for example by copying the configuration under the lock and parsing it outside, would reduce the joint task's worst case directly.

Deadlock

Deadlock occurs when tasks wait for each other in a cycle: task A holds mutex 1 and waits for mutex 2, while task B holds mutex 2 and waits for mutex 1. Neither can proceed, ever. Four conditions are all needed for deadlock: mutual exclusion, holding a resource while waiting for another, no preemption of resources and a circular wait. Breaking any one prevents it. The most practical rule is a global lock order: number every mutex and always acquire them in increasing order, which makes a circular wait impossible. Other defenses are to avoid holding one lock while taking another, to use timeouts that turn a hang into a reportable fault, and, under the immediate priority ceiling protocol, to rely on its built-in deadlock freedom on a single core.

Worked problem

Checking a lock order

A motion task takes the SPI bus lock then the calibration lock. A calibration task takes the calibration lock then the SPI bus lock. Is deadlock possible, and how would you fix it?

Show solution
  1. Each task can hold one lock while waiting for the other, forming a cycle.
  2. Assign an order, for example SPI = 1, calibration = 2, and require ascending acquisition.
  3. The calibration task must take SPI first, then calibration.

Answer: Deadlock is possible. Enforcing a single global order (SPI before calibration in both tasks) removes the circular wait. Better still, restructure so neither task holds both locks at once.

Lock-free communication

Locks are not the only way to share data. Several lock-free patterns are standard in real-time robot code because they never block the faster task.

  • Single-producer single-consumer ring buffer: one task only writes the head index, the other only writes the tail index, and on a single core (or with appropriate memory barriers on multicore) no lock is needed. This is the usual way to pass samples from an interrupt to a task, or log entries from a control task to a logging task.
  • Double buffering: the writer fills an inactive buffer and then switches a single pointer or index, which is one atomic store, to publish it. The reader always sees a complete buffer. A triple buffer adds a third slot so the writer never has to wait for the reader to finish.
  • Sequence locks: the writer increments a counter before and after writing; the reader reads the counter, copies the data, reads the counter again and retries if it changed or was odd. Readers never block the writer, which suits a fast writer and occasional readers.
  • Atomic operations: modern processors provide atomic increment, compare-and-swap and load-linked/store-conditional instructions, accessible from C11 and C++11 atomics, for counters and flags.

Lock-free code is harder to write correctly than it looks, especially on multicore processors with weakly ordered memory, so it is best taken from well-tested libraries or kept to the simple single-producer single-consumer patterns.

Ways to share data between real-time activities
ApproachBlocks the writer?Blocks the reader?Typical use
Mutex with inheritanceYes, brieflyYes, brieflyGeneral shared structures between tasks
Disable interrupts brieflyNo (delays interrupts)NoA few instructions shared with an interrupt
SPSC ring bufferNo (drops or overwrites when full)NoInterrupt to task streams, logging
Double or triple bufferNoNoLatest joint targets, latest sensor frame
Sequence lockNoReader may retryFast writer, slow occasional readers

Common mistakes

  • MistakeUsing a binary semaphore as a lock

    InsteadUse a mutex so the kernel can apply priority inheritance.

  • MistakeDoing computation while holding a lock

    InsteadCopy data under the lock and compute outside it.

  • MistakeCreating mutexes with inheritance disabled

    InsteadEnable inheritance or a ceiling protocol, as Pathfinder's fix did.

  • MistakeAcquiring multiple locks in different orders in different tasks

    InsteadEnforce one global lock order or avoid nested locks.

  • MistakeWriting custom lock-free code for multicore without memory barriers

    InsteadUse tested libraries or simple single-producer single-consumer patterns.

Summary and key terms

  • Race conditions arise when preemption interleaves accesses to shared data, producing rare and timing-dependent bugs.
  • Critical sections must be atomic, protected by mutexes, brief interrupt disabling or lock-free designs.
  • Mutexes have owners and support priority inheritance; semaphores signal events or count resources.
  • Priority inversion lets medium-priority tasks delay a high-priority task waiting on a low-priority lock holder.
  • Mars Pathfinder's 1997 resets were caused by priority inversion and fixed by enabling inheritance.
  • Priority inheritance and ceiling protocols bound the blocking term used in response-time analysis.
  • Deadlock needs a circular wait, which a global lock order prevents, and lock-free buffers avoid blocking altogether.
Race condition
A bug whose outcome depends on the relative timing of concurrent activities.
Critical section
Code that accesses shared data and must run atomically.
Atomic
Completing as one indivisible step from the viewpoint of other activities.
Mutex
A lock with an owner, held by one task at a time.
Semaphore
A counter used to signal events or count available resources.
Priority inversion
A high-priority task delayed by lower-priority tasks because of a shared resource.
Priority inheritance
Temporarily raising a lock holder to the priority of the highest waiter.
Priority ceiling protocol
Raising a lock holder to the highest priority of any task using that lock.
Deadlock
A permanent standstill in which tasks wait for each other in a cycle.
Lock-free
A design in which tasks share data without blocking each other.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. Why can count = count + 1 lose updates?

    Show answer

    It is a load, add and store that can be interrupted.

  2. What distinguishes a mutex from a binary semaphore?

    Show answer

    A mutex has an owner and supports priority inheritance.

  3. Name the three task roles in a priority inversion.

    Show answer

    High, medium and low priority.

  4. Which RTOS did Mars Pathfinder use?

    Show answer

    VxWorks.

  5. Under the ceiling protocol, how many times can a job be blocked?

    Show answer

    At most once, for one critical section.

  6. Critical sections of 30, 75 and 50 µs exist in lower tasks. What is B under the ceiling protocol?

    Show answer

    75 µs.

  7. List the four conditions for deadlock.

    Show answer

    Mutual exclusion, hold and wait, no preemption, circular wait.

  8. What simple rule prevents circular waits?

    Show answer

    Acquire locks in one global order.

  9. What pattern passes samples from an interrupt to a task without locks?

    Show answer

    A single-producer single-consumer ring buffer.

  10. What does a triple buffer add over a double buffer?

    Show answer

    The writer never waits for the reader.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz