30. Synchronization and thread safety
This section is about bugs that did not exist before the operating system. While the program was one loop, a variable changed in only one place and only when the loop reached that place. Now two tasks can change it at the same time — and they do, more often than you think.
Terms used in the documentation
Term Meaning race condition the result depends on the timing of tasks atomic operation an indivisible operation critical section a stretch of code run with interrupts masked scheduler suspension task switching is paused mutual exclusion / mutex only one task at a time / a lock for that priority inversion a high-priority task waits for a lower one priority inheritance the lock holder temporarily gets the waiter's priority deadlock tasks wait for each other forever recursive mutex a mutex the same task can take several times gatekeeper task a task that alone owns a resource thread safe safe to call at the same time from several tasks
30.1 A race: why counter++ does not work
The most innocent line in the world:
shared_counter++;
The compiler turns it into three instructions:
ldr r1, [r0] ; прочитать значение
adds r1, #1 ; прибавить единицу
str r1, [r0] ; записать обратно
Now let two tasks execute this line, and let the scheduler switch
them between ldr and str:
| Step | Task A | Task B | Value |
|---|---|---|---|
| 1 | read 5 | 5 | |
| 2 | preempted | read 5 | 5 |
| 3 | wrote 6 | 6 | |
| 4 | wrote 6 | 6 |
Two increments — the result is plus one. One is lost, and neither task will ever know. This is a race: the result depends on the moment at which the scheduler switched tasks.
Anything longer than one instruction is subject to races: a structure of two
fields, a 64-bit number on a 32-bit processor, any "read —
modify — write". The volatile keyword (section 3) does not help
at all here: it makes the compiler read the variable
from memory, but it does not turn three instructions into one.
Three ways to protect, from coarse to fine.
30.2 The critical section
taskENTER_CRITICAL();
shared_counter++;
taskEXIT_CRITICAL();
Inside the section there are no task switches and no interrupts at the threshold
and below (section 28): the kernel raises BASEPRI to 0x50. The three instructions
execute as one.
Sections nest. The kernel counts the depth and removes the mask only on exit from the outermost one. So a function with a section inside can safely be called from another section.
From an interrupt — a separate pair, taskENTER_CRITICAL_FROM_ISR and its
partner with the saved value. The ordinary pair must not be called from a handler:
with configASSERT enabled the kernel catches this.
⚠️ A section must be short. While it is open, all interrupts at the threshold and below wait. Ten microseconds is 720 cycles at 72 MHz during which UART reception, the tick and everything else are stalled. The rule: a handful of instructions inside a section. No loops, no calls, no waiting.
A single counter can do without a section. On Cortex-M3/M4 the increment is done with a C11 atomic operation (124.5):
#include <stdatomic.h>
static atomic_uint shared_counter;
void count_event(void)
{
atomic_fetch_add_explicit(&shared_counter, 1u, memory_order_relaxed);
}
armclang builds this into an LDREX/STREX loop: if an interrupt or a task switch
happened between the read and the write, STREX does not write, and
the loop tries again — the increment is not lost. Interrupts are not
disabled at all, so you can count this way even together with fast
interrupts above the threshold, which a section does not protect against (28.6).
Cortex-M0/M0+ have no such instructions: the same line becomes a call to
__sync_fetch_and_add_4, which again contains a section.
The section is a tool for small things: an increment, copying a pair of fields, reading and clearing a flag. For anything long — the next method.
30.3 Suspending the scheduler
vTaskSuspendAll();
recalculate_table(); /* десятки микросекунд */
xTaskResumeAll();
Softer than a section: only task switching is forbidden; interrupts run as usual. Suitable for long operations on data that is touched only by tasks, not by interrupts.
From an interrupt that occurs inside the pause you may call FromISR —
the kernel remembers that someone was woken and switches on exit
from xTaskResumeAll.
⚠️ Inside the pause you must not call functions that may block. With the scheduler stopped there is no one to wait:
vTaskDelay,xQueueReceivewith a non-zero time are errors, and the kernel catches them with a check.
30.4 The mutex
When the resource is not a variable but something long: the I²C bus, a display, a file on a card. A section is no good here — you cannot hold interrupts off for the duration of a bus transfer. You need a lock that one task takes while the others wait as tasks, without using the CPU.
static SemaphoreHandle_t m_bus;
/* в main, до vTaskStartScheduler */
m_bus = xSemaphoreCreateMutex();
configASSERT(m_bus != NULL);
void bus_write(uint8_t addr, uint8_t val)
{
if (xSemaphoreTake(m_bus, pdMS_TO_TICKS(50)) == pdTRUE) {
i2c_transfer(addr, val);
xSemaphoreGive(m_bus);
} else {
/* 50 мс не дождались — шина висит, и это надо знать */
}
}
Three rules of the mutex:
If you took it, give it back. Every Take has its own Give, on all exit paths
from the function, including the error ones.
The one who took it gives it back. The kernel relies on this, but for an ordinary mutex
it does not check: a foreign Give with configASSERT enabled halts the program,
and without it goes through and corrupts the priority inheritance bookkeeping. A recursive
mutex checks the owner and returns failure to anyone else.
Not from an interrupt. Neither take nor give. An interrupt is not a task, it cannot be an owner, and it has no priority to inherit. For interrupts — a binary semaphore or a queue.
⚠️ Always check the result of a
Takewith a limited wait. Failure means the resource has been busy for longer than is reasonable. Starting a bus transfer without getting the mutex means overlapping someone else's transfer, and that is worse than skipping your own.
Mutex and binary semaphore: what is the difference
The structure is the same, the cost is the same — 80 bytes. The behaviour differs:
| Binary semaphore | Mutex | |
|---|---|---|
| knows its owner | no | yes |
| priority inheritance | no | yes |
| who can give it | anyone, including an interrupt | only the owner |
| purpose | event signal | resource protection |
The semaphore is for the "interrupt → task" pairing (section 28). The mutex is for "one resource, many takers". You can mix the roles, but then you lose the only thing the mutex exists for.
30.5 ⚠️ Priority inversion
The situation that makes it worth understanding mutexes more deeply than "take — give".
Three tasks: L — low priority, M — medium, H — high. L has taken the mutex and is working with the bus. H wakes up, also wants the bus and blocks on the mutex — waiting for L to give it. So far everything is fair.
Now M wakes up. It does not need the bus; it is just computing. M has higher priority than L, so it preempts it. L does not run — does not give the mutex — H waits. A high-priority task waits for a medium-priority one that has nothing to do with the resource. How long does it wait? Until M is done working. That is, without bound.
| Task | Priority | What it does |
|---|---|---|
| H | 3 | waits for the mutex, can do nothing |
| M | 2 | computes its own stuff, not involved |
| L | 1 | holds the mutex, preempted, not running |
This is not a theoretical problem. This is exactly how the Mars Pathfinder lander hung in 1997: a low-priority weather data task held the shared bus mutex, a high-priority bus management task waited for it, and between them a medium-priority communications task ran for long stretches — and the watchdog timer rebooted the spacecraft. The cure was found remotely: priority inheritance was enabled on that mutex.
Priority inheritance is what a mutex does:
While a task holds a mutex that a higher-priority task is waiting for, it gets the priority of the waiting task.
For the duration of ownership L becomes level three. M with its two no longer preempts it. L finishes its work, gives the mutex, returns to one, H gets the resource. H's wait is bounded by the time L works with the resource — and only by that.
On a trace — the same three tasks with a binary semaphore and with a mutex:
Only the mutex gives inheritance. A binary semaphore does not know its owner and has no one to raise — which is why it must not be used to protect a resource.
⚠️ Inheritance is not a cure but a painkiller. It limits inversion but does not remove it: H still waits until L is done with the bus. The real cure is to hold the mutex as briefly as possible: take it, do the minimum, give it back. Do not compute under a mutex, do not wait under a mutex, do not call functions under a mutex that themselves wait.
30.6 ⚠️ Deadlock
Two resources, two tasks, two mutexes.
Task A: take the bus, then the log. Task B: take the log, then the bus.
A took the bus. B took the log. A waits for the log — B has it. B waits for the bus — A has it. Forever. Both are blocked, neither uses the CPU, the device looks alive from the outside and does nothing.
There is one cure, and it is organizational: a single locking order across the whole program. If there are several mutexes, they are numbered, and every task takes them strictly in ascending order. It gives them back in reverse order.
/* Во всех задачах: сначала bus, потом log. Без исключений. */
xSemaphoreTake(m_bus, portMAX_DELAY);
xSemaphoreTake(m_log, portMAX_DELAY);
/* ... */
xSemaphoreGive(m_log);
xSemaphoreGive(m_bus);
The second remedy is a limited wait instead of portMAX_DELAY.
The deadlock does not disappear, but it turns into a timeout failure that you can
notice and handle.
30.7 The recursive mutex
An ordinary mutex cannot be taken twice by the same task: the second Take
blocks forever — the task is waiting for itself. This happens
unnoticed: a function holding the mutex calls another function that
also takes this mutex.
void outer(void)
{
xSemaphoreTakeRecursive(m, portMAX_DELAY);
inner(); /* внутри — тот же мьютекс */
xSemaphoreGiveRecursive(m); /* освобождён только теперь */
}
A recursive mutex counts nesting: as many times as it was taken, that many
times it must be given, and only then is it free. It has its own functions —
TakeRecursive and GiveRecursive; the ordinary ones do not work with it.
Do not use it by default: it hides the structure of the program. If functions take the same mutex from each other, that is a reason to work out who is responsible for the resource, not a reason to turn on recursion.
30.8 🔧 The gatekeeper task
An alternative to the mutex that removes a whole class of problems.
The idea: one task owns the resource, alone and always. The others do not touch it at all — they put requests into this task's queue.
static QueueHandle_t q_log;
static void gatekeeper_task(void *arg)
{
log_msg_t m;
for (;;) {
xQueueReceive(q_log, &m, portMAX_DELAY);
uart_write(m.text, m.len); /* единственное место, где трогают UART */
}
}
/* любая задача, любое прерывание */
xQueueSend(q_log, &msg, 0);
xQueueSendFromISR(q_log, &msg, &woken);
What you get:
| Mutex | Gatekeeper | |
|---|---|---|
| deadlock | possible | impossible — there is no mutex |
| priority inversion | limited by inheritance | none: no one owns the resource |
| from an interrupt | not allowed | allowed — it is just a queue |
| request delay | wait until it is released | put it in and move on |
| cost | 80 bytes | a task plus a queue |
The sender does not wait for the resource — it waits for a slot in the queue, and usually does not wait at all. The order of requests is preserved. The gatekeeper's priority sets how urgently requests are served, and that is one decision instead of analysing every locking path.
The cost is a task with a stack and a queue: for a log of eight messages of 32 bytes this is 336 bytes of queue plus the task. For a UART, a display, a log on a card it is the typical and usually the best solution. The mutex is left for resources where you need an answer, not just a request.
30.9 What to choose
| Situation | Tool |
|---|---|
| an increment, a pair of fields, a flag | critical section |
| a single counter or flags in a word, Cortex-M3/M4 | atomic operation (30.2) |
| a long data recalculation, interrupts not involved | scheduler suspension |
| a resource with long access, an answer is needed | mutex, held briefly |
| a "write and forget" resource: log, display, UART | gatekeeper |
| a signal from an interrupt to a task | binary semaphore or notification |
| one variable, one word, one writer | nothing: a word write is atomic |
The last row matters: writing an aligned 32-bit word on
Cortex‑M is atomic. If one task writes a variable and others read it,
and it fits in a word, no protection is needed. A word in a packed structure
is not written this way, and a reader that waits for a change in a loop declares the variable
volatile — otherwise the compiler reads it once. A race begins where the read and the write
are separated, or where the data is larger than a word.
30.10 Pitfalls
A counter counts less than it should. A race on the increment. A section or an atomic operation (30.2).
The device is alive but does nothing. A deadlock on two mutexes. Check the locking order in all tasks.
A task hung on xSemaphoreTake of its own mutex. It took it
twice. Sort out the calls, or use a recursive mutex.
A high-priority task slows down for no reason. Inversion: it waits for a mutex held by a preempted low-priority task. Shorten the ownership; make sure it is a mutex, not a semaphore.
configASSERT in vPortEnterCritical. An ordinary section was called
from an interrupt. You need the FROM_ISR pair.
xSemaphoreGive returns failure. The mutex was not taken. A foreign Give
of an ordinary mutex does not fail — only configASSERT catches it;
only xSemaphoreGiveRecursive returns failure to another task.
UART reception loses bytes while working with the display. The critical section is too long. Replace it with a mutex or a gatekeeper.
A mutex from an interrupt. Not allowed in any form. A semaphore, a queue, a notification.
30.11 Summary
counter++is three instructions; a switch between them loses an increment.volatiledoes not protect against a race.- A critical section raises
BASEPRIto the threshold, nests and must be short: its length is a delay for all interrupts below. - Suspending the scheduler forbids only task switching; interrupts run, and blocking calls inside are not allowed.
- A mutex knows its owner and inherits priority; from an interrupt it is not available in any form.
- Priority inversion: the high task waits for the low one, which was preempted by the medium one. Inheritance limits the wait but does not remove it — a mutex is held briefly.
- Deadlock is cured by a single locking order across the whole program.
- A recursive mutex counts nesting; do not use it by default.
- A gatekeeper removes the mutex altogether: one task owns the resource, the others send requests into a queue — including from interrupts.
- Writing one word on Cortex‑M is atomic: one writer and any number of readers need no protection.
Next: section 31 "Events and notifications" — how to pass a fact without data, wait for several conditions at once and do without a semaphore entirely.