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

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

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

Вступление

Два процесса или два потока видят одну и ту же переменную в памяти. Каждый делает счётчик++. Логически должно получиться +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 структуры
Примитивы ОСmutex, semaphore, futexОбщий случай в приложениях
Высокоуровневые APIsynchronized, std::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 — см. 5115.


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

Мьютекс (mutual exclusion)

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

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

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

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

Семафор

Счётчик ресурсов: 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 в 5115.


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

ПримитивТипичное использованиеРиск 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
Когда очередь сообщений?Изоляция, границы сервисов

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


См. также

Другие статьи этого же раздела в боковом меню (как на странице «О разделе»).