Типичные баги конкурентного кода и как их избегать

Финальный разбор: какие баги встречаются чаще всего и какие правила защищают от них на практике.

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) важнее «ловли».
  • Проверку и действие над общими данными делайте атомарно, под блокировкой.
  • Меньше общего состояния, единый порядок блокировок, таймауты и готовые примитивы — лучшая защита.
Проверьте себя
1. Почему добавление логов (print) плохо помогает в отладке гонок?
AЛоги запрещены в потоках
BВывод меняет тайминги, и баг-heisenbug может исчезнуть или сместиться
Cprint создаёт новые потоки
DЛоги удаляют общие данные
2. В чём опасность паттерна «проверить, затем действовать» (check-then-act) при потоках?
AОн слишком медленный
BМежду проверкой и действием другой поток может изменить состояние, и оба пройдут проверку
CОн не компилируется
DОн работает только с числами
3. Какое правило вернее всего предотвращает deadlock на нескольких блокировках?
AБрать блокировки в случайном порядке
BВсегда захватывать блокировки в одном и том же фиксированном порядке
CНикогда не освобождать блокировки
DИспользовать как можно больше блокировок