$ sudo teach IT

Два воркера — одна задача: откуда берётся Race Condition?

Модуль: Конкурентная обработка задач в Python Backend
Уровень: Middle / Senior
Время прохождения: 25–35 минут


Чему ты научишься

После этого урока ты сможешь:

  • Объяснить, почему два воркера могут обработать одну задачу.
  • Распознать Race Condition в Python Backend.
  • Понять, почему обычная проверка статуса не защищает от гонки.
  • Объяснить, почему GIL не решает эту проблему.
  • Предложить атомарный захват задачи через PostgreSQL.

1. Реальная ситуация: задача обработана дважды

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

В PostgreSQL есть таблица tasks. В ней хранятся задачи, ожидающие обработки.

id type status
42 generate_report pending

Статус pending означает, что задача ещё не захвачена воркером.

Для обработки задач запущены два независимых процесса: Worker A и Worker B.

Оба используют примерно такую логику:

task = get_pending_task()

if task:
    process_task(task)
    mark_as_running(task)

На первый взгляд код выглядит нормально. Но что произойдёт, если оба воркера выполнят его одновременно?

Посмотрим на последовательность событий

Время Worker A Worker B
T1 Читает задачу #42: pending —
T2 — Читает задачу #42: pending
T3 Начинает обработку #42 —
T4 — Начинает обработку #42
T5 Обработка завершена Обработка завершена

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

Если задача генерирует отчёт, это может привести к лишней нагрузке. Если отправляет письмо — пользователь получит два письма. Если выполняет платёж — последствия могут быть гораздо серьёзнее.


2. Что такое Race Condition?

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

В нашем примере оба воркера обращаются к общему ресурсу — записи в базе данных.

Проблемная последовательность выглядит так:

READ → CHECK → PROCESS → UPDATE

Где:

  • READ — прочитать состояние задачи.
  • CHECK — проверить, что статус равен pending.
  • PROCESS — выполнить полезную работу.
  • UPDATE — изменить статус задачи.

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

Даже если перенести UPDATE перед PROCESS, проблема не исчезнет автоматически: два воркера всё ещё могут принять решение на основании одного и того же ранее прочитанного значения.

Главная мысль: проверка состояния и захват задачи должны быть согласованы так, чтобы только один воркер мог успешно перевести задачу из pending в running.


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

На собеседовании Race Condition часто связывают с многопоточностью. Но в backend-системах это понятие гораздо шире.

Process

Процесс — отдельный экземпляр выполняемой программы со своим адресным пространством.

Например, два Python-процесса могут независимо подключаться к одной PostgreSQL.

Thread

Поток — поток выполнения внутри процесса. Потоки одного процесса могут совместно использовать память.

Coroutine

Корутина — функция, выполнение которой можно приостановить и продолжить позже. В Python это часто связано с asyncio.

Worker

Воркер — логическая единица, которая выполняет задачи.

Воркер может быть:

  • отдельным процессом;
  • потоком;
  • асинхронным обработчиком;
  • экземпляром приложения в другом контейнере;
  • сервисом на другом сервере.

Поэтому наличие GIL в CPython не защищает от конкурентного захвата задачи разными процессами или экземплярами приложения.

Concurrency означает, что выполнение нескольких операций перекрывается во времени.

Parallelism означает, что операции физически выполняются одновременно.

Для возникновения Race Condition физический параллелизм не обязателен. Достаточно опасного чередования операций.


4. Разбираем порядок выполнения операций

Упростим модель. Пусть каждый воркер выполняет только два действия:

  1. READ — читает текущий статус задачи.
  2. CLAIM — пытается захватить задачу.

Для каждого воркера первое появление его буквы означает READ, а второе — CLAIM.

Сценарий AABB

A: READ  → видит pending
A: CLAIM → захватывает задачу

B: READ  → видит running
B: CLAIM → не захватывает задачу

Результат: один успешный захват.

Сценарий ABAB

A: READ  → видит pending
B: READ  → видит pending

A: CLAIM → захватывает задачу
B: CLAIM → тоже пытается захватить задачу

Если оба воркера принимают решение только по ранее прочитанному значению, оба могут считать захват успешным.

Результат наивной реализации: два успешных захвата одной задачи.

Сравнение

Порядок Наивный захват Атомарный захват
AABB 1 1
ABAB 2 1
ABBA 2 1
BBAA 1 1

Обрати внимание: наивная реализация иногда работает правильно. Именно поэтому подобные ошибки бывает сложно воспроизвести.


5. Как решить проблему на уровне PostgreSQL?

Один из способов — объединить проверку состояния и изменение состояния в одну SQL-операцию.

UPDATE tasks
SET status = 'running'
WHERE id = 42
  AND status = 'pending'
RETURNING id;

Здесь мы не делаем отдельный SELECT для принятия решения о захвате.

Вместо этого просим PostgreSQL:

Измени статус задачи #42 на running, но только если её текущий статус — pending. Верни id, если обновление произошло.

Что происходит при двух конкурентных запросах?

При уровне изоляции READ COMMITTED PostgreSQL согласует конкурентные обновления одной строки с помощью блокировки строки.

  1. Worker A выполняет UPDATE.
  2. Worker B пытается обновить ту же строку.
  3. Один из воркеров первым успешно меняет статус.
  4. После завершения конфликтующего обновления второй запрос проверяет условие заново.
  5. Если статус уже running, второй UPDATE не изменяет строку.

Успешный воркер получает результат:

 id
----
 42

Другой воркер не получает строк из RETURNING, если задача уже захвачена.

Как использовать результат в Python?

task_id = try_claim_task(42)

if task_id is None:
    print("Задача уже захвачена")
else:
    process_task(task_id)

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

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


6. А гарантирует ли это exactly-once processing?

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

Рассмотрим другой сценарий:

  1. Воркер успешно захватил задачу.
  2. Отправил запрос во внешний сервис.
  3. Внешний сервис выполнил действие.
  4. Воркер упал до сохранения результата.
  5. После восстановления задача была запущена повторно.

Если не предусмотреть дополнительную защиту, внешний эффект может повториться.

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

Подробнее разберём их в следующих уроках.


7. Что нужно запомнить

  • Race Condition возникает, когда результат зависит от порядка конкурентных операций.
  • Отдельные Python-процессы и контейнеры могут конкурировать за одну запись в PostgreSQL.
  • Последовательность SELECT → проверка → UPDATE сама по себе не гарантирует безопасный захват.
  • GIL не решает проблему согласованности между независимыми воркерами.
  • Атомарный UPDATE с условием по статусу позволяет определить единственного успешного захватчика задачи.
  • Атомарный захват и exactly-once processing — разные задачи.

8. Проверь себя

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

  1. Почему два воркера могут увидеть статус pending одновременно?
  2. Почему проблема возникает даже при запуске воркеров в разных процессах?
  3. Чем отличается последовательность AABB от ABAB?
  4. Почему UPDATE с условием status = 'pending' безопаснее отдельной проверки?
  5. Почему атомарный захват ещё не гарантирует exactly-once processing?

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

Два воркера - одна задача: откуда берётся Race Condition — Python Backend: инженерные задачи для собеседований Middle+/Senior | $ sudo teach IT