Асинхронный django: разоблачение Великого и Ужасного

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Доброе утро, уважаемый читатель. Сегодня мы разоблачаем господина Гудвина. В частности, обсуждаем DEP-9 - roadmap по добавлению асинхронности в django за его авторством.

Мы с вами будем обсуждать только ту часть, которая явно включена в DEP-9. Это значит, что ввод-вывод при работе с базой данных остаётся блокирующий (то есть, мы используем, скажем, psycopg2, а не asyncpg), но, при этом, поддерживаются новые юзкейсы, недоступные обычному WSGI-приложению - вебсокеты и запросы на сторонние сервисы (последние доступны, но неэффективны).

В предыдущей статье я написал, что запрос на сторонний сервис - одна из основных проблем для WSGI-приложений. Потому что он достаточно быстрый - это значит, мы можем попросить пользователя подождать, но достаточно медленный, чтобы мы могли позволить одному из потоков простаивать в это время.

Господин Гудвин решил, что лучше будет решить эту проблему, запуская блокирующие и асинхронные функции в разных потоках - автор думает так же. Но дьявол, как говорится - в деталях, и что касается последних, вариант мистера Гудвина мне совсем не нравится. В чём он заключается? Внутри асихронной функции мы используем тредпул из потоков для выполнения блокирующих участков. Если наша вьюшка-контроллер - обычная функция (блокирующая), мы её тоже оборачиваем в асинхронную - для универсальности. С виду - просто и вполне разумно.

На самом деле - нет. Я считаю, такой подход с самого начала обречён на провал: ясно, что это не может быть долгосрочным решением. Смотрите: у нас вьюшки-контроллеры могут быть как чисто асинхронными, так и содержащими блокирующие части. Какой middleware для них больше подойдёт? Конечно, асинхронный - ведь он подходит для обоих случаев. Все части веб-фреймворка, которые хоть как-то имеют дело со вводом-выводом, будут асинхронными - по сути, весь веб-фреймфорк будет асинхронным. Блокирующим будет только "пользовательский" код - во вьюшках-контроллерах. Понятно, что разделение кода на библиотечный и пользовательский - достаточно условно, поэтому блокирующего кода будет становиться всё меньше, предпочтение же будет отдаваться "нативному" асинхронному коду.

Какой напрашивается из этого вывод? Если наш вариант - это блокирующий ввод-вывод, мы должны сделать блокирующие вьюшки first-class, так сказать - основным вариантом. Аналогично - чтобы блокирующее middleware было first-class, и так далее. Как это сделать? Если вьюшка - обычная (блокирующая) функция, тут всё понятно. А если она содержит асинхронные операции, например, запрос на сторонний сервис? В прошлой статье я приводил такой пример:

def myview(request):
    # blocking code
    ...
    
    @async_to_sync
    async def http_request():
       async with httpx.AsyncClient() as client:
            response = await client.get(url)
            ...
    
    http_request()
    
    #blocking code
    ...

В прошлой статье я использовал другой синтаксис, но пока обойдёмся без него (если интересно, пример с кодом - единственный в моей предыдущей статье). Функция в середине, http_request, выполняется в другом потоке. Всё то же самое, только наоборот: теперь оборачиваем асинхронную функцию. В результате, мы можем сказать, что блокирующие вьюшки - first-class: они либо целиком блокирующие, либо начинаются и заканчиваются блокирующей частью. Учитывая последнее обстоятельство, middleware для них логично иметь тоже блокирующее. Всё как мы хотели.

Почему же такое простое соображение не пришло в голову мистеру Гудвину? Дело в том, что встаёт вопрос, как деплоить последнюю вьюшку: несмотря на то, что она начинается и заканчивается блокирующей частью, она больше не следует стандарту WSGI. Зато мистеру Гудвину наверняка пришли в голову другие соображения: если мы хотим иметь вебсокеты, то нам нужен асинхронный сервер. Поскольку мистер Гудвин - разработчик на питоне, он будет использовать для такого сервера event loop и asyncio. А если так, что мы получаем? Сервер приложений - асинхронный, сами приложения содержат блокирующие и асинхронные части вперемешку. Если это объединить, что мы получим? То, что получилось у мистера Гудвина - асинхронную функцию с блокирующими участками внутри.

У господина Гудвина асинхронный сервер был на twisted, назывался daphne. Потом появился сишный nginx unit - он также стал поддерживать ASGI приложения. В Ruby есть AnyCable, где "внешний" сервер - на Golang (правда, там микросервисы - фу!) В общем, я веду к тому, что сервер приложений может быть реализован как угодно, это не должно влиять на интерфейс приложения. У мистера Гудвина же - увы, особенности реализации явно повлияли.

Но - довольно критики, в нашем деле главное - конструктивный подход. Хорошо, сервер приложений может быть произвольным, каким же должен быть интерфейс приложения? WSGI, как я уже говорил, не годится для асинхронных сценариев. Асинхронных - я имею в виду, в широком смысле: когда мы заранее не знаем, в какой момент что-нибудь завершится и ждём от него callback.

Стандарт WSGI не основан на колбэках, к сожалению. Кстати, насколько хорошо вы знаете WSGI? Если хотите узнать лучше, то вот вам хорошая ссылка. Официальная документация по WSGI - не очень, поэтому в ней прямо указан список ссылок, где об этом можно ещё почитать (но моей ссылки там нет!)

WSGI устроен достаточно просто: информация о запросе хранится в переменной со странным названием environ, а функция-обработчик запроса возвращает итератор по телу запроса. Если ответ содержит attachment, он разбивается на чанки и становится частью тела ответа. Файлы на upload представляют собой file-like интерфейс, которые можно прочитать из environ['wsgi.input']. Кроме этого, WSGI-сервер предоставляет колбэк start_response, который мы вызываем, передав статус ответа и хедеры.

Так, один колбэк уже нашли - это start_response - не совсем тот, который нужен, конечно. А что, если бы рядом с ним ещё были колбэки middle_response и end_response - вместо итератора (только, конечно, с нормальными названиями)? Передавали бы мы чанки из байт в эти колбэки - содержимое ответа. Всё - проблема решена! И не нужен весь этот бред сумасшедшего с ASGI и его форматом сообщений. Зачем-то ещё объединили HTTP и вебсокеты в один протокол - какой в этом смысл, непонятно. Почему это не могли быть разные протоколы, оба поддерживаемые сервером приложений?

Теперь по поводу вебсокетов. Вот как выглядит приложение ASGI (из документации):

async def application(scope, receive, send):
    event = await receive()
    ...
    await send({"type": "websocket.send", "body": ...})

receive и send - это корутины. Опять же, встаёт вопрос - почему не сделать их блокирующими функциями? Асинхронные - тоже можно предоставить, но блокирующие должны быть first-class! Вообще, я думаю, лучше себе представлять, что у нас - сишный сервер приложений, и ему всё равно, какой интерфейс для питона предоставлять, в виде ли блокирующих или асинхронных функций.

Но есть ещё один интерфейс, который ещё лучше, чем блокирующие функции (это будет advanced секция - уважаемые джуны, даже не пытайтесь въехать).Он связан с моим чудо-синтаксисом, давайте, я его напомню. Используя его, первый пример с кодом можно записать так:

async def myview(request):
    # blocking code
    ...
    
    async with io:
       async with httpx.AsyncClient() as client:
            response = await client.get(url)
            ...

    #blocking code
    ...

Это код означает то же самое, что и первый пример с кодом. myview - это обычный (блокирующий) генератор.(Асинхронный) контекстный менеджер io делит генератор на 3 части: до него, внутри него и после него. Все 3 части можно выполнять в разных потоках.

Наверно, вы плохо себе представляете, что делать с таким генератором - как его выполнять. По частям: допустим, предыдущая часть генератора была асинхронной - значит, следующая будет блокирующей. Находим подходящий поток для неё - блокирующий, запускаем в нём что-то вроде gen.send(None). Ура, мы продвинулись на один шаг! Повторяем такой цикл, пока не закончится генератор. Если встречаем асинхронную секцию - там чуть сложнее: в асинхронном потоке запускаем корутину, которая будет обёрткой вокруг gen.send. Но это уже детали.

О том, как это работает, можно узнать в моей новогодней статье. Если в двух словах - пользуясь тем, что корутины - это обычные генераторы, мы иногда делаем yield специальных значений, которые и обрабатываем специальным образом. Главное - не забыть сделать внешнюю обёртку-корутину, которая не пропустит эти значения в event loop.

Теперь, вернёмся к нашим баранам и вспомним, что функции send и receive предоставляет ASGI-сервер. Они могут быть какими угодно! В том числе, могут делать yield специальных значений (в рамках вышеописанной магии). Внешне они могут выглядеть обычными корутинами:

async def myview():
    ...
    await receive()
    ...

В действительности же, эта строчка будет просто делить генератор на 2 части, предоставляя возможность серверу приложений обработать эту ситуацию максимально удобным для него образом.

(Advanced-часть заканчивается) Таким образом, мы как бы делаем экстеншен в механизме корутин и пользуемся им в своих целях (закончилась!)

Если обобщать вышесказанное, то asyncio и event loop, похоже, нигде бы и не фигурировали в моей версии сервера приложений. Да, у вьюшки-контроллера могут быть асинхронные части, которые нужно выполнить в асинхронном потоке. Но для того, чтобы это сделать, и стандарт никакой не нужен. Так что, в моём варианте, и упоминания ни про какой asyncio бы не было. И это нормально: ведь у нас блокирующий ввод-вывод в django.

Статья и так получилась большая - больше объяснять особенно ничего не буду. Моя задача была - так сказать, дать читателю направление для мысли. И убедить его, что "пространство для манёвра" - есть и даже более чем.

Я обещал немного рассказать о FastAPI - рассказываю: я им пользовался - он удобный. Видно, что подумали о мелочах. Забавным местом, где я однажды встрял и долго не мог нагуглить ответ было, когда нужно было передать ... в качестве параметра в Dependency Injection. Я не мог поверить, что нужно так и писать: ...

Вообще, я обычно нормально обхожусь без Dependency Injection. И без типов, в принципе, тоже. Зато в питон-сообществе теперь есть то, о чём написано в Zen of Python:

There should be one - and preferably only one - obvious way to do it.

Он теперь есть - по крайней мере, что касается веб-разработки - это FastAPI и sqlalchemy. Есть одна вещь, которая мне точно нравится в FastAPI - это что в роут в роутере - это url + http метод:

@app.post("/complaints")
async def complain():
    pass

Вьюшка, как вы можете видеть, только для метода post, а для get может быть другая. В django вьюшка - одна на все http методы. Уже несколько раз открывали на это issue - раз в 10 лет, примерно. И каждый раз закрывали с пометкой wontfix - якобы, нет консенсуса среди разработчиков. Во flask тоже с этим промахнулись. В Pyramid - сделали как нужно. В aiohttp - сделали как нужно.

В предыдущих статьях я писал, что у меня есть проект fibers - "нативная" асинхронность в django при помощи гринлетов. Я теперь не знаю, буду ли я его развивать - возможно, что нет. Этот подход уже применили в sql-алхимии - зачем делать то же самое? Пусть django ищет свои пути решения вопроса, и пусть они будут лучше, чем то, что мы видим сейчас. Хотя, конечно, верится с трудом.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Опрос
0% Согласен 0
50% Согласен в части, что FastAPI — хороший фреймворк 1
50% Автор что-то курит 1
Проголосовали 2 пользователя. Воздержавшихся нет.
Источник: https://habr.com/ru/post/712644/


Интересные статьи

Интересные статьи

Настройка любой площадки для CMS — это рутинный процесс, который должен быть доведен до автоматизма в каждой уважающей себя компании. А потому частенько воспринимается, как восход солнца — это происхо...
Появившиеся в 2006 году сервисы Google по работе с текстовыми документами (Google Docs) и таблицами (Google Sheets), дополненные 6 лет спустя возможностями работы с вирту...
Много всякого сыпется в мой ящик, в том числе и от Битрикса (справедливости ради стоит отметить, что я когда-то регистрировался на их сайте). Но вот мне надоели эти письма и я решил отписатьс...
Борис Цирлин и Александр Кушнеров 30.10.2019 Для опытного разработчика схем не составляет большого труда узнать знакомую схему, в каком бы виде она не была нарисована. В этой статье мы покажем,...
Практически все коммерческие интернет-ресурсы создаются на уникальных платформах соответствующего типа. Среди них наибольшее распространение получил Битрикс24.