open atlas
↑ К треку
Python для JS/TS-разработчиков PY · 06 · 01

ASGI и FastAPI: контракт scope/receive/send и один блокирующий вызов, который стопорит воркер

ASGI передаёт приложению scope и awaitable receive/send; uvicorn держит один event loop на воркер. async def живёт на этом цикле — один блокирующий вызов стопорит все запросы воркера; обычный def уходит в anyio-тредпул (~40 потоков). Плюс модель воркеров и честные req/s.

PY Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Пятница запуска: команда платежей выкатила крошечную фичу — async def эндпоинт, задававший статусному API партнёра один вопрос через requests.get. Под трафиком запуска p99 партнёра уполз со 180 мс до 6 с, и за минуту весь сервис замер в унисон: чекаут, каталог, даже /health. Один воркер uvicorn — это один процесс с одним event loop, и каждая корутина на нём теперь ждала за единственным синхронным сокетом. Liveness-пробы Kubernetes отвалились по таймауту, перезапустили поды, которые не были сломаны, и шторм рестартов до кучи смыл прогретые пулы соединений. py-spy dump рассказал всю историю одной строкой: единственный поток, припаркованный в socket.recv. Фикс, уехавший в два ночи, состоял из удаления одного ключевого слова — хендлер стал обычным def, чтобы FastAPI вытолкнул вызов в тредпул, — а постмортем задал ровно правильный вопрос: что именно async обещает событийному циклу?

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

Протокол под фреймворком

ASGI (Asynchronous Server Gateway Interface — стандарт взаимодействия между асинхронным сервером и Python-приложением) — не фича фреймворка; это контракт между сервером (uvicorn, hypercorn) и вашим приложением. ASGI-приложение — один асинхронный вызываемый объект с тремя аргументами: словарь scope, описывающий соединение, и два awaitable — receive, чтобы вытягивать события из сервера, и send, чтобы отправлять события обратно. FastAPI, Starlette, Django — на дне все они эта функция:

async def app(scope, receive, send):
    if scope["type"] == "lifespan":          # один раз на процесс: startup/shutdown
        while True:
            event = await receive()
            if event["type"] == "lifespan.startup":
                await send({"type": "lifespan.startup.complete"})
            elif event["type"] == "lifespan.shutdown":
                await send({"type": "lifespan.shutdown.complete"})
                return
    elif scope["type"] == "http":            # один scope на запрос
        await receive()                       # {'type': 'http.request', 'body': b'...'}
        await send({"type": "http.response.start", "status": 200,
                    "headers": [(b"content-type", b"application/json")]})
        await send({"type": "http.response.body", "body": b'{"ok": true}'})

Три типа scope несут на себе весь веб: lifespan (один на процесс — здесь живут запуск и остановка), http (один на запрос) и websocket (один на соединение, с множеством событий receive/send за его жизнь). Всё, что делает FastAPI, — роутинг, валидация, внедрение зависимостей — наслоено поверх этой единственной конвенции вызова; именно поэтому любой ASGI-middleware совместим с любым ASGI-фреймворком: каждый слой — просто приложение, оборачивающее другое приложение.

Один цикл, кооперативное планирование и ступор

uvicorn запускает один event loop на процесс-воркер. Каждый async def хендлер — корутина, запланированная на этом цикле, и цикл переключается между корутинами только в точках await — планирование кооперативное, а не вытесняющее. Это вся модель производительности — и вся модель отказа. Корутина, делающая блокирующий синхронный вызов — requests.get, time.sleep, тяжёлый расчёт в pandas, синхронный драйвер БД, — никогда не доходит до await, и цикл не получает поток обратно. Это не «тот эндпоинт замедлился». Это: каждый запрос в полёте на воркере, каждый эндпоинт, каждый health check останавливаются, пока вызов не вернётся. Блокирующий вызов на 200 мс в горячем хендлере ограничивает весь воркер ~5 req/s; шестисекундный таймаут партнёра сериализует воркер до бесполезности — ровно инцидент из Хука.

Правило диспетчеризации: async def на цикл, def в тредпул

FastAPI решает, где выполнять хендлер, только по его сигнатуре — никакого детектирования блокирующего кода во время выполнения нет:

@app.get("/right-async")
async def right_async():                       # корутина на event loop
    async with httpx.AsyncClient() as client:  # async-библиотека: await отдаёт цикл
        r = await client.get(PARTNER_URL)
    return r.json()

@app.get("/right-def")
def right_def():                               # anyio-тредпул (~40 потоков)
    return legacy_soap_client.fetch()          # блокирующая библиотека, цикл свободен

@app.get("/wrong")
async def wrong():                             # инцидент из Хука в три строки
    return requests.get(PARTNER_URL).json()   # блокирует цикл: стоят ВСЕ запросы

Обычные def хендлеры отправляются в рабочий тредпул anyio с дефолтной ёмкостью 40 токенов на весь процесс. Это защищает цикл от блокирующего кода — и ограничивает блокирующую конкурентность сорока; запрос номер 41 встаёт в очередь за потоком. Неверный выбор стоит денег в обе стороны. async def + блокирующий вызов — катастрофическое направление: один хендлер стопорит воркер. def + работа, которая не блокируется, — расточительное: хендлер, перекладывающий словари 50 мкс чистого CPU, платит за передачу в поток десятки-сотни микросекунд плюс токен из сорока — чистые накладные расходы. Правило честное и короткое: блокирующий I/O — в def (или за run_in_threadpool), асинхронные библиотеки — в async def, а CPU-bound работа не место ни там, ни там — ей дорога в пул процессов или в очередь задач, потому что в потоке она всё равно дерётся за GIL (Global Interpreter Lock — глобальная блокировка интерпретатора, не позволяющая Python-потокам исполнять байткод параллельно).

Викторина

async def эндпоинт вызывает requests.get к API партнёра с p99 около 2 с. Сервис работает на одном воркере uvicorn. Что увидят клиенты ДРУГИХ эндпоинтов?

Воркеры, потоки и честные числа

Единица деплоя — процесс-воркер: uvicorn --workers 4 форкает четыре процесса, у каждого свой event loop, свой тредпул на ~40 потоков и своя память. Ёмкость масштабируется как воркеры × поды; внутри одного воркера асинхронная конкурентность — тысячи припаркованных корутин (спящая корутина стоит килобайты, а не поток), блокирующая — те самые 40 токенов. Честные числа пропускной способности: тривиальное JSON-эхо на одном воркере uvicorn даёт тысячи req/s — порядка 3–9 тыс. на облачном vCPU с uvloop, зависит от машины. Добавьте один запрос к базе на 20 мс — и фреймворк перестаёт иметь значение: пул на 10 соединений с запросами по 20 мс упирается в ~500 req/s, на чём бы вы ни писали хендлер, потому что собственные накладные расходы фреймворка — ~100–300 мкс на запрос, один-два процента одного похода в БД. Бенчмаркайте эхо, потом бенчмаркайте с базой — и считайте воркеры от второго числа.

Викторина

Вы чините эндпоинт из Хука, сделав его обычным def. Приходят 100 одновременных запросов, каждый блокируется ~2 с на вызове партнёра. Что происходит?

Вспомните перед уходом
  1. 01
    Сформулируйте контракт ASGI точно: что за вызываемый объект, какие три типа scope и почему эта форма делает middleware совместимыми?
  2. 02
    Объясните правило диспетчеризации def против async def в FastAPI, режим отказа в каждом неверном направлении и честные числа для сайзинга.
Итог

Под FastAPI лежит один маленький контракт: асинхронный вызываемый объект, получающий словарь scope и два awaitable — receive и send, а scope-типы lifespan, http и websocket несут всё, что делает веб-сервис. uvicorn выполняет этот объект на одном event loop (событийном цикле) на процесс-воркер, и цикл планируется кооперативно — он меняет корутину только на await, что превращает один блокирующий синхронный вызов внутри async def хендлера в аварию всего воркера, а не в медленный эндпоинт: чекаут, каталог и /health замирают за одним припаркованным socket.recv, liveness-пробы отваливаются, и шторм рестартов доделывает дело. Правило диспетчеризации FastAPI механическое: async def — на цикл, обычный def — в anyio-тредпул с его дефолтными 40 токенами, что защищает цикл и ограничивает блокирующую конкурентность сорока, за которыми запросы стоят в очереди. Ошибка стоит денег в обе стороны: блокировка внутри async def останавливает мир; def вокруг микросекундной CPU-работы покупает бессмысленную передачу в поток. Честный конверт: тысячи эхо-req/s на воркер, ~100–300 мкс накладных расходов фреймворка и пул базы данных, который становится настоящим потолком, как только в путь попадает запрос на 20 мс, — так что блокирующий I/O в def, асинхронные библиотеки в async def, CPU — в пул процессов или очередь, а ёмкость — это воркеры × поды, посчитанные от DB-bound бенчмарка, а не от эхо. Теперь, когда встретишь async def хендлер с импортом requests, ты сразу знаешь, что случится с event loop — и что лечение умещается в одно ключевое слово.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.