async def и def обработчики: что выбрать
FastAPI принимает обработчики как с async def, так и с обычным def, но обращается с ними по-разному: синхронные он запускает в отдельном пуле потоков, чтобы не блокировать event loop.
Запомните правило:
async defкрутится прямо в event loop, а обычныйdefFastAPI уносит в 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.