ESPN DeportesLiga MX: Los pronósticos de ESPN para el Clásico NacionalThe Jerusalem PostNegev Vet clinics founder Dr. Galia Melman Shalev to receive award for Oct. 7 animal rescue effortsESPNWeek 3 preview: Florida State's bleak future, revenge game in Oxford, moreוואלהרוכב אופניים חשמליים בן 66 נפצע בינוני מפגיעת רכב באשדודInquirer4 students injured in shooting inside South Cotabato schoolRTP DesportoVasco Seabra confiante em resposta forte do Arouca em AlvaladeDaily MaverickGROUNDUP: Raw sewage is flowing in nearly every metro, and the numbers keep getting worseBBC NewsWe have a duty to 'progress' Good Friday Agreement, taoiseach tells PMAntara NewsGovt targets 1 million household gas connections by 2028EuronewsJapan tests mobile robotic rubbish bins at Shinjuku Station7sur7Début lundi du procès sur l’assassinat de la baronne Ullens: ce qu’il faut savoir
The Daily Newsstand · Free, Always
Friday, September 18, 2026

async def или def в FastAPI: как одна строчка делает сервис в 40 раз медленнее

Translate

В FastAPI можно объявить обработчик как async def, а можно как обычный def. Многие ставят async везде «потому что так быстрее» и спокойно вызывают внутри requests.get() или синхронный драйвер БД. Сервис при этом работает, тесты проходят, а на первой же реальной нагрузке всё встаёт колом, причём вместе с health‑check'ом.

В статье разберу, что на самом деле происходит с каждым вариантом, покажу замеры и дам короткую шпаргалку, какой вариант выбирать.

Как FastAPI выполняет обработчики

Правило одно, но из него следует всё остальное.

  • async def выполняется прямо в event loop. Пока корутина не дошла до await, никто другой в этом процессе не работает: ни другие запросы, ни /health.

  • def FastAPI сам отправляет в пул потоков через anyio.to_thread.run_sync. Event loop остаётся свободным, но размер пула ограничен: по умолчанию в anyio это 40 потоков на процесс.

То же самое касается зависимостей (Depends): синхронная зависимость уходит в threadpool, асинхронная выполняется в event loop.

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

Стенд

Чтобы не спорить на словах, я собрал небольшой стенд:

  • «внешний API» — отдельный FastAPI‑сервис, который отвечает через 200 мс (await asyncio.sleep(0.2));

  • тестируемый сервис с несколькими вариантами одной и той же ручки;

  • нагрузочный скрипт на httpx.AsyncClient, который шлёт N одновременных запросов и считает общее время и перцентили.

Окружение: 1 vCPU, Python 3.12, FastAPI 0.141.1, Starlette 1.6.0, uvicorn 0.53.0 (один воркер), httpx 0.28.1, requests 2.33.1. Абсолютные цифры на вашей машине будут другими, но соотношения сохранятся. Каждый замер я повторял несколько раз, разброс был в пределах 10%.

Варианты ручки:

import httpx
import requests
from fastapi import FastAPI
from starlette.concurrency import run_in_threadpool

UPSTREAM = "http://127.0.0.1:9000/slow"

# 1. Ошибка: синхронный клиент внутри async def
@app.get("/async-requests")
async def async_requests():
    return requests.get(UPSTREAM).json()

# 2. Синхронная функция — FastAPI уведёт её в threadpool
@app.get("/sync-requests")
def sync_requests():
    return requests.get(UPSTREAM).json()

# 3. Синхронный вызов, явно вынесенный в поток
@app.get("/async-to-thread")
async def async_to_thread():
    r = await run_in_threadpool(requests.get, UPSTREAM)
    return r.json()

# 4. Правильно: асинхронный клиент в async def
@app.get("/async-httpx")
async def async_httpx():
    r = await app.state.client.get(UPSTREAM)  # httpx.AsyncClient из lifespan
    return r.json()

Полный код стенда — в репозитории (ссылка в конце).

Замер 1: 100 одновременных запросов

Вариант

Общее время

p50

max

RPS

async def + requests

20.48 с

10 447 мс

20 458 мс

4.9

def + requests

0.70 с

467 мс

680 мс

143

async def + run_in_threadpool

0.70 с

475 мс

685 мс

142

async def + httpx.AsyncClient

0.52 с

395 мс

507 мс

192

Первая строка — это не «немного медленнее». 100 запросов по 200 мс ровно за 20 секунд означают, что сервис обрабатывал их строго по одному. Каждый requests.get() держал event loop, пока ждал ответа, и остальные 99 запросов стояли в очереди. Разница с правильным вариантом — примерно в 40 раз по RPS.

Самое неприятное, что ручка с одиночным запросом отвечает за нормальные 200 мс. На локальной машине и в юнит‑тестах проблема не видна.

Остальные три варианта ведут себя нормально. Асинхронный клиент быстрее всех, потому что не тратит ресурсы на потоки, но и def, и явный run_in_threadpool дают разумный результат.

Замер 2: что происходит с health‑check

Теперь запустим 50 медленных запросов и, пока они выполняются, дёрнем самую простую ручку:

@app.get("/health")
async def health():
    return {"status": "ok"}

Фоновая нагрузка (50 запросов)

Время ответа /health

async def + requests

10 193 мс

def + requests

31 мс

async def + httpx.AsyncClient

33 мс

Десять секунд на ответ {"status": "ok"}. В Kubernetes liveness‑проба с таймаутом в пару секунд такое не переживёт: под перезапустят, нагрузка перейдёт на соседей, и те упадут по той же причине. Проблема в одной ручке превращается в падение всего сервиса.

Замер 3: CPU‑нагрузка в async def

Блокирует не только сетевой ввод‑вывод. Любые тяжёлые вычисления внутри async def делают то же самое:

def cpu_work():
    s = 0
    for i in range(3_000_000):
        s += i * i
    return s

@app.get("/async-cpu")
async def async_cpu():
    return {"s": cpu_work()}

Один такой запрос занимает около 120 мс. Пока выполняются пять таких запросов, /health отвечает за 571 мс: он ждёт, пока все пять досчитаются.

В реальных проектах такой код встречается чаще, чем кажется: хеширование паролей через bcrypt или argon2, генерация PDF и Excel, обработка изображений, pandas, сериализация огромных JSON.

Для CPU‑задач перенос в поток помогает только частично: из‑за GIL потоки не считают параллельно, хотя event loop хотя бы получает шанс переключиться. Надёжные варианты — ProcessPoolExecutor или вынос задачи в фоновый воркер (Celery, arq, Taskiq).

Замер 4: лимит threadpool

Раз def работает хорошо, можно сделать все ручки синхронными? Не совсем: у пула потоков есть предел.

Для чистоты эксперимента я взял ручку с time.sleep(0.5), которая не нагружает процессор, и отправил на неё 200 одновременных запросов. Параллельно я дёргал две быстрые ручки: синхронную /sync-health и асинхронную /health.

Размер threadpool

Общее время

p50

/sync-health

/health

40 (по умолчанию)

2.72 с

1 592 мс

2 253 мс

37–144 мс

200

1.06–1.15 с

713–754 мс

16–241 мс

2 мс

С 40 потоками 200 запросов по 0.5 с проходят пятью «волнами», отсюда примерно 2.5 секунды. Главное здесь — быструю синхронную ручку тоже задело: она ждала свободный поток больше двух секунд, хотя сама выполняется мгновенно. Все def‑обработчики и синхронные зависимости делят один общий пул. Асинхронный /health при этом почти не пострадал.

Лимит можно поднять в lifespan:

from contextlib import asynccontextmanager
import anyio.to_thread

@asynccontextmanager
async def lifespan(app: FastAPI):
    limiter = anyio.to_thread.current_default_thread_limiter()
    limiter.total_tokens = 100
    yield

Прежде чем крутить эту настройку, учтите два момента.

  1. Каждый поток расходует память, а на малом числе ядер создание сотен потоков само по себе стоит времени. Это видно по замеру: при 200 потоках общее время всё равно не 0.5 с, а около секунды.

  2. Если потоки ходят в базу через пул соединений (например, SQLAlchemy с pool_size=10), то 100 потоков будут просто стоять в очереди за 10 соединениями. Размер threadpool нужно согласовывать с размерами пулов, в которые он упирается.

Отдельно отмечу asyncio.to_thread: он использует стандартный ThreadPoolExecutor event loop, а не лимитер anyio. Это другой пул с другим размером (min(32, os.cpu_count() + 4)), и в FastAPI‑коде удобнее придерживаться run_in_threadpool из Starlette, чтобы все синхронные вызовы жили в одном понятном пуле.

Шпаргалка: что выбирать

Что делает обработчик

Как объявлять

Ходит в сеть или БД через асинхронную библиотеку (httpx, asyncpg, redis.asyncio)

async def + await

Работает только с синхронными библиотеками (requests, psycopg2, boto3)

def

Смешанный случай: есть и await, и синхронный вызов

async def, синхронную часть — через await run_in_threadpool(...)

Тяжёлые вычисления

Процессный пул или фоновая очередь задач

Ничего не ждёт и считает мгновенно

async def (без накладных расходов на поток)

Если сомневаетесь, а асинхронного клиента под рукой нет, def безопаснее: в худшем случае вы упрётесь в лимит пула, а не остановите весь процесс.

Типичные скрытые блокировщики

Список того, что чаще всего незаметно оказывается внутри async def:

  • requests, urllib, синхронный клиент httpx.Client;

  • синхронные драйверы БД: psycopg2, pymysql, синхронная сессия SQLAlchemy;

  • SDK облаков и сервисов (boto3 и многие другие), даже если выглядят безобидно;

  • time.sleep() вместо await asyncio.sleep();

  • чтение и запись больших файлов через обычный open();

  • хеширование паролей, сжатие, криптография;

  • синхронный клиент Redis;

  • тяжёлая работа с pandas, Pillow и генерация документов.

Как найти проблему в своём проекте

Линтер. В ruff есть набор правил ASYNC (порт flake8-async), который ловит блокирующие HTTP‑вызовы, time.sleep и open() внутри асинхронных функций:

toml

[tool.ruff.lint]
extend-select = ["ASYNC"]

Это самый дешёвый способ: проблема находится ещё до код‑ревью.

Debug‑режим asyncio. С переменной окружения PYTHONASYNCIODEBUG=1 asyncio пишет в лог предупреждения о callback'ах, которые выполнялись дольше slow_callback_duration (по умолчанию 100 мс). Для прода режим тяжеловат, но на стенде помогает быстро найти виновника.

Метрика задержки event loop. В проде я бы держал простой фоновый монитор, который замечает, что цикл «опаздывает»:

import asyncio
import logging

logger = logging.getLogger(__name__)

async def loop_lag_monitor(interval: float = 0.5, threshold: float = 0.1):
    loop = asyncio.get_running_loop()
    while True:
        start = loop.time()
        await asyncio.sleep(interval)
        lag = loop.time() - start - interval
        if lag > threshold:
            logger.warning("event loop lag: %.0f ms", lag * 1000)

Запускается как задача в lifespan, а значение lag удобно отдавать в Prometheus. Если график задержки подскакивает вместе с ростом latency, почти наверняка где‑то сидит блокирующий вызов.

py‑spy. Команда py-spy dump --pid <PID> показывает, что прямо сейчас выполняет каждый поток процесса. Если в момент подвисания главный поток стоит внутри requests или socket.recv, виновник найден.

Итоги

  1. async def не делает код быстрее сам по себе. Это договорённость: внутри нет блокирующих вызовов.

  2. Один синхронный вызов в async def превращает сервис в однопоточный. В замере это дало падение с ~190 до 4.9 RPS и /health, отвечающий 10 секунд.

  3. def безопасен, но все синхронные обработчики и зависимости делят пул из 40 потоков, и быстрые ручки ждут медленных.

  4. CPU‑нагрузку не решает ни async, ни threadpool — её нужно выносить в процессы или очередь.

  5. Включите правила ASYNC в ruff и следите за задержкой event loop — это поймает большинство проблем до того, как они попадут в прод.

Код стенда: https://github.com/fonartur/fastapi‑async‑vs‑def‑benchmark.

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.