Два потока одновременно читают одно значение из базы данных. Оба видят остаток на счете: 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 (через расширения) | Гонки в многопоточном коде |
| Helgrind | C, C++ (через Valgrind) | Нарушения порядка блокировок, гонки |
| Java PathFinder | Java | Модельная проверка многопоточных программ |
| 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, напишите стресс-тесты для критических операций и проверяйте инварианты данных в мониторинге. Это не гарантирует поимку всех гонок, но существенно снижает шанс, что они доберутся до пользователей.