Состояние гонки и взаимоблокировки: причины и защита
Продвинутое3 минРедакция Innovialabs
Коротко
Гонка возникает, когда несколько потоков без синхронизации меняют общие данные, а deadlock — когда потоки ждут ресурсы друг друга по кругу. Лечится синхронизацией, единым порядком блокировок и отказом от общего изменяемого состояния.
Коротко: что это и почему опасно
Состояние гонки (race condition) — ситуация, когда результат зависит от того, в каком порядке потоки или процессы выполнили операции над общими данными. Код работает на тестах и ломается под нагрузкой.
Взаимоблокировка (deadlock) — потоки ждут друг друга бесконечно: первый держит ресурс A и ждёт B, второй держит B и ждёт A. Программа не падает, а просто зависает.
Обе ошибки плохо воспроизводятся, поэтому их важно понимать заранее, а не ловить в продакшене.
Воспроизводим гонку
Операция «прочитать — изменить — записать» не атомарна. Между чтением и записью другой поток может успеть обновить значение, и его изменение потеряется.
import threading
balance = 0
def deposit(n):
global balance
for _ in range(n):
current = balance # чтение
balance = current + 1 # запись
threads = [threading.Thread(target=deposit, args=(100_000,)) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(balance) # может оказаться меньше 400000
В реальных системах то же самое происходит с остатками на складе, балансами и счётчиками: два запроса одновременно читают «осталось 1 шт.» и оба оформляют заказ.
Как исправить гонку
- Блокировка (mutex, lock). Критическая секция выполняется только одним потоком.
lock = threading.Lock()
def deposit(n):
global balance
for _ in range(n):
with lock:
balance += 1
- Атомарные операции. Для простых счётчиков есть
AtomicIntegerв Java, пакетsync/atomicв Go. Они быстрее мьютекса, но подходят только для одной переменной. - Атомарность в базе данных. Вместо чтения в коде и записи обратно пишите условие в сам запрос:
UPDATE products SET stock = stock - 1
WHERE id = 42 AND stock > 0;
Если затронуто 0 строк — товара нет. Для сложной логики используйте транзакции и SELECT ... FOR UPDATE.
- Передача сообщений. Данными владеет один поток, остальные отправляют ему задачи через очередь (
queue.Queue, каналы в Go, акторы). Нет общего изменяемого состояния — нет гонки.
Воспроизводим deadlock
import threading, time
a, b = threading.Lock(), threading.Lock()
def worker1():
with a:
time.sleep(0.1)
with b: pass
def worker2():
with b:
time.sleep(0.1)
with a: pass
threading.Thread(target=worker1).start()
threading.Thread(target=worker2).start() # программа зависнет
Как избежать взаимоблокировок
- Единый порядок захвата. Если все потоки берут блокировки строго в порядке A, затем B, цикл ожидания невозможен. Порядок можно задать по id ресурса — например, при переводе между счетами сначала блокировать счёт с меньшим id.
- Таймауты.
lock.acquire(timeout=1)позволяет отступить, освободить уже взятое и повторить попытку. - Минимальные критические секции. Не держите блокировку во время сетевых запросов, I/O и вызовов чужого кода.
- Одна блокировка вместо нескольких, если производительность это позволяет.
- Очереди и иммутабельные данные снимают проблему на уровне архитектуры.
В базах данных deadlock тоже бывает: СУБД обнаруживает его и откатывает одну из транзакций. Код должен уметь повторить такую транзакцию.
Как обнаружить
| Инструмент | Что ловит |
|---|---|
go test -race, go run -race | гонки данных в Go |
ThreadSanitizer (-fsanitize=thread) | гонки в C/C++ и Rust |
Дамп потоков (jstack, py-spy dump) | где именно застряли потоки |
| Логи СУБД | взаимоблокировки транзакций |
| Нагрузочные тесты | гонки, которые видны только при параллельных запросах |
Типичные ошибки: проверять поведение одним запросом, считать операцию += атомарной, держать блокировку на время HTTP-запроса и ловить исключение о deadlock без повторной попытки.