FastAPI асинхронен, и это его сила: один процесс держит тысячи одновременных запросов, пока они ждут базу или соседний сервис. Но та же асинхронность — источник самых дорогих и незаметных ошибок производительности. Один блокирующий вызов в неправильном месте превращает быстрый сервис в медленный, и в логах это никак не видно.
Чтобы не наступить на это, нужно понимать ровно одну вещь: как FastAPI выполняет твою функцию в зависимости от того, async def она или просто def.
Две дороги: event loop и threadpool
FastAPI обрабатывает обработчики по-разному:
async defвыполняется прямо в event loop — едином потоке, который по очереди ведёт все запросы. Пока такой обработчик чего-то ждёт черезawait, loop переключается на другие запросы.def(обычная функция) выполняется в отдельном потоке из пула — FastAPI сам уводит его туда, чтобы синхронный код не остановил loop.
Вывод, из которого следует всё остальное: в async def нельзя делать ничего блокирующего без await. Блокирующий вызов в async def останавливает не один запрос, а весь event loop — встают все запросы сразу.
Когда что выбирать
Правило простое:
- Внутри только
await-вызовы (async-база, async-HTTP) — пишиasync def. Это основной путь FastAPI. - Внутри неизбежный синхронный код (библиотека без async, тяжёлый расчёт) — пиши
def, и FastAPI уведёт его в threadpool, не трогая loop.
Худший вариант — async def, внутри которого синхронный блокирующий вызов: ты пообещал фреймворку неблокирующую функцию и нарушил обещание.
import time
import asyncio
@router.get("/bad")
async def bad():
time.sleep(1) # блокирует ВЕСЬ event loop на секунду
@router.get("/ok")
async def ok():
await asyncio.sleep(1) # отдаёт управление loop — другие запросы идут
Async-клиенты вместо блокирующих
Чтобы оставаться в async def, всё, что ходит наружу, должно быть асинхронным. Синхронные клиенты в async-обработчике — та самая ловушка.
import httpx
@router.get("/rate")
async def rate(currency: str):
async with httpx.AsyncClient() as client:
response = await client.get(f"https://rates.example/{currency}")
return response.json()
Для базы это означает async-драйвер и async-сессию SQLAlchemy (await session.execute(...)), а не синхронный вызов, — детали в статье про персистентность. Клиент к соседнему сервису — httpx.AsyncClient, а не синхронный. Один блокирующий вызов в цепочке сводит на нет всю асинхронность вокруг.
Неизбежный блокирующий вызов
Иногда синхронного не избежать: библиотека без async-версии, обращение к файлу, CPU-тяжёлый шаг. Если обработчик в остальном async, такой вызов уводят в поток явно:
import asyncio
@router.post("/render")
async def render(body: RenderRequest):
result = await asyncio.to_thread(render_pdf, body) # блокирующее — в отдельный поток
return result
asyncio.to_thread выполняет синхронную функцию в пуле потоков и не блокирует loop. Это аккуратный способ примирить async-обработчик с синхронной зависимостью.
Где это в UCP
Конкурентность — это решение уровня инфраструктуры, не бизнес-логики: Handler выражает сценарий, а async/await — лишь способ его исполнить, не заняв loop. Поэтому держать Handler-ы и репозитории асинхронными «до конца» (async сверху донизу) проще, чем смешивать: одна синхронная прослойка посреди async-цепочки и есть та самая утечка производительности.
Это тот же выбор, что в Spring-биндинге между MVC, WebFlux и виртуальными потоками, — только в Python он проявляется в одном ключевом слове async. Понимание, где проходит граница между loop и потоком, — то, что отличает сервис, который держит нагрузку, от сервиса, который встаёт под ней, и это часть ремесла продукт-инженера. Долгие же операции вообще не место в обработчике запроса — их выносят в фоновые задачи.