- PVSM.RU - https://www.pvsm.ru -
В FastAPI можно объявить обработчик как async def, а можно как обычный def. Многие ставят async везде «потому что так быстрее» и спокойно вызывают внутри requests.get() или синхронный драйвер БД. Сервис при этом работает, тесты проходят, а на первой же реальной нагрузке всё встаёт колом, причём вместе с health‑check'ом.
В статье разберу, что на самом деле происходит с каждым вариантом, покажу замеры и дам короткую шпаргалку, какой вариант выбирать.
Правило одно, но из него следует всё остальное.
async def выполняется прямо в event loop. Пока корутина не дошла до await, никто другой в этом процессе не работает: ни другие запросы, ни /health.
def FastAPI сам отправляет в пул потоков через anyio.to [1]_thread.run [2]_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()
Полный код стенда — в репозитории (ссылка в конце).
|
Вариант |
Общее время |
p50 |
max |
RPS |
|---|---|---|---|---|
|
|
20.48 с |
10 447 мс |
20 458 мс |
4.9 |
|
|
0.70 с |
467 мс |
680 мс |
143 |
|
|
0.70 с |
475 мс |
685 мс |
142 |
|
|
0.52 с |
395 мс |
507 мс |
192 |
Первая строка — это не «немного медленнее». 100 запросов по 200 мс ровно за 20 секунд означают, что сервис обрабатывал их строго по одному. Каждый requests.get() держал event loop, пока ждал ответа, и остальные 99 запросов стояли в очереди. Разница с правильным вариантом — примерно в 40 раз по RPS.
Самое неприятное, что ручка с одиночным запросом отвечает за нормальные 200 мс. На локальной машине и в юнит‑тестах проблема не видна.
Остальные три варианта ведут себя нормально. Асинхронный клиент быстрее всех, потому что не тратит ресурсы на потоки, но и def, и явный run_in_threadpool дают разумный результат.
Теперь запустим 50 медленных запросов и, пока они выполняются, дёрнем самую простую ручку:
@app.get("/health")
async def health():
return {"status": "ok"}
|
Фоновая нагрузка (50 запросов) |
Время ответа |
|---|---|
|
|
10 193 мс |
|
|
31 мс |
|
|
33 мс |
Десять секунд на ответ {"status": "ok"}. В Kubernetes liveness‑проба с таймаутом в пару секунд такое не переживёт: под перезапустят, нагрузка перейдёт на соседей, и те упадут по той же причине. Проблема в одной ручке превращается в падение всего сервиса.
Блокирует не только сетевой ввод‑вывод. Любые тяжёлые вычисления внутри 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).
Раз def работает хорошо, можно сделать все ручки синхронными? Не совсем: у пула потоков есть предел.
Для чистоты эксперимента я взял ручку с time.sleep(0.5), которая не нагружает процессор, и отправил на неё 200 одновременных запросов. Параллельно я дёргал две быстрые ручки: синхронную /sync-health и асинхронную /health.
|
Размер threadpool |
Общее время |
p50 |
|
|
|---|---|---|---|---|
|
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
Прежде чем крутить эту настройку, учтите два момента.
Каждый поток расходует память, а на малом числе ядер создание сотен потоков само по себе стоит времени. Это видно по замеру: при 200 потоках общее время всё равно не 0.5 с, а около секунды.
Если потоки ходят в базу через пул соединений (например, 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) |
|
|
Работает только с синхронными библиотеками (requests, psycopg2, boto3) |
|
|
Смешанный случай: есть и |
|
|
Тяжёлые вычисления |
Процессный пул или фоновая очередь задач |
|
Ничего не ждёт и считает мгновенно |
|
Если сомневаетесь, а асинхронного клиента под рукой нет, 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, виновник найден.
async def не делает код быстрее сам по себе. Это договорённость: внутри нет блокирующих вызовов.
Один синхронный вызов в async def превращает сервис в однопоточный. В замере это дало падение с ~190 до 4.9 RPS и /health, отвечающий 10 секунд.
def безопасен, но все синхронные обработчики и зависимости делят пул из 40 потоков, и быстрые ручки ждут медленных.
CPU‑нагрузку не решает ни async, ни threadpool — её нужно выносить в процессы или очередь.
Включите правила ASYNC в ruff и следите за задержкой event loop — это поймает большинство проблем до того, как они попадут в прод.
Код стенда: https://github.com/fonartur/fastapi‑async‑vs‑def‑benchmark [3].
Автор: fonartur
Источник [4]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/python/458254
Ссылки в тексте:
[1] anyio.to: http://anyio.to
[2] thread.run: http://thread.run
[3] https://github.com/fonartur/fastapi‑async‑vs‑def‑benchmark: https://github.com/fonartur/fastapi-async-vs-def-benchmark
[4] Источник: https://habr.com/ru/articles/1083864/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1083864
Нажмите здесь для печати.