IPC & Synchronisation: Queues, Semaphores, Mutexes

Concurrent tasks must share data and coordinate safely. Learn race conditions and critical sections, message queues, binary and counting semaphores, mutexes with priority inheritance, event groups and task notifications, stream buffers, the classic problems (priority inversion, deadlock, starvation), and design rules for ISR-to-task communication.

As soon as two tasks (or a task and an ISR) touch the same data, you have a potential race: the result depends on exact timing and fails rarely and unpredictably. Synchronisation primitives make the order of operations explicit.

The golden rule of RTOS design is: communicate by passing messages, and share data only when you must — then protect it properly. This lesson gives you the tools and the failure modes to avoid.

An analogy

A mutex is the key to a single bathroom: whoever holds it has exclusive use and must return it. A counting semaphore is a car park barrier counting free spaces. A queue is a mailbox: senders drop letters, the receiver collects them in order and sleeps when it is empty.

Race conditions

count++ is really load → add → store. If a task is preempted between load and store and another task increments count, one update is lost. Any read-modify-write of shared data, or multi-word data (a struct, a 64-bit value), needs protection — or better, a design where only one task owns the data.

Choosing a primitive

Match the tool to the job:

Priority inversion

Low task L holds a mutex; high task H needs it and blocks; medium task M (not using the mutex) preempts L and runs for a long time — H effectively waits for M. This nearly doomed NASA's Mars Pathfinder (1997). Priority inheritance temporarily raises L to H's priority while it holds the mutex, so M can't preempt it; priority ceiling protocols raise it on lock.

Deadlock, starvation and design rules

Deadlock occurs when tasks wait for each other in a cycle (A holds M1 wants M2; B holds M2 wants M1). Prevent it by always acquiring mutexes in a global order, never holding a mutex while blocking on a queue, using timeouts, or avoiding nested locks. Never use a mutex from an ISR (no owner context) — use FromISR semaphores or notifications. Prefer single-owner 'gatekeeper' tasks for shared peripherals: other tasks send requests to its queue.

Formulas

Worked example: Inversion delay

Given: L's critical section 2 ms; M runs 10 ms; No inheritance

Find: H's worst blocking.

Answer: ≈ 12 ms; with inheritance ≈ 2 ms

Worked example: Queue depth

Given: Bursts of 50 CAN frames within 5 ms; Consumer may be delayed 20 ms

Find: Minimum queue depth.

Answer: ≥ 50 entries (+ margin → 64)

Worked example: Deadlock

Given: Task A: lock(SPI) then lock(FLASH); Task B: lock(FLASH) then lock(SPI)

Find: Problem and fix.

Answer: Always lock SPI before FLASH in both tasks (global lock order)

ISR → queue → task, and a mutex-protected shared resource (FreeRTOS)

static QueueHandle_t adc_q;
static SemaphoreHandle_t i2c_mutex;

void ADC_IRQHandler(void)
{
    BaseType_t woken = pdFALSE;
    uint16_t sample = ADC1->DR;
    xQueueSendFromISR(adc_q, &sample, &woken);    /* never blocks in an ISR */
    portYIELD_FROM_ISR(woken);                     /* switch now if a higher task woke */
}

void filter_task(void *arg)
{
    uint16_t s;
    for (;;) {
        if (xQueueReceive(adc_q, &s, portMAX_DELAY) == pdTRUE) {   /* sleeps when empty */
            process_sample(s);
        }
    }
}

void sensor_task(void *arg)
{
    for (;;) {
        if (xSemaphoreTake(i2c_mutex, pdMS_TO_TICKS(10)) == pdTRUE) {  /* timeout! */
            read_imu_over_i2c();                   /* keep this section short */
            xSemaphoreGive(i2c_mutex);
        } else {
            report_error(ERR_I2C_BUSY);
        }
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

void app_init(void)
{
    adc_q = xQueueCreate(32, sizeof(uint16_t));
    i2c_mutex = xSemaphoreCreateMutex();           /* has priority inheritance */
}

Queues copy data, so no sharing at all. The mutex protects a bus used by several tasks; a finite timeout turns a potential deadlock into a detectable error.

Common mistakes

Applications

Key takeaways

Going further