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; def FastAPI уводит в пул потоков (~40) — оба варианта корректны, если применять к месту.
  • Блокирующий синхронный код (requests, time.sleep, синхронный драйвер, CPU-цикл) в async def стопорит весь цикл и тормозит все запросы.
  • Есть await асинхронных библиотек — пишите async def; работа целиком синхронная — пишите обычный def.
  • Нужно вызвать синхронную функцию из async def — уводите её в поток через run_in_threadpool / asyncio.to_thread.
  • Пул потоков ограничен по размеру, поэтому для массовых сетевых вызовов асинхронные клиенты масштабируются лучше синхронных в threadpool.
Проверьте себя
1. Где FastAPI выполняет обработчик, объявленный обычным def (а не async def)?
AПрямо в event loop, как и async def
BВ отдельном потоке из пула (threadpool), оставляя event loop свободным
CВ отдельном процессе на другом ядре
DНе выполняет вовсе — требуется async def
2. Почему вызвать синхронный requests.get внутри async def — плохая идея?
Arequests несовместим с FastAPI и выбросит исключение
BКод async def исполняется в event loop, и блокирующий requests остановит цикл, заморозив все остальные запросы
CЭто работает медленнее ровно в 2 раза, но безопасно
DFastAPI автоматически уведёт requests в threadpool, так что разницы нет
3. Внутри async def нужно вызвать синхронную легаси-функцию. Как сделать это правильно?
AПросто вызвать её — внутри async def всё уже асинхронно
BОбернуть вызов в run_in_threadpool / asyncio.to_thread, чтобы он ушёл в поток и не блокировал цикл
CДобавить перед ней await
DПереписать функцию на async def, не меняя её тело