async def и def обработчики: что выбрать

FastAPI принимает обработчики как с async def, так и с обычным def, но обращается с ними по-разному: синхронные он запускает в отдельном пуле потоков, чтобы не блокировать event loop.

Запомните правило: async def крутится прямо в event loop, а обычный def FastAPI уносит в threadpool. Выбор между ними определяет, заблокируете вы сервер или нет.

Это одно из самых недопонятых мест фреймворка. Кажется, что «асинхронный фреймворк» обязан требовать асинхронные функции. На деле FastAPI устроен прагматично: огромное количество существующих библиотек синхронные (старые драйверы БД, многие SDK), и заставлять всех переписывать их было бы жестоко. Поэтому фреймворк поддерживает оба стиля и сам решает, как безопасно их выполнить.

Если ваш обработчик объявлен async def, FastAPI доверяет вам: он запускает функцию прямо в event loop и ожидает, что вы внутри пользуетесь только неблокирующими await-операциями. Если же обработчик объявлен обычным def, FastAPI понимает, что внутри может оказаться блокирующий код, и поэтому выполняет такую функцию в threadpool — отдельном пуле рабочих потоков. Пока поток из пула блокируется на синхронной операции, основной event loop остаётся свободным для других запросов.

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

Под капотом FastAPI (через Starlette и anyio) делает примерно так: смотрит, корутина перед ним или обычная функция. Корутину он await'ит. Обычную функцию оборачивает в run_in_threadpool, который отправляет её в пул потоков и возвращает корутину-обёртку. Схема выбора:

обработчик
    |
    +-- async def ----> запуск прямо в event loop
    |                    (вы обязаны использовать await)
    |
    +-- def ----------> run_in_threadpool
                         (отдельный поток, event loop свободен)

Смоделируем разницу «занять поток vs уступить» на stdlib, без сети. Считаем, сколько «тактов» планировщик успевает раздать другим, пока одна операция ждёт:

import time

def blocking_work():
    # имитация блокирующего вызова: поток занят целиком
    total = 0
    for _ in range(3_000_000):
        total += 1
    return total

start = time.perf_counter()
blocking_work()
print("блокирующая работа заняла, сек:", round(time.perf_counter() - start, 3))
print("вывод: пока этот код выполнялся в потоке, event loop не должен был его держать —")
print("именно поэтому FastAPI уносит синхронные def-обработчики в threadpool")

Попробуй сам ▶ Если бы такой блокирующий цикл оказался прямо в async def, он застопорил бы весь сервер; в обычном def FastAPI спрячет его в поток.

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

Ошибка №1 — «async ради async»: объявить async def и внутри ходить в базу синхронным драйвером. Тогда вы получаете худшее: event loop блокируется, threadpool не используется. Лучше было бы оставить обычный def. Ошибка №2 — обратное: написать обычный def, но внутри вызвать асинхронную библиотеку без await; вы получите объект корутины, который никогда не выполнится. Ошибка №3 — забыть, что threadpool ограничен по размеру: если все потоки заняты долгими блокирующими вызовами, новые синхронные запросы встанут в очередь.

Best practices

  • Есть асинхронная библиотека и вы используете await — пишите async def.
  • Библиотека только синхронная (или код блокирующий) — пишите обычный def, пусть FastAPI сам уносит его в threadpool.
  • Не смешивайте: не вызывайте блокирующее в async def и асинхронное без await в def.
  • Если очень нужно вызвать блокирующее из корутины — оберните в fastapi.concurrency.run_in_threadpool.

Практическое правило выбора

Чтобы не гадать каждый раз, держите в голове короткий алгоритм. Спросите себя: «Все ли библиотеки, которыми я пользуюсь в этом обработчике, асинхронные, и я действительно вызываю их через await?» Если да — пишите async def. Если хотя бы одна важная операция синхронная и блокирующая (старый драйвер БД, синхронный HTTP-клиент, тяжёлый разбор файла) — пишите обычный def и доверьте FastAPI унести его в threadpool. Это правило спасает от самой коварной деградации производительности, когда сервер под нагрузкой внезапно «залипает»: почти всегда виной оказывается блокирующий вызов внутри async def, который намертво держит event loop. Если же нужно из корутины вызвать редкую блокирующую функцию, не переписывая весь обработчик, оберните её в run_in_threadpool — так вы локально вернёте её в пул потоков.

Итог: выбор между async def и def — это выбор, где выполнится код: в event loop или в threadpool. Правильное соответствие стиля функции и стиля библиотек — главный ключ к производительности FastAPI.

Проверьте себя
1. Как FastAPI выполняет обработчик, объявленный обычным def (не async)?
AОтказывается его запускать
BЗапускает прямо в event loop
CЗапускает в отдельном пуле потоков (threadpool), чтобы не блокировать event loop
DСоздаёт под него новый процесс
2. Что плохого в async def обработчике, который внутри делает синхронный блокирующий запрос к базе?
AНичего, это рекомендуемый подход
BБлокирующий вызов остановит весь event loop, ухудшив обслуживание всех запросов
CFastAPI автоматически перенесёт его в threadpool
DКод не скомпилируется