Race condition в коде: что это такое, как обнаружить и как защититься

Race condition в коде: что это такое, как обнаружить и как защититься

Два потока одновременно читают одно значение из базы данных. Оба видят остаток на счете: 1000 рублей. Оба списывают по 800. Оба успешно записывают результат. Итог: вместо ожидаемого отказа по недостатку средств баланс уходит в минус. Деньги списаны дважды, хотя каждый поток сделал всё «правильно».

Это классический пример race condition - состояния гонки. Баг не воспроизводится на локальной машине, не падает в тестах и появляется только под нагрузкой в production. Именно поэтому такие ошибки особенно опасны: их сложно поймать, легко пропустить и дорого исправлять постфактум.

В этой статье разберем, как работает race condition, где он чаще всего прячется, как его диагностировать и какими способами защищаться - от мьютексов до архитектурных решений.

Коротко:

  • Race condition возникает, когда результат программы зависит от порядка выполнения нескольких потоков или процессов, а этот порядок непредсказуем.
  • Чаще всего встречается при работе с общей памятью, базами данных, файлами и кешем.
  • Не воспроизводится стабильно - может проявляться раз в тысячу запросов или только при определенной нагрузке.
  • Основные инструменты защиты: блокировки, атомарные операции, изоляция транзакций, очереди и архитектурные паттерны без общего состояния.
  • Диагностировать помогают race detector в Go, ThreadSanitizer для C/C++ и Python, стресс-тесты и анализ логов с временными метками.

Что такое race condition

Race condition - это ситуация, когда корректность программы зависит от относительного порядка выполнения операций в нескольких потоках или процессах. Если порядок меняется, результат становится непредсказуемым.

Ключевое слово здесь - «зависит». Само по себе параллельное выполнение не является проблемой. Проблема возникает, когда два потока обращаются к одному ресурсу без координации, и хотя бы один из них этот ресурс изменяет.

Формально выделяют три условия, при которых возникает состояние гонки:

  • Есть общий ресурс (переменная, файл, запись в БД, ячейка кеша).
  • Как минимум один поток его изменяет.
  • Нет механизма, который гарантирует правильный порядок доступа.

Если убрать любое из этих условий, race condition исчезает. Именно на этом строятся все способы защиты.

Почему это сложно поймать

Состояние гонки - один из самых коварных классов ошибок по нескольким причинам.

Во-первых, оно недетерминировано. Планировщик операционной системы решает, какой поток получит процессор в следующий момент. Это решение зависит от нагрузки, приоритетов, таймингов - и в разных запусках может быть разным. Баг воспроизводится в 1 из 10 000 запросов или только на конкретном железе.

Во-вторых, добавление отладочного кода меняет тайминги. Вы вставляете print или точку останова - и гонка перестает проявляться, потому что добавленная задержка изменила порядок выполнения. Это называют эффектом Гейзенберга в программировании.

В-третьих, тесты обычно запускаются в один поток или с небольшой нагрузкой. Гонка, которая возникает при 500 одновременных запросах, в unit-тестах просто не проявится.

Типичные места, где прячется состояние гонки

Счетчики и агрегаты без синхронизации

Операция counter++ выглядит атомарной, но на уровне процессора это три шага: прочитать значение, прибавить единицу, записать обратно. Если два потока выполняют эти шаги одновременно, один из инкрементов теряется.

Пример на Python:

import threading

counter = 0

def increment():
    global counter
    for _ in range(100000):
        counter += 1

t1 = threading.Thread(target=increment)
t2 = threading.Thread(target=increment)
t1.start(); t2.start()
t1.join(); t2.join()
print(counter)  # Ожидаем 200000, получаем меньше

Из-за GIL в CPython этот конкретный пример иногда работает корректно, но полагаться на это нельзя - поведение зависит от версии интерпретатора и операции.

Паттерн «проверить, потом действовать»

Классическая ловушка: проверить условие, а потом выполнить действие, предполагая, что условие не изменилось. Между проверкой и действием другой поток успевает изменить состояние.

Типичный пример - создание файла:

if not os.path.exists(filename):  # Поток A проверяет - файла нет
    # Поток B тоже проверяет - файла нет, создает его
    open(filename, 'w')  # Поток A создает файл повторно

Та же проблема возникает при проверке баланса перед списанием, при проверке уникальности записи перед вставкой, при проверке наличия товара перед резервированием.

Ленивая инициализация

if instance is None:
    instance = ExpensiveObject()  # Два потока могут создать два объекта

Без блокировки два потока одновременно видят None и оба создают объект. Один из них будет потерян, а если объект имеет побочные эффекты при создании - возникнут более серьезные проблемы.

Составные операции с базой данных

Даже если каждый отдельный запрос атомарен, последовательность запросов - нет. Классический пример: SELECT для чтения значения, затем UPDATE для его изменения. Между этими двумя запросами другая транзакция может изменить ту же строку.

Кеш как источник гонок

Паттерн «прочитать из кеша, если нет - загрузить из БД и сохранить» порождает состояние гонки при параллельных запросах на один и тот же ключ. Несколько потоков одновременно видят промах кеша и все идут в базу. Это называют «thundering herd» или «cache stampede».

Инструменты для обнаружения

ИнструментЯзык / платформаЧто находит
Go race detector (-race)GoОдновременный доступ к переменным без синхронизации
ThreadSanitizer (TSan)C, C++, Python (через расширения)Гонки в многопоточном коде
HelgrindC, C++ (через Valgrind)Нарушения порядка блокировок, гонки
Java PathFinderJavaМодельная проверка многопоточных программ
Stress-тесты с параллельными запросамиЛюбойПроявляет гонки через высокую нагрузку

В Go встроенный детектор гонок запускается одной флагом:

go test -race ./...
go run -race main.go

Он инструментирует код при компиляции и отслеживает все обращения к памяти. Когда два горутина обращаются к одной переменной без синхронизации, детектор выводит стек вызовов обоих - это сильно ускоряет диагностику.

Для стресс-тестирования полезны инструменты вроде wrk, k6 или locust. Запустите сценарий с 100-500 параллельными пользователями, выполняющими одно и то же действие, и проверяйте инварианты: итоговые суммы, количество записей, уникальность идентификаторов.

Способы защиты

Мьютексы и блокировки

Самый прямолинейный способ: захватить блокировку перед входом в критическую секцию и освободить после выхода. Пока один поток держит мьютекс, остальные ждут.

import threading

lock = threading.Lock()
counter = 0

def increment():
    global counter
    with lock:
        counter += 1

Важно держать блокировку минимально необходимое время. Если внутри критической секции выполняется долгая операция (например, сетевой запрос), все остальные потоки будут заблокированы, и параллелизм превратится в последовательное выполнение.

Для сценариев «много читателей, редкие записи» используют RWMutex (read-write mutex): несколько потоков могут читать одновременно, но запись требует эксклюзивного доступа.

Атомарные операции

Для простых операций над числами (инкремент, декремент, сравнение и замена) атомарные примитивы быстрее мьютексов, потому что работают на уровне процессорных инструкций без переключения контекста.

// Go
import "sync/atomic"

var counter int64
atomic.AddInt64(&counter, 1)  // Атомарный инкремент

Атомарные операции хороши для счетчиков и флагов, но не подходят для сложных составных операций.

Изоляция транзакций в базе данных

Для гонок на уровне БД правильный инструмент - транзакции с нужным уровнем изоляции и пессимистичные или оптимистичные блокировки.

Пессимистичная блокировка - явно заблокировать строку при чтении:

-- PostgreSQL
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;  -- Блокируем строку
-- Другие транзакции будут ждать здесь
UPDATE accounts SET balance = balance - 800 WHERE id = 1;
COMMIT;

Оптимистичная блокировка - не блокировать при чтении, но проверять при записи, не изменилась ли строка:

-- Читаем версию
SELECT balance, version FROM accounts WHERE id = 1;
-- version = 5, balance = 1000

-- Обновляем только если версия не изменилась
UPDATE accounts 
SET balance = 200, version = 6 
WHERE id = 1 AND version = 5;
-- Если affected rows = 0, кто-то успел изменить запись раньше нас

Оптимистичная блокировка лучше работает при редких конфликтах, пессимистичная - когда конфликты часты и нужно избежать повторных попыток.

Неизменяемые структуры данных

Если объект нельзя изменить после создания, гонки при чтении невозможны по определению. Вместо изменения создается новый объект с обновленными данными. Этот подход активно используется в функциональных языках и в React (иммутабельность состояния).

В Java и Kotlin это val вместо var, неизменяемые коллекции, классы record. В Python - tuple вместо list, frozenset, датаклассы с frozen=True.

Очереди сообщений и модель акторов

Архитектурный подход: вместо прямого доступа к общему состоянию потоки обмениваются сообщениями через очередь. Каждый актор обрабатывает сообщения последовательно, поэтому гонки внутри актора невозможны.

В Go это каналы (chan). В Python - queue.Queue или asyncio.Queue. На уровне сервисов - Kafka, RabbitMQ, Redis Streams.

// Go: безопасная передача данных через канал
ch := make(chan int, 1)

go func() {
    ch <- computeResult()  // Отправляем результат
}()

result := <-ch  // Получаем результат безопасно

Thread-local storage

Если каждый поток работает со своей копией данных, гонок нет. В Python это threading.local(), в Java - ThreadLocal. Подходит для контекстных данных вроде соединения с БД или текущего пользователя в запросе.

Типичные ошибки при защите от гонок

ОшибкаПоследствиеКак исправить
Блокировка только при записи, но не при чтенииЧитатель видит частично обновленные данныеБлокировать и чтение, или использовать RWMutex
Слишком широкая критическая секцияПотеря параллелизма, деградация производительностиДержать блокировку минимально необходимое время
Deadlock из-за неправильного порядка захвата блокировокПрограмма зависает навсегдаВсегда захватывать блокировки в одном порядке
Уровень изоляции READ COMMITTED вместо REPEATABLE READФантомные чтения, гонки в транзакцияхВыбирать уровень изоляции под конкретный сценарий
Считать volatile достаточной защитой в JavaВидимость гарантирована, атомарность - нетИспользовать AtomicInteger или synchronized

Deadlock: когда лечение хуже болезни

Добавляя блокировки, легко создать новую проблему - взаимную блокировку. Поток A держит ресурс 1 и ждет ресурс 2. Поток B держит ресурс 2 и ждет ресурс 1. Оба ждут вечно.

Классическое правило предотвращения: всегда захватывать блокировки в одном и том же порядке во всем коде. Если везде сначала захватывается блокировка счета-отправителя, потом получателя - deadlock невозможен.

Другой подход - использовать tryLock с таймаутом: если не удалось захватить блокировку за N миллисекунд, освободить уже захваченные и попробовать позже.

Важно: Deadlock и race condition - разные проблемы с разными симптомами. Deadlock проявляется как зависание (программа перестает отвечать), race condition - как неправильные данные или редкие сбои. Не путайте их при диагностике.

Пример: защита операции перевода денег

Представим сервис переводов. Пользователь переводит деньги с одного счета на другой. Без защиты два параллельных перевода с одного счета могут оба пройти, даже если суммарно превышают баланс.

Наивная реализация:

def transfer(from_id, to_id, amount):
    sender = db.get_account(from_id)
    if sender.balance < amount:
        raise InsufficientFunds()
    db.update_balance(from_id, sender.balance - amount)
    db.update_balance(to_id, ...)

Между get_account и update_balance другой запрос может прочитать тот же баланс и тоже пройти проверку.

Защита через пессимистичную блокировку в PostgreSQL:

def transfer(from_id, to_id, amount):
    with db.transaction():
        # FOR UPDATE блокирует строку до конца транзакции
        sender = db.query(
            "SELECT * FROM accounts WHERE id = %s FOR UPDATE",
            from_id
        )
        if sender.balance < amount:
            raise InsufficientFunds()
        db.execute(
            "UPDATE accounts SET balance = balance - %s WHERE id = %s",
            amount, from_id
        )
        db.execute(
            "UPDATE accounts SET balance = balance + %s WHERE id = %s",
            amount, to_id
        )

Теперь второй параллельный запрос будет ждать снятия блокировки и увидит уже обновленный баланс.

Чеклист: как проверить код на race condition

  • Есть ли в коде глобальные или разделяемые переменные, которые изменяются из нескольких потоков?
  • Все ли операции «проверить, потом действовать» защищены блокировкой или транзакцией?
  • Используется ли -race флаг в Go или ThreadSanitizer при тестировании?
  • Есть ли стресс-тесты с параллельными запросами на критические операции?
  • Правильно ли выбран уровень изоляции транзакций для каждого сценария?
  • Нет ли мест, где блокировка захватывается в разном порядке в разных частях кода?
  • Проверены ли операции с кешем на предмет cache stampede?
  • Есть ли мониторинг аномалий в данных (отрицательные балансы, дублирующиеся записи, расхождение агрегатов)?

FAQ

Что такое race condition простыми словами?

Это ошибка, которая возникает, когда два потока одновременно работают с одними данными и мешают друг другу. Результат зависит от того, кто успел первым, - и это делает поведение программы непредсказуемым.

Чем race condition отличается от deadlock?

Race condition - это неправильный результат из-за неконтролируемого порядка операций. Deadlock - это зависание, когда два потока бесконечно ждут друг друга. Первое проявляется как баги с данными, второе - как зависшая программа.

Можно ли поймать race condition обычными тестами?

Обычные unit-тесты почти никогда не воспроизводят гонки - они не создают нужную нагрузку и тайминги. Нужны специальные инструменты (race detector) и стресс-тесты с параллельными запросами.

Всегда ли нужны блокировки для защиты?

Нет. Для простых числовых операций достаточно атомарных примитивов. Для изолированных данных подходит thread-local storage. Архитектуры без общего состояния (акторы, очереди) решают проблему на уровне дизайна без явных блокировок.

Как понять, что в production есть race condition?

Косвенные признаки: отрицательные значения там, где их быть не должно; дублирующиеся записи в БД несмотря на уникальные индексы; расхождение между агрегатами и детальными данными; ошибки, которые воспроизводятся только под нагрузкой и исчезают при повторной попытке.

Замедляют ли блокировки программу?

Да, блокировки добавляют накладные расходы и уменьшают параллелизм. Поэтому важно держать критическую секцию минимальной, использовать RWMutex там, где чтений больше, чем записей, и рассматривать атомарные операции для простых случаев.

Итог

Race condition - это не экзотика. Это повседневная проблема любого сервиса, который обрабатывает параллельные запросы и работает с общим состоянием. Баги этого класса особенно неприятны тем, что не воспроизводятся стабильно и часто проявляются только в production под нагрузкой.

Защита строится на понимании трех условий гонки: общий ресурс, изменение, отсутствие координации. Убрать любое из них - и проблема исчезает. Блокировки, атомарные операции, транзакции с правильной изоляцией, иммутабельность и архитектуры без общего состояния - это не взаимоисключающие подходы, а инструменты для разных ситуаций.

Добавьте race detector в CI, напишите стресс-тесты для критических операций и проверяйте инварианты данных в мониторинге. Это не гарантирует поимку всех гонок, но существенно снижает шанс, что они доберутся до пользователей.