async def против def: когда какой
Урок объясняет, в чём практическая разница между async def- и def-обработчиками во FastAPI и как не положить весь сервер одной строкой.
Правило большого пальца — если внутри роута есть
await(асинхронные библиотеки), пишитеasync def; если работа синхронная и блокирующая, пишите обычныйdef, и FastAPI сам уведёт её в пул потоков.
FastAPI разрешает объявлять обработчик двумя способами: async def endpoint(...) и обычным def endpoint(...). Выбор не косметический — от него зависит, в каком контексте выполнится ваш код и не заблокирует ли он остальные запросы. Это самая частая причина, по которой «быстрый асинхронный фреймворк» внезапно начинает отвечать по секунде на каждый запрос.
Корень в том, что FastAPI построен поверх ASGI и крутится в одном event loop (событийном цикле). Этот цикл — однопоточный: пока он выполняет ваш код, он не может обслуживать никого другого. Поэтому всё зависит от того, отдаёте ли вы управление циклу через await или держите поток мёртвой хваткой.
Зачем это на практике
Представьте API, где один эндпоинт ходит в внешний сервис по HTTP. Если вы используете асинхронный клиент (httpx.AsyncClient) и пишете async def + await, то на время сетевого ожидания цикл свободен и принимает другие запросы — сервер держит тысячи соединений. Если же вы в том же async def вызовете синхронный requests.get(...), поток встанет на всё время запроса, и никто другой в этот момент не обслуживается. Один медленный вызов тормозит весь процесс.
Понимание этой развилки — разница между сервером, который масштабируется, и сервером, который «почему-то тормозит под нагрузкой, хотя код асинхронный».
Как FastAPI запускает sync-роуты
Ключевая деталь, которую многие не знают: обычный def-обработчик FastAPI запускает не в event loop, а в отдельном потоке из пула (threadpool на базе anyio, по умолчанию до 40 потоков). Цикл при этом остаётся свободен. Поэтому синхронный блокирующий код в def-роуте безопасен: он блокирует свой поток из пула, но не главный цикл.
А вот async def-обработчик выполняется прямо в event loop. Здесь любой блокирующий вызов (синхронный сетевой запрос, тяжёлый CPU-цикл, time.sleep, синхронный драйвер БД) останавливает цикл целиком.
| Объявление | Где исполняется | Когда выбирать |
async def | в event loop | внутри есть await асинхронных библиотек (httpx, asyncpg, aioredis) |
def | в потоке из threadpool | работа синхронная: requests, синхронный драйвер БД, тяжёлые вычисления, файлы |
Вот как это выглядит в коде. Этот пример импортирует FastAPI, поэтому в браузерном раннере он не исполним — это блок для чтения (кнопки «Запустить» у него нет).
from fastapi import FastAPI
import httpx # асинхронный клиент
import requests # синхронный клиент
app = FastAPI()
# ХОРОШО: async def + await асинхронного клиента -> цикл свободен на время сети
@app.get("/async-ok")
async def async_ok():
async with httpx.AsyncClient() as client:
r = await client.get("https://example.com")
return {"status": r.status_code}
# ХОРОШО: обычный def с синхронным клиентом -> уйдёт в threadpool, цикл свободен
@app.get("/sync-ok")
def sync_ok():
r = requests.get("https://example.com")
return {"status": r.status_code}
# КАТАСТРОФА: блокирующий requests внутри async def -> стопорит весь event loop
@app.get("/async-bad")
async def async_bad():
r = requests.get("https://example.com") # блокирует цикл на всё время запроса
return {"status": r.status_code}
Запомните пару async-ok и sync-ok как два корректных мира: либо «всё асинхронно через await», либо «всё синхронно, но в обычном def». Смешивать их — путь к async-bad.
Почему блокирующий код в async — катастрофа
Чтобы прочувствовать эффект, смоделируем event loop на чистом asyncio (это уже исполнимый Python — стандартная библиотека, без FastAPI). Корутина heartbeat печатает «тук» — это аналог «сервер обслуживает другие запросы». А blocker заходит в синхронный цикл и не отдаёт управление через await — это и есть блокирующий вызов внутри корутины, как синхронный requests.get в async def. Следите за порядком строк.
import asyncio
events = []
async def heartbeat():
for i in range(4):
events.append(f"тук {i}")
await asyncio.sleep(0.01)
async def blocker():
await asyncio.sleep(0.005) # стартуем сразу после первого тука
events.append(">>> вошёл в блокирующий участок")
total = 0
for _ in range(2_000_000): # синхронный цикл: НЕ отдаём управление
total += 1
events.append(">>> вышел из блокирующего участка")
async def main():
await asyncio.gather(heartbeat(), blocker())
print("\n".join(events))
asyncio.run(main())
Вывод:
тук 0 >>> вошёл в блокирующий участок >>> вышел из блокирующего участка тук 1 тук 2 тук 3
Смотрите на порядок: после «туку 0» сервер замолкает на всё время синхронного цикла. Туки 1, 2 и 3 печатаются только после того, как блокирующий участок завершился — хотя их sleep давно «истёк». Цикл не мог обслуживать heartbeat, пока blocker крутил синхронный цикл и не возвращал управление. Ровно это происходит с вашими запросами, когда один async def блокируется: остальные ждут в очереди.
run_in_threadpool: спасение для async-роутов
Иногда вы внутри async def вынуждены вызвать синхронную функцию (легаси-библиотека, синхронный SDK). Превратить роут в def нельзя — рядом есть await. Решение: увести блокирующий вызов в поток через run_in_threadpool (FastAPI) или asyncio.to_thread. Тогда цикл на время вызова свободен.
from fastapi import FastAPI
from fastapi.concurrency import run_in_threadpool
import requests
app = FastAPI()
@app.get("/mixed")
async def mixed(q: str):
data = await fetch_async(q) # асинхронная часть
# синхронный requests НЕ зовём напрямую — уводим в поток:
extra = await run_in_threadpool(requests.get, "https://example.com")
return {"data": data, "extra": extra.status_code}
Смоделируем эффект на стандартном asyncio: to_thread — это тот же приём, что и run_in_threadpool. Блокирующий blocking_cpu уезжает в поток, а heartbeat продолжает тикать — все его «туки» проходят, пока тяжёлый вызов считается в потоке.
import asyncio
events = []
def blocking_cpu(): # «тяжёлый» синхронный вызов, увезённый в поток
total = 0
for _ in range(20_000_000):
total += 1
return 42
async def heartbeat():
for i in range(4):
events.append(f"тук {i}")
await asyncio.sleep(0.01)
async def main():
task = asyncio.create_task(heartbeat())
result = await asyncio.to_thread(blocking_cpu) # увели блокировку в поток
events.append(f">>> результат={result}")
await task
print("\n".join(events))
asyncio.run(main())
Вывод:
тук 0 тук 1 тук 2 тук 3 >>> результат=42
Теперь все «туки» проходят свободно, пока блокирующий вызов считается — цикл не встал. Сравните с предыдущим выводом, где после «туку 0» туки 1–3 застряли и появились лишь по завершении блокирующего участка. Это и есть разница между блокировкой цикла и аккуратным уводом работы в поток.
Как это работает под капотом
FastAPI смотрит на сигнатуру обработчика. Если это корутинная функция (async def), он await-ит её прямо в цикле. Если обычная def, он оборачивает её в run_in_threadpool и отправляет в пул потоков anyio. Тот же механизм применяется к зависимостям (Depends): синхронные зависимости тоже уезжают в threadpool, асинхронные исполняются в цикле.
Размер пула ограничен (по умолчанию около 40 одновременных потоков). Это значит: если у вас 100 одновременных запросов к def-роуту с медленным синхронным вызовом, первые ~40 займут потоки, а остальные встанут в очередь на свободный поток. Поэтому для по-настоящему высоконагруженных сетевых вызовов асинхронные библиотеки масштабируются лучше пула потоков — корутин в одном цикле может быть десятки тысяч.
Частые ошибки
Вызывать синхронный requests/синхронный драйвер БД внутри async def. Это блокирует цикл на всё время вызова. Либо берите асинхронный клиент и await, либо сделайте роут обычным def, либо оберните вызов в run_in_threadpool.
Писать async def «на всякий случай», а внутри — только синхронный код. Тогда вы лишаетесь автоматического threadpool: синхронная работа крутится в цикле и тормозит всех. Если await не нужен — пишите def.
Тяжёлые CPU-вычисления в async def. Числодробилка не отдаёт управление через await, она просто занимает поток. В цикле это заморозит сервер; даже в threadpool из-за GIL чистый Python-CPU плохо параллелится — такие задачи выносят в отдельные процессы (см. урок про фоновые задачи и Celery).
Забыть await перед асинхронным вызовом. Без await вы получите не результат, а объект корутины, и операция просто не выполнится — типичный «тихий» баг.
Итоги
async defисполняется в event loop;defFastAPI уводит в пул потоков (~40) — оба варианта корректны, если применять к месту.- Блокирующий синхронный код (requests, time.sleep, синхронный драйвер, CPU-цикл) в
async defстопорит весь цикл и тормозит все запросы. - Есть
awaitасинхронных библиотек — пишитеasync def; работа целиком синхронная — пишите обычныйdef. - Нужно вызвать синхронную функцию из
async def— уводите её в поток черезrun_in_threadpool/asyncio.to_thread. - Пул потоков ограничен по размеру, поэтому для массовых сетевых вызовов асинхронные клиенты масштабируются лучше синхронных в threadpool.