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:
- Queue / message buffer: pass data between tasks or from ISR to task
- Binary semaphore / task notification: signal an event ('data ready') — lightweight
- Counting semaphore: count events or manage N identical resources
- Mutex: exclusive access to a shared resource; has an owner and priority inheritance
- Event group: wait for combinations of flags (e.g. Wi-Fi up AND time synced)
- Critical section (disable interrupts): only for a few instructions
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
- Response time with blocking:
Rᵢ = Cᵢ + Bᵢ + Σⱼ∈hp(i) ⌈Rᵢ/Tⱼ⌉·Cⱼ - Blocking with priority inheritance:
Bᵢ ≤ Σ longest critical sections of lower-priority tasks sharing its mutexes - Priority ceiling:
Bᵢ ≤ one longest lower-priority critical section - Queue sizing:
Depth ≥ burst rate × worst consumer delay (+ margin) - Deadlock conditions (all four needed):
Mutual exclusion + hold-and-wait + no preemption + circular wait
Worked example: Inversion delay
Given: L's critical section 2 ms; M runs 10 ms; No inheritance
Find: H's worst blocking.
- H waits for L's remaining 2 ms plus all of M
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.
- All 50 frames arrive before the consumer runs
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.
- Circular wait possible
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
- Wrong: Using a binary semaphore for mutual exclusion. — Right: Use a mutex — it has an owner and priority inheritance.
- Wrong: Taking a mutex inside an ISR. — Right: ISRs signal with FromISR semaphores/notifications/queues.
- Wrong: Infinite timeouts everywhere. — Right: Use timeouts and handle failures to detect deadlocks.
- Wrong: Long work while holding a mutex. — Right: Keep critical sections minimal; copy data out and release.
Applications
- Sensor pipelines: ISR → queue → filter task → queue → logger.
- Shared buses: Mutex or gatekeeper task for I²C/SPI used by many drivers.
- Networking: Event groups for 'link up + IP acquired + time synced'.
- Resource pools: Counting semaphores for DMA channels or buffers.
Key takeaways
- Share by message passing; protect what must be shared.
- Queues for data, semaphores/notifications for signals, mutexes for resources.
- Priority inheritance bounds inversion.
- Fixed lock order + timeouts prevent deadlock.
Going further
- Lock-free structures: SPSC ring buffers and atomics avoid locks entirely.
- Priority ceiling protocol: Bounds blocking to one critical section; used in OSEK/AUTOSAR.
- Formal verification: Model checking (SPIN, TLA+) for concurrency designs.