Состояние гонки и блокировки

Материал из llmdocs — справочник разработчика
Портал: Основы CS
Состояние гонки и блокировки
Гонка (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) в подозрительных местах.
  • Проектирование без общего изменяемого состояния: неизменяемые данные, владение одним потоком, очереди задач.

См. также

Источники

  1. Herlihy, Shavit — The Art of Multiprocessor Programming (2nd ed.)
  2. Go — Data Race Detector
  3. The Go Memory Model
  4. The Rustonomicon — Data Races and Race Conditions
  5. Coffman, Elphick, Shoshani — System Deadlocks (ACM Computing Surveys, 1971)
  6. Clang — ThreadSanitizer
  7. Python — threading: Lock objects