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

В FastAPI можно объявить обработчик как async def, а можно как обычный def. Многие ставят async везде «потому что так быстрее» и спокойно вызывают внутри requests.get() или синхронный драйвер БД. Сервис при этом работает, тесты проходят, а на первой же реальной нагрузке всё встаёт колом, причём вместе с health‑check'ом.
В статье разберу, что на самом деле происходит с каждым вариантом, покажу замеры и дам короткую шпаргалку, какой вариант выбирать.
Как FastAPI выполняет обработчики
Правило одно, но из него следует всё остальное.
async defвыполняется прямо в event loop. Пока корутина не дошла доawait, никто другой в этом процессе не работает: ни другие запросы, ни/health.defFastAPI сам отправляет в пул потоков через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 |
|---|---|---|---|---|
| 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 дают разумный результат.
Замер 2: что происходит с health‑check
Теперь запустим 50 медленных запросов и, пока они выполняются, дёрнем самую простую ручку:
@app.get("/health")
async def health():
return {"status": "ok"}Фоновая нагрузка (50 запросов) | Время ответа |
|---|---|
| 10 193 мс |
| 31 мс |
| 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 |
|
|
|---|---|---|---|---|
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.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.