Состояние гонки и блокировки
| Гонка (race condition) | результат зависит от порядка или времени выполнения |
|---|---|
| Гонка данных (data race) | одновременный доступ к памяти, хотя бы один — запись, без синхронизации |
| Средства синхронизации | мьютекс, RW-блокировка, семафор, атомарные операции, каналы |
| Типовые сбои | взаимоблокировка, livelock, голодание, инверсия приоритетов |
| Инструменты | ThreadSanitizer, go test -race, Miri, Helgrind |
Состояние гонки (англ. race condition) — ошибка в программе, при которой результат зависит от непредсказуемого порядка или времени выполнения параллельных действий: потоков, процессов, запросов, корутин. Такие ошибки возникают в конкурентных программах, плохо воспроизводятся («у меня работает») и могут проявиться раз в миллион запусков. Основное средство борьбы — синхронизация: блокировки, атомарные операции, передача сообщений.[1]
Классический пример
Два потока увеличивают общий счётчик:
counter = 0
def work():
global counter
for _ in range(1_000_000):
counter += 1 # прочитать → прибавить → записать
Операция counter += 1 неатомарна: это чтение, сложение и запись. Если оба потока прочитали 41, оба запишут 42 — одно увеличение потеряно, и итог меньше 2 000 000. Даже GIL в CPython не спасает: поток может быть прерван между байткод-инструкциями, а в сборках без GIL гонки проявляются ещё чаще.
Гонка и гонка данных
Различают два понятия:[4]
- Гонка данных (data race) — два потока одновременно обращаются к одной ячейке памяти, хотя бы один пишет, и между обращениями нет синхронизации. В C, C++ и Go это неопределённое или некорректное поведение по модели памяти языка.[3] Безопасный Rust исключает гонки данных на этапе компиляции через систему владения и заимствования.
- Логическая гонка (race condition) — более общее понятие: ошибка в порядке действий даже без «сырой» гонки за память. Пример — TOCTOU (time of check to time of use): программа проверила, что файла нет, а затем создала его, но между проверкой и созданием файл успел создать кто-то другой. Ту же природу имеют двойное списание денег при параллельных запросах и «потерянное обновление» в базе данных.
Средства синхронизации
Мьютекс (mutual exclusion) — блокировка, которую в каждый момент держит не больше одного потока. Код между захватом и освобождением называется критической секцией.
import threading
lock = threading.Lock()
def work():
global counter
for _ in range(1_000_000):
with lock: # захват и гарантированное освобождение
counter += 1
var mu sync.Mutex
mu.Lock()
counter++
mu.Unlock()
Другие примитивы:
- RW-блокировка (
RWMutex,RwLock) — много читателей или один писатель. - Семафор — ограничивает число одновременных владельцев, например не больше 10 соединений к API.
- Условные переменные и события — ожидание, пока другой поток не выполнит условие.
- Атомарные операции (
sync/atomic,std::sync::atomic, compare-and-swap) — неделимые операции процессора над одним значением, без блокировки; на них строятся lock-free структуры. - Передача сообщений — вместо общей памяти потоки обмениваются данными через очереди или каналы: «не общайтесь через разделяемую память, разделяйте память через общение» (поговорка Go).
Взаимоблокировки и другие сбои
Блокировки решают гонки, но создают свои проблемы:
- Взаимоблокировка (deadlock) — поток A держит блокировку X и ждёт Y, поток B держит Y и ждёт X; оба стоят вечно. Коффман и соавторы (1971) назвали четыре необходимых условия: взаимное исключение, удержание с ожиданием, отсутствие вытеснения и циклическое ожидание.[5] Самая практичная профилактика — разрушить последнее условие, всегда захватывая блокировки в одном и том же порядке; помогают также тайм-ауты (
lock.acquire(timeout=1)) иtry_lock. - Livelock — потоки активно уступают друг другу и не продвигаются.
- Голодание — поток никогда не получает ресурс, потому что его опережают другие.
- Инверсия приоритетов — высокоприоритетная задача ждёт низкоприоритетную, держащую блокировку (знаменитый сбой марсохода Mars Pathfinder в 1997 году).
- Конкуренция за блокировку (contention) — если критическая секция велика, потоки выстраиваются в очередь и параллелизм пропадает. Секции должны быть короткими: не делать под блокировкой ввод-вывод и сетевые запросы.
Гонки за пределами потоков
- Асинхронный код: в
asyncioнет вытеснения посреди строки, но между двумяawaitдругая корутина может изменить общие данные — для этого естьasyncio.Lock. - Базы данных: параллельные транзакции порождают аномалии, от которых защищают уровни изоляции,
SELECT … FOR UPDATEи оптимистические блокировки с номером версии (см. Транзакция и ACID). - Распределённые системы: два экземпляра сервиса обрабатывают одну задачу; нужны идемпотентность, распределённые блокировки с аренду (lease) или очереди с гарантией доставки.
- Файловая система: атомарное создание (
O_CREAT | O_EXCL), запись во временный файл иrename.
Как находить
- Детекторы гонок данных: ThreadSanitizer в Clang и GCC,
go test -raceиgo run -race,[2][6] Miri для unsafe-кода в Rust, Helgrind в Valgrind. Они находят гонки, которые реально произошли во время прогона, поэтому нужны тесты с параллельной нагрузкой. - Стресс-тесты, многократный повтор теста, искусственные задержки (
sleep) в подозрительных местах. - Проектирование без общего изменяемого состояния: неизменяемые данные, владение одним потоком, очереди задач.