Перейти к основному содержимому

Гонки, критические секции и разделяемая память

Разработчику Архитектору Инженеру

Вступление

Два процесса или два потока видят одну и ту же переменную в памяти. Каждый делает счётчик++. Логически должно получиться +2. На практике часто +1 — и это гонка данных (race condition).

Операционная система даёт разделяемую память (shared memory) и быстрый IPC. Ответственность за корректность — на программисте: нужны критические секции и примитивы синхронизации, которые ядро реализует атомарно.

Эта статья — про концепции уровня ОС. Примеры на Java: 5-03-java/23; асинхронность в приложениях: 4-05-asinhronnost.

Связано: управление процессами, тупики, ядро и IPC.


Почему "просто увеличить счётчик" не работает

На уровне процессора counter++ — это несколько шагов:

  1. загрузить значение из RAM в регистр;
  2. увеличить регистр;
  3. записать обратно в RAM.

Два потока могут перемешаться:

ВремяПоток AПоток Bcounter в памяти
1read → 1010
2read → 1010
3write 1111
4write 1111 (ожидали 12)

Потерянное обновление (lost update) — классический симптом гонки.

Поток ≠ процесс

Потоки одного процесса разделяют адресное пространство — гонки между ними особенно часты. Разные процессы тоже могут гоняться, если используют разделяемую память или файл, отображённый в память.


Критическая секция

Критическая секция — фрагмент кода, где программа обращается к общему ресурсу (переменная, структура, устройство). Требования к решению (Dijkstra):

  1. Взаимное исключение — в критической секции одновременно не больше одного исполнителя.
  2. Прогресс — если никто не в секции, выбор следующего не должен зависнуть навсегда.
  3. Ограниченное ожидание — нет бесконечного starvation из-за блокировки.

На практике это реализуют блокировками, которые поддерживает ОС.


Уровни решения

УровеньПримерКогда уместно
Отключение прерыванийТолько в ядре, на одном CPUМикросекунды, нельзя в user space
Атомарные инструкцииLOCK CMPXCHG, C11 atomicСчётчики, lock-free структуры
Interlocked* (Windows)InterlockedExchange, InterlockedIncrementАтомарные операции без mutex в user mode
Примитивы ОСmutex, semaphore, futexОбщий случай в приложениях
Высокоуровневые APIsynchronized, std::mutexПоверх примитивов ОС

Пользовательская программа не должна отключать прерывания — это привилегия ядра.


Аппаратная и программная синхронизация

Spin-lock (активное ожидание)

Поток в цикле опрашивает флаг, не снимаясь с CPU. Имеет смысл в ядре на короткой критической секции и при малой конкуренции. В user space на одном ядре — риск сжечь CPU.

Алгоритм Петерсона (два процесса)

Классическое программное решение задачи критической секции без mutex в ОС. Два флага interested[] и переменная turn — каждый процесс объявляет намерение войти и уступает очередь сопернику:

int turn = 0;
int interested[2] = { 0, 0 };

void enter(int me) {
int other = 1 - me;
interested[me] = 1;
turn = me;
while (turn == me && interested[other])
; /* ждём */
}
void leave(int me) { interested[me] = 0; }

На практике в приложениях используют примитивы ОС; Peterson нужен для понимания условий взаимного исключения и экзамена.

Interlocked в Windows

Группа функций с префиксом Interlocked выполняет read-modify-write атомарно относительно других потоков:

LONG flag = 0;
while (InterlockedExchange(&flag, 1) == 1)
Sleep(0); /* критическая секция */
InterlockedExchange(&flag, 0);

Для сложной логики всё равно нужны mutex/семафоры.


Объекты синхронизации Windows (Win32)

В Windows синхронизация строится на объектах ядра с HANDLE (см. ядро, Windows):

ОбъектРоль
MutexВзаимное исключение; владелец может рекурсивно захватить; при завершении потока мьютекс освобождается
SemaphoreСчётчик ресурсов; CreateSemaphore, ReleaseSemaphore
EventСигнал "событие произошло"; auto-reset / manual-reset
Wait functionsWaitForSingleObject, WaitForMultipleObjects — блокируют поток до сигнала или таймаута

CreateMutex(NULL, FALSE, L"Global\\MyMutex") — именованный мьютекс виден другим процессам.

Producer–consumer с ограниченным буфером на семафорах — empty (свободные слоты), full (заполненные), mutex (доступ к буферу). Семафоры задают порядок (кооперативная часть), mutex — исключение при доступе к данным (конкурентная часть).


Разделяемая память в POSIX

Самый быстрый IPC — **отобразить одну область в два адресных пространства:

// Упрощённая схема: родитель создаёт сегмент, fork, оба видят int *counter
int *counter = mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_ANONYMOUS, -1, 0);
*counter = 0;
if (fork() == 0) {
for (int i = 0; i < 100000; i++)
(*counter)++; // гонка без синхронизации!
}

MAP_SHARED — изменения видны другому процессу. Без mutex или атомиков результат недетерминирован.

Тот же счётчик с pthread_mutex (детерминированно +2 за два потока по 100000 итераций):

#include <pthread.h>

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
int *counter; /* отображён через mmap MAP_SHARED, как выше */

void *worker(void *arg) {
(void)arg;
for (int i = 0; i < 100000; i++) {
pthread_mutex_lock(&lock);
(*counter)++;
pthread_mutex_unlock(&lock);
}
return NULL;
}

Альтернативы: shm_open + mmap, System V shmget/shmat — см. Управление процессами в Linux.


Процедурные примитивы синхронизации

Мьютекс (mutual exclusion)

Двоичная блокировка: свободен / занят.

  • lock() — если занят, поток блокируется (ядро снимает с CPU);
  • unlock() — будит одного ожидающего.

В Linux pthread: pthread_mutex_t; в ядре — futex (fast userspace mutex): попытка в user space, syscall только при contention.

Не путать с взаимной блокировкой (deadlock) — это ситуация, когда процессы ждут друг друга по кругу. Мьютекс — инструмент; deadlock — ошибка использования. См. Тупики (deadlock) и защита от них.


Семафор

Счётчик ресурсов: wait (P) уменьшает, при 0 блокирует; signal (V) увеличивает, будит ожидающих.

  • Бинарный семафор ≈ мьютекс (с нюансами владения).
  • Счётный — пул из N одинаковых слотов (N соединений к БД, N буферов).

Барьер

Все N участников должны дойти до точки — только потом любой продолжает. Удобно для фаз параллельных алгоритмов (pthread_barrier).


Условная переменная (condition variable)

Ждать условия под мьютексом: "очередь не пуста", "есть свободный слот". wait атомарно отпускает mutex и засыпает; signal/broadcast будит.

Паттерн producer-consumer:

producer: lock(mutex); положить в буфер; signal(cond); unlock(mutex);
consumer: lock(mutex); while (пусто) wait(cond, mutex); взять; unlock(mutex);

Монитор (учебное понятие)

Монитор — языковая конструкция: модуль + mutex + условные переменные внутри; только один поток внутри монитора. В Java — synchronized на метод/блок. В чистом C — комбинация mutex + cond вручную.


Очереди сообщений

Данные копируются в ядро — медленнее shared memory, но не нужна общая память и проще избежать гонок на структурах (если одно сообщение — атомарная единица). См. msgsnd/msgrcv в Управление процессами в Linux.


Сравнение примитивов

ПримитивТипичное использованиеРиск deadlock
MutexЗащита структуры данныхПри нескольких mutex без порядка
SemaphoreПул ресурсов, сигнализацияТот же
Cond varОжидание условияВместе с mutex — да, если нарушен порядок
BarrierСинхронизация фазРедко
Message queueОбмен без shared stateБлокировки в другом месте

Типичные ошибки

  1. Забыли синхронизировать shared memory — недетерминизм в тестах.
  2. Держать lock слишком долго — падает параллелизм, растёт latency.
  3. Два mutex в разном порядке в разных потоках — deadlock.
  4. Spinlock в user space на одном ядре — busy-wait сжигает CPU (в ядре на коротких критических секциях — норма).
  5. Сигналы и mutex — не все функции async-signal-safe; в обработчике сигнала нельзя pthread_mutex_lock без осторожности.

Как ОС помогает

  • Планировщик снимает поток с CPU при блокировке на mutex — другие работают.
  • Приоритетное наследование — снижает инверсию приоритетов (частично).
  • Атомарные операции в CPU и барьеры памяти — основа lock-free.
  • Инструменты: strace, Thread Sanitizer (TSan), helgrind — ловят гонки на тестах.

Практический вывод

ВопросОтвет
Нужна ли синхронизация?Да, если несколько исполнителей пишут в общее состояние
Что выбрать первым?Mutex + чёткая критическая секция
Когда shared memory?Большие объёмы, низкая latency
Когда очередь сообщений?Изоляция, границы сервисов

Дальше: тупики и защита, управление процессами.


Мини-кейс из реальной разработки

Симптом: счетчик событий "иногда теряет" инкременты под нагрузкой.
Причина: несколько потоков одновременно пишут в общее состояние без атомарности/блокировки.
Лечение — минимальная критическая секция, корректный примитив синхронизации, нагрузочный тест с конкурентным доступом.

Главный урок: гонки редко заметны на малом трафике, но почти всегда проявляются в пиковые часы.