Фоновые задачи: 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"}
Сравнение двух подходов:
| Критерий | BackgroundTasks | Celery / очередь |
| Переживает рестарт | нет | да (задача в брокере) |
| Ретраи при сбое | нет | да, настраиваемые |
| Расписание (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.