Асинхронный django: разоблачение Великого и Ужасного
- четверг, 26 января 2023 г. в 00:48:23
Доброе утро, уважаемый читатель. Сегодня мы разоблачаем господина Гудвина. В частности, обсуждаем 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 make_http_request():
async with httpx.AsyncClient() as client:
response = await client.get(url)
...
make_http_request()
#blocking code
...
В прошлой статье я использовал другой синтаксис, но пока обойдёмся без него (если интересно, пример с кодом - единственный в моей предыдущей статье). Функция в середине, make_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 и вебсокеты в один протокол - какой в этом смысл, непонятно. Почему это не могли быть 2 разных протокола, оба поддерживаемые сервером приложений?
Теперь по поводу вебсокетов. Вот как выглядит приложение 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 - и каждый раз закрывали с пометкой wontfix. Во flask тоже с этим промахнулись. В Pyramid - сделали как нужно. В aiohttp - сделали как нужно.
В предыдущих статьях я писал, что у меня есть проект fibers - "нативная" асинхронность в django при помощи гринлетов. Я теперь не знаю, буду ли я его развивать - возможно, что нет. Этот подход уже применили в sql-алхимии - зачем делать то же самое? Пусть django ищет свои пути решения вопроса, и пусть они будут лучше, чем то, что мы видим сейчас. Хотя, конечно, верится с трудом.