Типичные баги конкурентного кода и как их избегать
Финальный разбор: какие баги встречаются чаще всего и какие правила защищают от них на практике.
Heisenbug — баг конкурентности, который меняет поведение или исчезает при попытке его наблюдать (например, при добавлении логов), из-за чувствительности к таймингам.
Конкурентные баги коварны: они недетерминированны, плохо воспроизводятся и часто исчезают под отладчиком. Поэтому важнее не «ловить» их, а знать типовые ловушки и обходить их заранее. Соберём всё, что мы изучили, в практический свод.
Каталог типичных багов
| Баг | Суть | Лекарство |
| Race condition | несогласованный доступ к общим данным | блокировка, очередь, неизменяемость |
| Deadlock | круговое ожидание блокировок | единый порядок захвата, таймауты |
| Livelock | активность без прогресса | случайный backoff |
| Starvation | поток не получает ресурс | справедливые очереди, старение |
| Check-then-act | состояние изменилось после проверки | атомарная проверка-и-действие под блокировкой |
Ловушка «проверить, затем действовать»
Очень частый баг — проверить условие, а потом действовать, но между этими шагами другой поток всё поменял.
cache = {}
def get_or_create(key):
# ОПАСНО при потоках: между проверкой и записью другой поток мог вставить ключ
if key not in cache: # проверили
cache[key] = key.upper() # ... здесь мог вклиниться другой поток ... затем действуем
return cache[key]
print(get_or_create("a"))
print(get_or_create("a"))Вывод:
A A
В однопоточном коде всё хорошо, но при потоках проверку и действие нужно делать под одной блокировкой, иначе два потока одновременно пройдут проверку и оба создадут значение.
Практические правила
- Минимизируйте общее изменяемое состояние. Нет общих данных — нет половины багов.
- Предпочитайте очереди и сообщения блокировкам. Готовые структуры корректнее самописных.
- Один порядок блокировок, всегда. Это убирает deadlock.
- Ставьте таймауты. Зависание превращается в управляемую ошибку.
- Не отлаживайте гонки принтами. Логи меняют тайминги; используйте детерминированные тесты и анализаторы.
- Выбирайте инструмент под нагрузку. CPU — процессы, I/O — потоки или asyncio.
Как работает под капотом
Почему эти баги так трудно ловить? Планировщик ОС вытесняет потоки в непредсказуемые моменты, а процессор и компилятор переупорядочивают инструкции. Сочетание делает поведение зависящим от тончайших таймингов, которые невозможно повторить вручную. Поэтому индустрия движется к подходам, где опасных мест меньше по дизайну: неизменяемые данные, передача сообщений, высокоуровневые примитивы (Queue, Executor) вместо ручных блокировок.
Частые ошибки
- «Добавлю лог и пойму». Лог меняет тайминги, и heisenbug прячется. Воспроизводите детерминированно.
- Чинить симптом, а не причину. Лишний
sleepможет «замаскировать» гонку, не убрав её. - Самописная синхронизация «по-быстрому». Почти всегда уже есть проверенный примитив.
Итог
- Конкурентные баги недетерминированны и плохо воспроизводимы (heisenbug).
- Знание каталога (гонки, deadlock, livelock, check-then-act) важнее «ловли».
- Проверку и действие над общими данными делайте атомарно, под блокировкой.
- Меньше общего состояния, единый порядок блокировок, таймауты и готовые примитивы — лучшая защита.