Skip to content

05 · Semaphores & Mutexes

Queues move data; semaphores move signals — "something happened," "a resource is free." Their cousin the mutex protects shared things — a serial port, an I2C bus, a config struct — from being used by two tasks at once. The two look similar (both are take/give objects) but solve opposite problems, and mixing them up causes real bugs: this module draws the lines sharply, demonstrates priority inheritance, and gives you your first deadlock — on purpose.

Binary semaphores: signaling that something happened

A binary semaphore holds either 0 or 1. One task (or ISR — next module) gives it; another task takes it, blocking until the give happens. It's the RTOS way to say event — with no data attached, just "go."

SemaphoreHandle_t startSignal;

void workerTask(void *pv) {
  for (;;) {
    // Sleeps (0% CPU) until someone gives the semaphore:
    if (xSemaphoreTake(startSignal, portMAX_DELAY) == pdTRUE) {
      Serial.printf("[%lu ms] worker: triggered!\n", millis());
    }
  }
}

void triggerTask(void *pv) {
  for (;;) {
    vTaskDelay(pdMS_TO_TICKS(1500));
    Serial.printf("[%lu ms] trigger: firing worker\n", millis());
    xSemaphoreGive(startSignal);
  }
}

void setup() {
  Serial.begin(115200);
  startSignal = xSemaphoreCreateBinary();     // starts EMPTY (must be given first)
  xTaskCreate(workerTask,  "worker",  2048, NULL, 2, NULL);
  xTaskCreate(triggerTask, "trigger", 2048, NULL, 1, NULL);
}

void loop() { vTaskDelay(portMAX_DELAY); }

Because a binary semaphore can only hold one "give," events don't accumulate: two gives before a take still wake the taker once. If every event must be counted, use a counting semaphore or a queue.

Counting semaphores: tracking a countable resource

A counting semaphore holds 0..N. Classic uses: a pool of N identical resources (take = claim one, give = return one), or counting events that mustn't be lost:

// 3 identical "slots" (e.g. buffers); 4 tasks compete for them
SemaphoreHandle_t slots = xSemaphoreCreateCounting(3, 3);  // max 3, start 3

void userTask(void *pv) {
  int id = (int)(intptr_t)pv;
  for (;;) {
    xSemaphoreTake(slots, portMAX_DELAY);          // claim a slot (or wait)
    Serial.printf("task %d: got a slot (%u left)\n",
                  id, uxSemaphoreGetCount(slots));
    vTaskDelay(pdMS_TO_TICKS(500 + 300 * id));     // "use" it
    xSemaphoreGive(slots);                         // return it
    vTaskDelay(pdMS_TO_TICKS(200));
  }
}

At any moment at most three tasks are inside the take/give window — the fourth waits, automatically, with no polling.

Mutexes: protecting shared state

A mutex (mutual exclusion) guards a resource so only one task uses it at a time. The take/give calls are the same, but the meaning is different — and so is the machinery:

struct Config { float threshold; uint32_t periodMs; };
Config config = {2.5f, 1000};
SemaphoreHandle_t configMutex;

void writerTask(void *pv) {
  for (;;) {
    vTaskDelay(pdMS_TO_TICKS(3000));
    xSemaphoreTake(configMutex, portMAX_DELAY);   // ---- critical section ----
    config.threshold += 0.1f;                     // both fields updated
    config.periodMs  += 50;                       // together, atomically
    xSemaphoreGive(configMutex);                  // --------------------------
  }
}

void readerTask(void *pv) {
  for (;;) {
    vTaskDelay(pdMS_TO_TICKS(700));
    xSemaphoreTake(configMutex, portMAX_DELAY);
    Config snapshot = config;                     // consistent copy
    xSemaphoreGive(configMutex);
    Serial.printf("threshold=%.1f period=%lu\n",
                  snapshot.threshold, snapshot.periodMs);
  }
}

void setup() {
  Serial.begin(115200);
  configMutex = xSemaphoreCreateMutex();          // starts AVAILABLE
  xTaskCreate(writerTask, "writer", 2048, NULL, 1, NULL);
  xTaskCreate(readerTask, "reader", 2048, NULL, 2, NULL);
}

void loop() { vTaskDelay(portMAX_DELAY); }

Without the mutex, the reader could be preempted between the writer's two field updates and print a threshold from the new config with a periodMs from the old — a torn read that no volatile can prevent. Mutex discipline: take before touching, give immediately after, keep the critical section tiny (copy out, then work on the copy — as readerTask does). Never block (vTaskDelay, queue waits) while holding a mutex, and only the task that took a mutex may give it.

Priority inheritance — why a mutex isn't just a binary semaphore

The famous failure mode: priority inversion. Low-priority task L holds the lock; high-priority task H blocks waiting for it; medium-priority task M (unrelated) preempts L — so M, the least urgent runnable task, is effectively blocking H, the most urgent, indefinitely. This bug famously rebooted the Mars Pathfinder lander.

Mutexes fix it with priority inheritance: while H waits for the mutex, L is temporarily boosted to H's priority, so M cannot preempt it; L finishes the critical section quickly, releases, and drops back down. Binary semaphores do not do this — which is exactly why the rule is:

Mutex for protecting a resource. Semaphore for signaling an event. Never swap them.

(Mutexes also must not be used from ISRs, and shouldn't be used for signaling — the inheritance bookkeeping assumes lock/unlock by the same task.)

Deadlock, in four lines

Two locks, taken in opposite orders by two tasks:

// Task A:                         // Task B:
xSemaphoreTake(mutex1, ...);       xSemaphoreTake(mutex2, ...);
xSemaphoreTake(mutex2, ...);     xSemaphoreTake(mutex1, ...);  

A holds 1 and wants 2; B holds 2 and wants 1. Both wait forever. Defenses, in order of preference:

  1. Hold one lock at a time (restructure so you never nest).
  2. Global lock ordering — if you must nest, every task takes locks in the same documented order (always mutex1 before mutex2).
  3. Timeouts — take with pdMS_TO_TICKS(100) instead of forever, and back off (release everything, retry) on failure; at minimum, log it — a timeout that fires is a design bug telling you where.

When a queue beats a semaphore

If the "event" carries any data — which reading, which button, how much — a semaphore forces you to smuggle the data through a shared global next to the signal, reinventing the race you were avoiding. A queue is a semaphore plus payload plus buffering; use it whenever the consumer needs to know more than "it happened." Likewise "N events pending" with data per event is just a queue of depth N. Semaphores win only for pure, data-free signaling (especially ISR→task wake-ups) and resource counting — and Module 7 shows an even cheaper tool (task notifications) for the simplest of those cases.

Cheat sheet

API / concept Purpose
xSemaphoreCreateBinary() Event flag; starts empty; gives don't accumulate
xSemaphoreCreateCounting(max, init) Resource pool / event counter 0..max
xSemaphoreCreateMutex() Lock with priority inheritance; starts available
xSemaphoreTake(s, timeout) Wait/claim — pdTRUE on success
xSemaphoreGive(s) Signal/release (mutex: same task that took it)
uxSemaphoreGetCount(s) Current count
Priority inversion Low holds lock high needs; medium preempts low → high starves
Priority inheritance Mutex boosts the holder to the waiter's priority — mutexes only
Deadlock defenses Don't nest → fixed lock order → timeouts + backoff
Queue vs semaphore Event carries data? Queue. Pure signal / resource count? Semaphore. Shared state? Mutex.

How It Actually Works

A binary semaphore and a mutex share the same underlying queue-based data structure (a queue of length 1 with no data, just a count), but a mutex carries one extra field a plain semaphore doesn't: the holder task handle, which is what makes priority inheritance possible. When a high-priority task calls xSemaphoreTake() on a mutex already held by a low-priority task, the kernel doesn't just block — it walks the holder's TCB and temporarily raises its priority to match the blocked task's, then re-sorts it into the correct Ready-list priority slot. This stops priority inversion: without inheritance, a medium-priority task that never touches the mutex could keep preempting the low-priority holder indefinitely, and the high-priority task would starve waiting on a resource held by a task that never gets to run. The moment the low task calls xSemaphoreGive(), the kernel restores its original priority and immediately reschedules — because the give operation ends with the same "check if a waiter should preempt me" logic queues use. Plain binary/counting semaphores skip all of this bookkeeping (no holder concept applies to counting a pool of N resources), which is exactly why they must never be used to protect a shared mutable resource — only a mutex's holder tracking prevents inversion.

Exercise

  1. Build the priority-inversion demo: lowTask (prio 1) takes a binary semaphore and busy-works 500 ms before giving it; highTask (prio 3) wakes every 200 ms and takes the same semaphore; mediumTask (prio 2) busy-works in 50 ms bursts. Log timestamps and measure how long high waits.
  2. Replace the binary semaphore with a mutex and measure again — explain the improvement in terms of priority inheritance.
  3. Construct the two-mutex deadlock on purpose (add logging before each take). Confirm both tasks freeze while the rest of the system keeps running. Fix it once with lock ordering, then again with 100 ms timeouts plus retry, and state which fix you'd ship and why.