Гонки, критические секции и разделяемая память
Вступление
Два процесса или два потока видят одну и ту же переменную в памяти. Каждый делает счётчик++. Логически должно получиться +2. На практике часто +1 — и это гонка данных (race condition).
Операционная система даёт разделяемую память (shared memory) и быстрый IPC. Ответственность за корректность — на программисте: нужны критические секции и примитивы синхронизации, которые ядро реализует атомарно.
Эта статья — про концепции уровня ОС. Примеры на Java: 5-03-java/23; асинхронность в приложениях: 4-05-asinhronnost.
Связано: управление процессами, тупики, ядро и IPC.
Почему "просто увеличить счётчик" не работает
На уровне процессора counter++ — это несколько шагов:
- загрузить значение из RAM в регистр;
- увеличить регистр;
- записать обратно в RAM.
Два потока могут перемешаться:
| Время | Поток A | Поток B | counter в памяти |
|---|---|---|---|
| 1 | read → 10 | 10 | |
| 2 | read → 10 | 10 | |
| 3 | write 11 | 11 | |
| 4 | write 11 | 11 (ожидали 12) |
Потерянное обновление (lost update) — классический симптом гонки.
Потоки одного процесса разделяют адресное пространство — гонки между ними особенно часты. Разные процессы тоже могут гоняться, если используют разделяемую память или файл, отображённый в память.
Критическая секция
Критическая секция — фрагмент кода, где программа обращается к общему ресурсу (переменная, структура, устройство). Требования к решению (Dijkstra):
- Взаимное исключение — в критической секции одновременно не больше одного исполнителя.
- Прогресс — если никто не в секции, выбор следующего не должен зависнуть навсегда.
- Ограниченное ожидание — нет бесконечного starvation из-за блокировки.
На практике это реализуют блокировками, которые поддерживает ОС.
Уровни решения
| Уровень | Пример | Когда уместно |
|---|---|---|
| Отключение прерываний | Только в ядре, на одном CPU | Микросекунды, нельзя в user space |
| Атомарные инструкции | LOCK CMPXCHG, C11 atomic | Счётчики, lock-free структуры |
| Interlocked* (Windows) | InterlockedExchange, InterlockedIncrement | Атомарные операции без mutex в user mode |
| Примитивы ОС | mutex, semaphore, futex | Общий случай в приложениях |
| Высокоуровневые API | synchronized, 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 functions | WaitForSingleObject, 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 | Блокировки в другом месте |
Типичные ошибки
- Забыли синхронизировать shared memory — недетерминизм в тестах.
- Держать lock слишком долго — падает параллелизм, растёт latency.
- Два mutex в разном порядке в разных потоках — deadlock.
- Spinlock в user space на одном ядре — busy-wait сжигает CPU (в ядре на коротких критических секциях — норма).
- Сигналы и mutex — не все функции async-signal-safe; в обработчике сигнала нельзя
pthread_mutex_lockбез осторожности.
Как ОС помогает
- Планировщик снимает поток с CPU при блокировке на mutex — другие работают.
- Приоритетное наследование — снижает инверсию приоритетов (частично).
- Атомарные операции в CPU и барьеры памяти — основа lock-free.
- Инструменты:
strace, Thread Sanitizer (TSan),helgrind— ловят гонки на тестах.
Практический вывод
| Вопрос | Ответ |
|---|---|
| Нужна ли синхронизация? | Да, если несколько исполнителей пишут в общее состояние |
| Что выбрать первым? | Mutex + чёткая критическая секция |
| Когда shared memory? | Большие объёмы, низкая latency |
| Когда очередь сообщений? | Изоляция, границы сервисов |
Дальше: тупики и защита, управление процессами.
Мини-кейс из реальной разработки
Симптом: счетчик событий "иногда теряет" инкременты под нагрузкой.
Причина: несколько потоков одновременно пишут в общее состояние без атомарности/блокировки.
Лечение — минимальная критическая секция, корректный примитив синхронизации, нагрузочный тест с конкурентным доступом.
Главный урок: гонки редко заметны на малом трафике, но почти всегда проявляются в пиковые часы.