Доброе утро, уважаемый читатель. Сегодня мы разоблачаем господина Гудвина. В частности, обсуждаем 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 и вебсокеты в один протокол — какой в этом смысл, непонятно. Почему это не могли быть разные протоколы, оба поддерживаемые сервером приложений?
Теперь по поводу вебсокетов. Вот как выглядит приложение 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.
Статья и так получилась большая — больше объяснять особенно ничего не буду. Моя задача была — так сказать, дать читателю направление для мысли. И убедить его, что «пространство для манёвра» — есть и даже более чем.
В предыдущих статьях я писал, что у меня есть проект fibers — «нативная» асинхронность в django при помощи гринлетов. Я теперь не знаю, буду ли я его развивать — возможно, что нет. Этот подход уже применили в sql-алхимии — зачем делать то же самое? Пусть django ищет свои пути решения вопроса, и пусть они будут лучше, чем то, что мы видим сейчас. Хотя, конечно, верится с трудом.
ссылка на оригинал статьи https://habr.com/ru/post/712644/
Добавить комментарий