← назад к разделу

PYTHON · FASTAPI Фаза 5. Фундамент - ядро FastAPI

Обязательно

Async и конкурентность в FastAPI

Event loop · Threadpool · TaskGroup

async def работает в цикле событий, def уходит в ограниченный пул, а блокирующий вызов останавливает всех.

1. await отдаёт цикл другим запросам

1 / 5

Пока база или соседний сервис отвечает, корутина ждёт без потока. Event loop продолжает остальные задачи и возвращается к ней по готовности.

Event loopone threadA: await dbB: работаетC: работает

2. async def и def идут разными путями

2 / 5

async def выполняется в event loop и ждёт через await. Обычный def FastAPI отправляет в общий пул потоков, чтобы синхронный код не остановил цикл.

async defevent loopawait db / httpтысячи ожиданийdefthreadpool: 40sync library41-й ждёт слот

3. Блокировка в async def останавливает всех

3 / 5

time.sleep, requests и синхронная Session не отдают управление. Задержка растёт у всех запросов сразу, хотя ошибки в логе может не быть.

async def bad()time.sleep(3)loop стоитRequest B: ждётRequest C: ждётHealth: тоже ждёт

4. Независимые вызовы запускают вместе

4 / 5

TaskGroup ждёт все задачи и отменяет соседей при ошибке. gather подходит для частичных результатов, но сам не отменяет оставшиеся вызовы.

TaskGroupGET /user · 200 msGET /stats · 200 ms~200 msне 400 ms

5. Синхронное выносят осознанно

5 / 5

Один sync-шаг уходит в to_thread, вычисления на чистом Python - в процессы. Каждый внешний await получает таймаут, а AsyncClient создаётся в lifespan.

Async I/Ohttpx.AsyncClientAsyncSessionSync I/Oasyncio.to_threadpool limitCPU-boundProcessPoolExecutorнесколько ядер

FastAPI строится на асинхронности: один процесс держит тысячи одновременных запросов, пока они ждут базу или внешний сервис. Но та же асинхронность — источник незаметных проблем: один блокирующий вызов в неправильном месте останавливает весь сервис, и в логах это никак не отразится.

Разберём с нуля: что такое event loop, чем async def отличается от def, и где что использовать.

Как Python выполняет много задач в одном потоке

Обычная программа делает одно действие за другим. Если функция делает запрос к базе, она просто ждёт — никакой другой работы в это время не происходит.

Асинхронный подход устроен иначе. Есть event loop (цикл событий) — один поток, который ведёт множество задач одновременно. Когда задача начинает ждать (запрос к базе, HTTP-вызов, чтение файла), она говорит: «я жду, займись другими». Loop переключается на следующую задачу, а вернётся к первой, когда та дождётся ответа.

Ключевое слово await — это именно такая пометка: «здесь я жду, loop может переключиться».

async def get_user(user_id: int):
    user = await db.fetch_user(user_id)   # ждём БД → loop свободен для других запросов
    return user

Пока db.fetch_user выполняется на стороне базы данных, loop обрабатывает другие входящие запросы. Вернётся к этой функции, когда ответ придёт.

Две дороги: async def и def

FastAPI выполняет обработчики по-разному в зависимости от того, как они объявлены.

async def event loop await: loop свободен ответ def пул потоков, 40 штук блокирующий вызов в потоке ответ async def + time.sleep event loop loop стоит 3 секунды все запросы ждут

Два правильных пути и один неправильный: блокирующий вызов внутри async def останавливает цикл событий целиком, а не один запрос.

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

def (обычная функция) — FastAPI сам уводит её в отдельный поток из пула, чтобы синхронный код не заблокировал loop. Это полезно, когда синхронного кода не избежать.

@router.get("/users/{user_id}")
async def get_user(user_id: int):
    # правильно: async-драйвер базы, await, loop свободен
    user = await db.execute(select(User).where(User.id == user_id))
    return user


@router.get("/report")
def generate_report():
    # правильно: синхронный тяжёлый расчёт → FastAPI уводит в threadpool
    data = heavy_sync_calculation()
    return data

Главная ловушка: блокировка event loop

Самая частая ошибка — написать async def, но внутри вызвать что-то блокирующее без await:

import time


@router.get("/bad")
async def bad():
    time.sleep(3)         # блокирует ВЕСЬ event loop на 3 секунды


@router.get("/ok")
async def ok():
    await asyncio.sleep(3)    # отдаёт управление loop — другие запросы идут

time.sleep не знает про event loop. Он останавливает поток целиком — то есть весь loop, и все параллельные запросы встают вместе с ним. Никакой ошибки в логах не будет, сервис просто начнёт медленно отвечать под нагрузкой.

Правило: в async def нельзя вызывать ничего блокирующего без await. Вы пообещали фреймворку неблокирующую функцию — он рассчитывает на это.

Async-клиенты для внешних вызовов

Если обработчик async def, все исходящие запросы тоже должны быть асинхронными. Синхронный HTTP-клиент внутри async def — та самая ловушка.

import httpx


# неправильно: requests — синхронная библиотека, блокирует loop
@router.get("/rate/bad")
async def rate_bad(currency: str):
    import requests
    response = requests.get(f"https://rates.example/{currency}")  # блокирует loop
    return response.json()


# правильно: httpx.AsyncClient с await
@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(...)), а не синхронный вызов.

Пример выше открывает клиент на каждый запрос, и это упрощение: новый AsyncClient каждый раз заново устанавливает соединения. В рабочем сервисе клиент создают один раз в lifespan, кладут на app.state и достают через зависимость — тогда соединения переиспользуются.

Параллельные запросы через asyncio.gather

Иногда нужно сделать несколько независимых async-вызовов и дождаться всех. Если делать их по очереди через await, они выполнятся последовательно. asyncio.gather запускает их одновременно:

import asyncio
import httpx


@router.get("/dashboard")
async def dashboard():
    async with httpx.AsyncClient() as client:
        user_task = client.get("https://api.example/user")
        stats_task = client.get("https://api.example/stats")

        user_resp, stats_resp = await asyncio.gather(user_task, stats_task)

    return {
        "user": user_resp.json(),
        "stats": stats_resp.json(),
    }

Оба запроса уходят почти одновременно. Если каждый занимает 200 мс, суммарное время будет около 200 мс, а не 400.

Когда блокирующего не избежать

Иногда синхронного кода не обойти: библиотека без async-версии, CPU-тяжёлый шаг, обращение к файловой системе. Если остальной обработчик async, такой вызов уводят в поток явно через asyncio.to_thread:

import asyncio


def render_pdf(body):
    # тяжёлая синхронная операция
    ...


@router.post("/render")
async def render(body: RenderRequest):
    result = await asyncio.to_thread(render_pdf, body)   # в отдельный поток, loop свободен
    return result

asyncio.to_thread выполняет синхронную функцию в пуле потоков и возвращает результат через await. Loop при этом не блокируется.

Это то же самое, что происходит автоматически при def-обработчике, — только явное, когда вы хотите оставаться в async def.

Когда что выбирать

СитуацияЧто писать
Только async-вызовы внутри (БД, HTTP)async def
Синхронная библиотека, старый кодdef — FastAPI уведёт в threadpool
async-обработчик + один синхронный шагasync def + asyncio.to_thread
Несколько независимых async-вызововasync def + asyncio.gather

Худший вариант — async def с синхронным блокирующим вызовом внутри: ни корректности, ни производительности.

Ещё одна отмена, которая доходит слишком далеко: если запрос клиента прервали, событие отмены летит вниз по всем await и роняет фоновую запись, которую начинать бросать нельзя. Отделить внутреннюю задачу от внешней отмены умеет asyncio.shield: то, что обёрнуто в него, продолжает выполняться, даже когда отменяют снаружи (снаружи при этом поднимается CancelledError). Как отмена шаг за шагом идёт по цепочке await и чем shield отличается от простого перехвата - в статье Задачи и отмена следующей фазы; здесь достаточно помнить: «отмена запроса не должна ронять фиксацию платежа».

Дополнительно: при первом чтении можно пропустить

Глубже: TaskGroup, таймауты, размер пула потоков и процессырасширенное

У asyncio.gather есть неприятное свойство: если одна задача упала, остальные продолжают работать, а их результат уже никому не нужен. asyncio.TaskGroup (с 3.11) отменяет соседей при первой ошибке и поднимает ExceptionGroup, которую разбирают через except*:

async with asyncio.TaskGroup() as tg:
    user = tg.create_task(client.get("https://api.example/user"))
    stats = tg.create_task(client.get("https://api.example/stats"))
return {"user": user.result().json(), "stats": stats.result().json()}

Ожидание без предела — это зависший запрос под нагрузкой. Любой внешний вызов оборачивают в async with asyncio.timeout(2):, а у httpx.AsyncClient задают timeout при создании.

Пул потоков для def-обработчиков и asyncio.to_thread общий, и по умолчанию в нём 40 потоков. Сорок первый синхронный вызов будет ждать свободного потока, и сервис «встанет» без единой ошибки в логах. Лимит задаётся в lifespan:

import anyio

anyio.to_thread.current_default_thread_limiter().total_tokens = 100

Поток, впрочем, не поможет тяжёлым вычислениям: из-за GIL они всё равно отнимут ядро у event loop. Такую работу уводят в процессы — loop.run_in_executor(ProcessPoolExecutor(), f) или в отдельный воркер. Почему так, разобрано в статье о том, как Python выполняет код.

Коротко

  • Event loop — один поток, который ведёт много задач, переключаясь между ними в точках await.
  • async def выполняется в event loop. В нём нельзя вызывать ничего блокирующего без await.
  • def FastAPI автоматически выполняет в threadpool — синхронный код не блокирует loop.
  • Блокирующий вызов в async def останавливает весь event loop и все запросы сразу.
  • Для HTTP-запросов наружу используют httpx.AsyncClient, а не синхронный requests.
  • Для принудительного выноса синхронного кода в поток — asyncio.to_thread.
  • Для параллельного выполнения нескольких async-задач — asyncio.gather.
  • TaskGroup отменяет соседей при ошибке, asyncio.timeout ограничивает ожидание; пул потоков общий и в нём 40 потоков по умолчанию; вычисления уводят в процессы.

Что почитать дальше