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 и потоком, — то, что отличает сервис, который держит нагрузку, от сервиса, который встаёт под ней, и это часть ремесла продукт-инженера. Долгие же операции вообще не место в обработчике запроса — их выносят в фоновые задачи.