Фоновые задачи: BackgroundTasks и когда нужен Celery

Урок разбирает встроенный механизм BackgroundTasks FastAPI, его границы и момент, когда пора переходить к настоящей очереди задач вроде Celery.

BackgroundTasks — способ выполнить лёгкую работу после того, как ответ уже ушёл клиенту, не заставляя его ждать; задача исполняется в том же процессе FastAPI.

Частый сценарий: пользователь отправил форму, вам нужно отдать ему «ок» немедленно, но при этом ещё отправить письмо, записать лог или сбросить кэш. Заставлять клиента ждать SMTP-сервер — плохо: ответ затянется. FastAPI даёт встроенный инструмент BackgroundTasks, который выполнит такую работу уже после отправки ответа.

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

Зачем это на практике

Цель — короткое время ответа. Если клиент сделал POST /signup, мы хотим вернуть 201 за миллисекунды, а не за секунду, пока уйдёт приветственное письмо. Вынеся отправку письма в фон, мы отвечаем сразу, а письмо уходит «вдогонку». Пользователь доволен скоростью, а необязательная работа делается асинхронно по отношению к ответу.

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

Как работают BackgroundTasks

Вы добавляете параметр background_tasks: BackgroundTasks в обработчик и регистрируете функции через .add_task(...). FastAPI выполнит их после возврата ответа. Пример импортирует FastAPI, поэтому он для чтения (не исполняется в браузере).

from fastapi import FastAPI, BackgroundTasks

app = FastAPI()

def write_log(message: str):
    with open("log.txt", "a") as f:
        f.write(message + "\n")

def send_email(to: str):
    # представим медленную отправку через SMTP
    ...

@app.post("/signup")
async def signup(email: str, background_tasks: BackgroundTasks):
    background_tasks.add_task(write_log, f"новый пользователь: {email}")
    background_tasks.add_task(send_email, email)
    # ответ уходит немедленно; обе задачи выполнятся ПОСЛЕ этого
    return {"status": "ok"}

Важная деталь: задачи запускаются после формирования ответа, но в том же процессе и (для синхронных функций) в том же пуле потоков. Если задача — async def, она исполнится в event loop; если обычная def — в threadpool. Здесь действует ровно та же логика блокировки, что и в прошлом уроке.

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

Жёсткие ограничения

BackgroundTasks — это не очередь. Вот её реальные границы:

ОграничениеЧто это значит
Живёт в процессе веб-серверапри рестарте/деплое/краше процесса невыполненные задачи теряются безвозвратно
Нет ретраевупавшую задачу никто не повторит; нет встроенной обработки сбоев
Нет персистентностизадачи нигде не сохраняются — нельзя пережить перезапуск или посмотреть историю
Делит ресурсы с APIтяжёлая задача отъедает потоки/цикл и замедляет обслуживание запросов
Нет расписаниянельзя «через час» или «каждую ночь» — только сразу после ответа
Не масштабируется отдельнонельзя добавить воркеров под нагрузку независимо от веб-серверов

Иными словами, BackgroundTasks хорош ровно для лёгкой, необязательной, быстрой работы, потерю которой при рестарте вы переживёте: дописать строку в лог, отправить одно уведомление, сбросить кэш.

Когда пора в Celery (или другую очередь)

Как только задача становится важной, долгой или требует гарантий — её место в полноценной очереди задач. Самый известный вариант в мире Python — Celery с брокером (Redis или RabbitMQ); есть и более лёгкие альтернативы (RQ, Dramatiq, arq, TaskIQ). Идея у всех одна: API кладёт сообщение в брокер и сразу отвечает, а отдельный процесс-воркер забирает сообщение и выполняет задачу.

Это псевдоструктура для понимания, не исполнимый код:

# tasks.py — описание задачи для воркера Celery
from celery import Celery

celery_app = Celery("app", broker="redis://localhost:6379/0")

@celery_app.task(bind=True, max_retries=3)
def generate_report(self, user_id: int):
    # тяжёлая работа: выгрузка, рендер PDF, загрузка в S3...
    ...

# в роуте FastAPI — только постановка в очередь, ответ мгновенный
@app.post("/reports")
async def make_report(user_id: int):
    task = generate_report.delay(user_id)   # кладём в брокер и сразу отвечаем
    return {"task_id": task.id, "status": "queued"}

Сравнение двух подходов:

КритерийBackgroundTasksCelery / очередь
Переживает рестартнетда (задача в брокере)
Ретраи при сбоенетда, настраиваемые
Расписание (cron)нетда (Celery beat)
Отдельное масштабированиенетда (добавляем воркеров)
Тяжёлый CPU / минуты работынет (тормозит API)да (отдельные процессы)
Инфраструктураничего лишнегонужен брокер (Redis/RabbitMQ) и воркеры

Грубое правило-памятка: секунды и «не страшно потерять» → BackgroundTasks; минуты, важно, нужны ретраи/расписание/масштаб → очередь.

Как это работает под капотом

Под капотом BackgroundTasks — это объект, который ASGI-сервер выполняет в рамках жизненного цикла ответа: starlette формирует и отправляет тело ответа, а затем вызывает накопленные задачи. Никакого отдельного хранилища, брокера или процесса нет — всё происходит внутри того же воркера uvicorn/gunicorn, что обслуживает запросы.

Celery устроен принципиально иначе: .delay() сериализует имя задачи и аргументы в сообщение и кладёт его в брокер. Воркер — отдельный процесс (часто на другой машине) — слушает очередь, десериализует сообщение, исполняет задачу, при ошибке делает ретрай с задержкой, а результат может сохранить в backend (тот же Redis). Поэтому задача переживает рестарт API, а воркеров можно масштабировать независимо.

Частые ошибки

Складывать в BackgroundTasks тяжёлую или критичную работу. Рендер видео, генерация большого отчёта, платёжная логика — всё это нельзя терять при деплое и нельзя гонять в процессе API. Такое выносят в очередь.

Думать, что задача защищена от падения. У BackgroundTasks нет ретраев и персистентности: упало — потерялось молча, клиент уже получил «ок». Если важно «точно выполнилось», нужен брокер с подтверждениями.

Класть блокирующий код в async-задачу. Фоновые задачи подчиняются той же модели: синхронная функция уйдёт в threadpool, а блокирующий вызов внутри async def-задачи затормозит цикл — ровно как в обработчике.

Тащить Celery в маленький проект ради одного письма. Брокер и воркеры — это инфраструктура, которую надо разворачивать и сопровождать. Если задача лёгкая и одна — BackgroundTasks проще и достаточно. Сложность вводите по необходимости.

Итоги

  • BackgroundTasks выполняет работу после отправки ответа, в том же процессе — отлично для лёгких необязательных дел (лог, одно уведомление, сброс кэша).
  • У него нет персистентности, ретраев, расписания и отдельного масштабирования: при рестарте незавершённые задачи теряются.
  • Как только работа становится тяжёлой, долгой или критичной — выносите её в очередь (Celery/RQ/Dramatiq) с брокером и отдельными воркерами.
  • Очередь даёт ретраи, расписание (cron), переживание рестартов и независимое масштабирование воркеров.
  • Фоновые задачи подчиняются той же модели sync/async: блокирующий код в них так же опасен для event loop.
Проверьте себя
1. Что произойдёт с задачами BackgroundTasks, если процесс FastAPI перезапустится до их выполнения?
AОни сохранятся и выполнятся после рестарта
BОни будут безвозвратно потеряны — BackgroundTasks не персистентны
CFastAPI автоматически повторит их через брокер
DОни переедут на другой воркер uvicorn
2. Когда стоит выбрать Celery (или другую очередь) вместо BackgroundTasks?
AВсегда — BackgroundTasks устарели
BДля тяжёлых, долгих или критичных задач, которым нужны ретраи, расписание или отдельное масштабирование
CТолько если вы пишете на async def
DКогда нужно отправить ровно одно письмо после ответа