30. Синхронизация и потокобезопасность
Раздел про ошибки, которых не было до операционной системы. Пока программа была одним циклом, переменная менялась только в одном месте и только когда до этого места доходил цикл. Теперь две задачи могут менять её одновременно — и делают это, причём чаще, чем кажется.
Как это называется в документации
Оригинал Перевод race condition гонка, состояние гонки atomic operation атомарная операция, неделимая critical section критическая секция scheduler suspension приостановка планировщика mutual exclusion / mutex взаимное исключение / мьютекс priority inversion инверсия приоритетов priority inheritance наследование приоритета deadlock взаимная блокировка, тупик recursive mutex рекурсивный мьютекс gatekeeper task задача-привратник thread safe безопасный при одновременном вызове
30.1 Гонка: почему counter++ не работает
Самая невинная строка на свете:
shared_counter++;
Компилятор превращает её в три инструкции:
ldr r1, [r0] ; прочитать значение
adds r1, #1 ; прибавить единицу
str r1, [r0] ; записать обратно
Теперь пусть две задачи выполняют эту строку, и планировщик переключает
их между ldr и str:
| Шаг | Задача A | Задача B | Значение |
|---|---|---|---|
| 1 | прочитала 5 | 5 | |
| 2 | вытеснена | прочитала 5 | 5 |
| 3 | записала 6 | 6 | |
| 4 | записала 6 | 6 |
Два приращения — результат плюс один. Одно потеряно, и ни одна из задач об этом не узнает. Это и есть гонка: результат зависит от того, в какой момент планировщик переключил задачи.
Всё, что хуже одной инструкции, подвержено гонкам: структура из двух
полей, 64-разрядное число на 32-разрядном процессоре, любое «прочитать —
изменить — записать». Ключевое слово volatile (раздел 3) здесь
не помогает вовсе: оно заставляет компилятор читать переменную
из памяти, но не делает три инструкции одной.
Три способа защиты, от грубого к тонкому.
30.2 Критическая секция
taskENTER_CRITICAL();
shared_counter++;
taskEXIT_CRITICAL();
Внутри секции — ни переключений задач, ни прерываний от порога
и ниже (раздел 28): ядро поднимает BASEPRI до 0x50. Три инструкции
выполняются как одна.
Секции вкладываются. Ядро считает глубину и снимает маску только на выходе из самой внешней. Поэтому функция с секцией внутри спокойно вызывается из другой секции.
Из прерывания — своя пара, taskENTER_CRITICAL_FROM_ISR и парная
к ней с сохранённым значением. Обычная пара из обработчика вызываться
не должна: при включённом configASSERT ядро это ловит.
⚠️ Секция должна быть короткой. Пока она открыта, все прерывания от порога и ниже ждут. Десять микросекунд — это 720 тактов при 72 МГц, на которые встал приём по UART, тик и всё остальное. Правило: в секции — единицы инструкций. Никаких циклов, никаких вызовов, никакого ожидания.
Один счётчик — можно и без секции. На Cortex-M3/M4 приращение делают атомарной операцией C11 (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 собирает это в петлю LDREX/STREX: если между чтением и
записью случились прерывание или переключение задачи, STREX не пишет, и
петля повторяет попытку — приращение не теряется. Прерывания при этом не
запрещаются вовсе, поэтому так можно считать и вместе с быстрыми
прерываниями выше порога, от которых секция не спасает (28.6). У
Cortex-M0/M0+ таких команд нет: та же строка становится вызовом
__sync_fetch_and_add_4, внутри которого снова секция.
Секция — инструмент для мелких вещей: инкремент, копирование пары полей, чтение и сброс флага. Для длинного — следующий способ.
30.3 Приостановка планировщика
vTaskSuspendAll();
recalculate_table(); /* десятки микросекунд */
xTaskResumeAll();
Мягче секции: запрещено только переключение задач; прерывания идут как обычно. Годится для длинных операций над данными, которые трогают только задачи, а прерывания — нет.
Из прерывания, случившегося внутри паузы, вызывать FromISR можно —
ядро запомнит, что кого-то разбудили, и переключится на выходе
из xTaskResumeAll.
⚠️ Внутри паузы нельзя вызывать функции, которые могут заблокировать. Ждать при остановленном планировщике некому:
vTaskDelay,xQueueReceiveс ненулевым временем — ошибка, и ядро её ловит проверкой.
30.4 Мьютекс
Когда ресурс — не переменная, а что-то длинное: шина I²C, дисплей, файл на карте. Секция тут негодна — держать прерывания на время обмена по шине нельзя. Нужен замок, который берёт одна задача, а остальные ждут как задачи, не потребляя процессора.
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 мс не дождались — шина висит, и это надо знать */
}
}
Три правила мьютекса:
Взял — отдай. Каждому Take — свой Give, по всем путям выхода
из функции, включая ошибочные.
Отдаёт тот, кто брал. Ядро на это рассчитывает, но у обычного мьютекса
не проверяет: чужой Give при включённом configASSERT остановит программу,
а без него пройдёт и испортит учёт наследования приоритета. Рекурсивный
мьютекс владельца сверяет и чужому вернёт отказ.
Из прерывания — нельзя. Ни взять, ни отдать. Прерывание не задача, владельцем быть не может, и наследовать приоритет ему нечего. Для прерываний — двоичный семафор или очередь.
⚠️ Результат
Takeс ограниченным ожиданием проверяется всегда. Отказ означает, что ресурс занят дольше разумного. Войти в обмен по шине, не получив мьютекс, — значит наложиться на чужой обмен, и это хуже, чем пропустить свой.
Мьютекс и двоичный семафор: в чём разница
Структура одна, стоимость одна — 80 байт. Поведение разное:
| Двоичный семафор | Мьютекс | |
|---|---|---|
| знает владельца | нет | да |
| наследование приоритета | нет | да |
| отдать может | кто угодно, в том числе прерывание | только владелец |
| назначение | сигнал о событии | защита ресурса |
Семафор — для связки «прерывание → задача» (раздел 28). Мьютекс — для «ресурс один, желающих много». Смешивать роли можно, но тогда теряется единственное, ради чего мьютекс существует.
30.5 ⚠️ Инверсия приоритетов
Ситуация, ради которой стоит понимать мьютексы глубже, чем «взял — отдал».
Три задачи: L — низкий приоритет, M — средний, H — высокий. L взяла мьютекс и работает с шиной. Просыпается H, тоже хочет шину и блокируется на мьютексе — ждёт, пока L отдаст. Пока всё честно.
Теперь просыпается M. Ей шина не нужна, она просто считает. M приоритетнее L, значит вытесняет её. L не работает — мьютекс не отдаёт — H ждёт. Высокоприоритетная задача ждёт среднеприоритетную, которая к ресурсу вообще не имеет отношения. Сколько ждёт? Пока M не наработается. То есть неограниченно.
| Задача | Приоритет | Что делает |
|---|---|---|
| H | 3 | ждёт мьютекс, ничего не может |
| M | 2 | считает своё, ни при чём |
| L | 1 | держит мьютекс, вытеснена, не работает |
Это не теоретическая беда. Ровно так в 1997 году зависал посадочный аппарат Mars Pathfinder: низкоприоритетная задача сбора метеоданных держала мьютекс общей шины, его ждала высокоприоритетная задача управления шиной, а между ними подолгу работала среднеприоритетная задача связи, — и сторожевой таймер перезагружал аппарат. Лечение нашли дистанционно: включили наследование приоритета у этого мьютекса.
Наследование приоритета — то, что делает мьютекс:
Пока задача держит мьютекс, которого ждёт более приоритетная, она получает приоритет ждущей.
L на время владения становится третьей. M со своей двойкой её больше не вытесняет. L дорабатывает, отдаёт мьютекс, возвращается к единице, H получает ресурс. Ожидание H ограничено временем работы L с ресурсом — и только им.
На трассе — те же три задачи с двоичным семафором и с мьютексом:
Наследование даёт только мьютекс. Двоичный семафор владельца не знает и поднимать некого — потому его и нельзя использовать для защиты ресурса.
⚠️ Наследование — не лекарство, а обезболивающее. Оно ограничивает инверсию, но не убирает её: H всё равно ждёт, пока L закончит с шиной. Настоящее лечение — держать мьютекс как можно короче: взял, сделал минимум, отдал. Не вычислять под мьютексом, не ждать под мьютексом, не звать под мьютексом функции, которые сами ждут.
30.6 ⚠️ Взаимная блокировка
Два ресурса, две задачи, два мьютекса.
Задача A: взять шину, потом журнал. Задача B: взять журнал, потом шину.
A взяла шину. B взяла журнал. A ждёт журнал — у B. B ждёт шину — у A. Навсегда. Обе в состоянии ожидания, обе процессора не потребляют, прибор снаружи выглядит живым и ничего не делает.
Лечение одно, и оно организационное: единый порядок захвата во всей программе. Если мьютексов несколько, они нумеруются, и любая задача берёт их строго по возрастанию номера. Отдаёт — в обратном порядке.
/* Во всех задачах: сначала bus, потом log. Без исключений. */
xSemaphoreTake(m_bus, portMAX_DELAY);
xSemaphoreTake(m_log, portMAX_DELAY);
/* ... */
xSemaphoreGive(m_log);
xSemaphoreGive(m_bus);
Второе средство — ограниченное ожидание вместо portMAX_DELAY.
Тупик не исчезнет, но превратится в отказ по времени, который можно
заметить и обработать.
30.7 Рекурсивный мьютекс
Обычный мьютекс нельзя взять дважды из одной задачи: второй Take
заблокируется навсегда — задача ждёт саму себя. Случается это
незаметно: функция под мьютексом вызывает другую функцию, которая
тоже берёт этот мьютекс.
void outer(void)
{
xSemaphoreTakeRecursive(m, portMAX_DELAY);
inner(); /* внутри — тот же мьютекс */
xSemaphoreGiveRecursive(m); /* освобождён только теперь */
}
Рекурсивный мьютекс считает вложенность: сколько раз взяли, столько
раз надо отдать, и только тогда он свободен. У него свои функции —
TakeRecursive и GiveRecursive; обычные с ним не работают.
Брать его по умолчанию не стоит: он скрывает структуру программы. Если функции берут один мьютекс друг из друга — это повод разобраться, кто за ресурс отвечает, а не повод включать рекурсию.
30.8 🔧 Задача-привратник
Альтернатива мьютексу, которая убирает целый класс проблем.
Идея: ресурсом владеет одна задача, единолично и всегда. Остальные не трогают его вовсе — они кладут запросы в очередь к этой задаче.
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);
Что получается:
| Мьютекс | Привратник | |
|---|---|---|
| взаимная блокировка | возможна | невозможна — мьютекса нет |
| инверсия приоритетов | ограничена наследованием | нет: ресурсом никто не владеет |
| из прерывания | нельзя | можно — это просто очередь |
| задержка запроса | ждём, пока освободят | кладём и идём дальше |
| цена | 80 байт | задача плюс очередь |
Отправитель не ждёт ресурса — он ждёт места в очереди, и обычно не ждёт вовсе. Порядок запросов сохраняется. Приоритет привратника задаёт, насколько срочно обслуживаются запросы, и это одно решение вместо анализа всех путей захвата.
Цена — задача со стеком и очередь: для журнала на восемь сообщений по 32 байта это 336 байт очереди плюс задача. Для UART, дисплея, журнала на карту — типовое и обычно лучшее решение. Для мьютекса остаются ресурсы, где нужен ответ, а не только запрос.
30.9 Что выбрать
| Ситуация | Средство |
|---|---|
| инкремент, пара полей, флаг | критическая секция |
| один счётчик или флаги в слове, Cortex-M3/M4 | атомарная операция (30.2) |
| длинный пересчёт данных, прерывания не участвуют | приостановка планировщика |
| ресурс с длинным доступом, нужен ответ | мьютекс, держать коротко |
| ресурс «пиши и забудь»: журнал, дисплей, UART | привратник |
| сигнал из прерывания задаче | двоичный семафор или уведомление |
| одна переменная, одно слово, один писатель | ничего: запись слова атомарна |
Последняя строка важна: запись выровненного 32-разрядного слова на
Cortex‑M атомарна. Если переменную пишет одна задача, а читают другие,
и она умещается в слово — защита не нужна. Слово в упакованной структуре
так не пишется, а читатель, ждущий изменения в цикле, объявляет переменную
volatile — иначе компилятор прочитает её один раз. Гонка начинается там, где чтение и запись
разнесены, или где данных больше слова.
30.10 Грабли
Счётчик считает меньше, чем должен. Гонка на инкременте. Секция или атомарная операция (30.2).
Прибор живой, но ничего не делает. Взаимная блокировка на двух мьютексах. Проверить порядок захвата во всех задачах.
Задача зависла на xSemaphoreTake своего же мьютекса. Взяла
дважды. Разобраться в вызовах или рекурсивный мьютекс.
Высокоприоритетная задача тормозит без причины. Инверсия: ждёт мьютекс, который держит вытесненная низкоприоритетная. Укоротить владение; убедиться, что это мьютекс, а не семафор.
configASSERT в vPortEnterCritical. Обычная секция вызвана
из прерывания. Нужна пара FROM_ISR.
xSemaphoreGive возвращает отказ. Мьютекс не был взят. Чужой Give
обычного мьютекса отказа не даёт — его ловит только configASSERT;
отказ от чужой задачи возвращает лишь xSemaphoreGiveRecursive.
Приём по UART теряет байты при работе с дисплеем. Слишком длинная критическая секция. Заменить на мьютекс или привратника.
Мьютекс из прерывания. Нельзя ни в каком виде. Семафор, очередь, уведомление.
30.11 Итоги
counter++— три инструкции; переключение между ними теряет приращение.volatileот гонки не защищает.- Критическая секция поднимает
BASEPRIдо порога, вкладывается и обязана быть короткой: её длина — задержка всех прерываний ниже. - Приостановка планировщика запрещает только переключение задач; прерывания идут, а блокирующие вызовы внутри недопустимы.
- Мьютекс знает владельца и наследует приоритет; из прерывания он недоступен ни в каком виде.
- Инверсия приоритетов: высокая ждёт низкую, которую вытеснила средняя. Наследование ограничивает ожидание, но не убирает его — мьютекс держат коротко.
- Взаимная блокировка лечится единым порядком захвата во всей программе.
- Рекурсивный мьютекс считает вложенность; брать по умолчанию не стоит.
- Привратник убирает мьютекс вовсе: ресурсом владеет одна задача, остальные шлют запросы в очередь — в том числе из прерываний.
- Запись одного слова на Cortex‑M атомарна: один писатель и любое число читателей защиты не требуют.
Дальше: раздел 31 «События и уведомления» — как передать факт без данных, дождаться нескольких условий сразу и обойтись без семафора вовсе.