Некоторые уже видели мои статьи про добавление асинхронности в django. Этот пост не об этом: вопрос более широкий и посвящён асинхронности в целом. И подход совсем другой.
Кстати, вопрос с асинхронным django тоже решился - как побочный эффект. Между прочим, собираюсь использовать это в продакшене при первой возможности.
Итак, асинхронность в стиле gevent - что бы это могло быть? Читайте под катом. На картинке - иллюстрация к сказке Киплинга "Слонёнок".
Сначала немного предисловия. Своего рода источником вдохновения стала sql-алхимия с её странным плагином для асинхронности. Если кто не знает, алхимия использует гринлеты в качестве мостика между синхронным и асинхронным кодом.
У меня даже состоялась небольшая переписка с Майком Байером (автором sql-алхимии), как раз по вопросу использования там гринлетов, где я выразил своё мнение, что, мол, дешёвая штука, никуда не годная, антипаттерн, который просится в учебник. Я готов был представить неопровержимые аргументы, но потом немного подумал... и ещё немного подумал - и решил, что в этом что-то есть.
Что нам даёт хак с гринлетами: он позволяет вызывать асинхронные функции внутри синхронных. Такие скрытые асинхронные функции, получается: вызываются как обычные, а под капотом реализацию имеют асинхронную.
Реализован вышеуказанный хак в репозитории greenhack - мной, по рецептам sql-алхимии.
Для того, чтобы этот хак работал, нужно иметь возможность разделить синхронный и асинхронный код по двум разным гринлетам (функциям). В таком случае, каждый раз, когда мы встречаем функцию с асинхронной реализацией, мы можем переключаться в асинхронный гринлет и вычислять её там. Необязательно всё это сейчас представлять, но можно запомнить простое правило:
Мы имеем возможность вызывать (скрытые) асинхронные функции как обычные, но функция, в которой мы это делаем, сама должна быть обычной, то есть, объявленной без слова async
Возникает вопрос, зачем нам такие извращения нужны - сейчас вы всё поймёте.
Во-первых, мы получаем своего рода gevent: мы пишем код как синхронный, под капотом же он использует асинхронный ввод-вывод. Причём, интеллигентный gevent: никакого monkey-patching-а, мы сами пишем асинхронную реализацию для нужных функций. Если gevent берёт ваш синхронный код, как он есть, и магически превращает его в асинхронный, то в нашем случае - нет, нужно позаботиться о том, чтобы все наши библиотеки поддерживали асинхронный ввод-вывод и такой способ запуска.
Возьмём, к примеру, django. Достаточно для него написать асинхронный database backend - и вуаля, он становится асинхронным! У меня будет пример с кодом, так что сами увидите.
Во-вторых, мы можем поддержать синхронный и асинхронный ввод-вывод одновременно: это может регулироваться всего одной настройкой. И это - уже преимущество перед традиционным подходом, в asyncio Вы такого не сделаете. Для веб разработки, допустим, асинхронный ввод-вывод всегда предпочтительней, но что, если у нас какой-нибудь сервис ML, и всё, что он обычно делает - это запускает tensorflow?
Итак, как это всё выглядит на практике: вот пример с кодом. Я решил взять нетривиальную django view и сделать её асинхронной. Покажу сразу то, что получилось, в репозитории это лежит здесь.
@as_async
def food_delivery(request):
order: Order = prepare_order(request)
order.save()
resp = myhttpx.post(settings.KITCHEN_SERVICE, data=order.as_dict())
match resp.status_code, resp.json():
case 201, {"mins": _mins} as when:
if consumer := ws.consumers.get(request.user.username):
consumer.send_json(when)
return JsonResponse(when)
case _:
kitchen_error(resp)
Итак, что мы здесь имеем? Order - это модель django. С ней мы можем обращаться, как рекомендует документация. Драйвер базы данных psycopg - асинхронный. Почему это возможно? Потому что мы используем асинхронный database backend.
Дальше мы обращаемся к сервису кухни. Http-клиент, как Вы догадались, тоже асинхронный. myhttpx - это обёртка вокруг httpx.
После всего - шлём нотификацию по вебсокету. Здесь уже каждый грамотный читатель знает, что операция асинхронная. Используем channels для этого, потому что вебсокеты почему-то до сих пор не добавили в django.
Сам сервис запускаем через uvicorn. В manage.py добавили такую интересную строчку:
import greenhack
greenhack.start_loop()
В результате мы можем пользоваться всеми утилитами командной строки из django, при этом драйвер базы данных - асинхронный. Разве не магия?
Поскольку итак очевидно, что это идеальное решение для асинхронных веб-сервисов, напишу про недостатки. У нас могут быть некоторые трудности с отладкой и профилировкой. Код получается разбит между синхронным и асинхронным гринлетом - соответственно, стек вызовов при отладке тоже показывается не весь. Несложно, конечно, напечатать правильный стек вызовов - но по умолчанию показывается другой. Автор sqlalchemy пишет, что профилировка такого кода вызывает сложности.
С другой стороны, асинхронный код отлично исполняется в консоли при отладке, не нужен для этого вложенный event loop и nest_asyncio (как в стандартном asyncio).
Вместо заключения: на мой взгляд, то, что я описал - годный подход к поддержке асинхронности в принципе. У него есть объективное преимущество перед традиционным подходом - это возможность одновременной поддержки синхронного и асинхронного I/O. Он позволяет использовать существующие библиотеки, вроде django. Последние используют асинхронный ввод-вывод и даже не всегда знают об этом.
Собственно, началось всё с того, что я решил портировать код django в асинхронный. Мало того, что эта задача решена, библиотеки более верхнего уровня, вроде django-rest-framework и других, тоже работают без всяких модификаций. Сравните это с текущим подходом - DEP-09. Его смело можно признавать deprecated, и большую часть кода для него - тоже.
За обновлениями следите на гитхабе.
Комментарии (11)
splatt
21.10.2022 21:35Когда в Python 3.4 релизнули нативный asyncio, я надеялся, что вот-вот и весь этот кошмар с monkey.patch_all() уйдет в прошлое.
Но вот прошло 8 лет, а воз и ныне там. Большинство крупных проектов все еще либо на Django, либо Flask + SQLAlchemy и нормальной поддержкой асинхронности все еще не пахнет. Продакшн связка gevent + gunicorn все еще нестабильна, хотя все делают вид, что это работает.
Остается выбор, либо куча воркеров (дорого), либо нестандартные связки типа nginx + gevent.pywsgi + monkey.patch_all который "вроде как работает", но никто не знает и в какой момент выбранная вами библиотека перестанет работать с gevent и внезапно начнет вам блокировать основной поток.
Готов услышать альтернативные мнения, но сегодня я бы не стал использовать Python в качестве языка для серьезных проектов.
Murtagy
22.10.2022 03:02+2Ну, есть же fastapi, twisted и другие решения.
У нас на twisted + klein приложение обрабатывает миллионы запросов в сутки, не вижу особых мотиваторов на что-то переезжать.Murtagy
22.10.2022 03:08Питон в продакшене у огромных контор и вполне себе работает. О серьезных проектах на flask или других threaded-wsgi в прочем сейчас уже речь не идет. Либо django для батерек из коробки, либо что-то асинхронное - fastapi, starlite и тд.
Все асинхронные либы используем сразу, синхронные пихаем в тредпул.splatt
22.10.2022 03:49Что вы используете/советуете для ORM? Последний раз когда я смотрел (пару лет назад), адекватных замен SQLAlchemy или Django ORM, да что бы еще и с полноценной поддержкой asyncio просто не было.
Например, у нас в проекте 120+ таблиц/моделей SQLAlchemy, огромное количество связей между ними, кэширования, joinedload/noload, lazy, итд. Создавать и поддерживать такой функционал без полноценной ORM это ад.
Можно поподробнее про тредпул? Каким образом вы это делаете и как это решает концептуальную проблему с GIL?
mayorovp
22.10.2022 10:22+1GIL является же проблемой для CPU-bound потоков, но для IO-bound оно никогда проблемой не было, ни концептуальной, ни какой-то ещё
mariner
22.10.2022 10:51Есть ещё Tortoise ORM асинхронный клон Django ORM. Ormar - интересная надстройка над алхимией.
Murtagy
22.10.2022 21:18Рекомендаций по ORM не дам - у нас просто асинхронный коннектор к БД и круды. Необходимости делать множественные джойны отсутствует, так как основной объект имеет часть инфы как binary-json (postgres). Часть реляционной схемы съедена, что имеет свои минусы и плюсы.
Год назад проверял по алхимии и она было только частично async, сейчас вроде они полностью мигрировали. Есть асинхронные ORM вроде tortoise, не пользовался по серьезному.
Тредпул это просто имитация асинхронности для блокирующего кода. Условно у нас есть эндпоинт который читает с базы и допустим делает запрос куда-нибудь, функция которая делает запрос уже написана и использует requests - блокирующую либу. Мы делаем threads.deferToThread (это в twisted, в асинкио есть аналоги) и задачка помещается в очередь на выполнение тредпулом. Запрос это IO задача, то есть поток сразу уходит спать пока не получит ответ. Проблема с GIL может быть только для CPU-bound задач. Возможно потому что ORM у нас нет - проблем с загрузкой процессора тоже нет
whoisking
21.10.2022 22:05+1библиотеки более верхнего уровня, вроде django-rest-framework и других, тоже работают без всяких модификаций
Разве можно асинхронный queryset обернуть в Response из DRF? Или я что-то не так понял?)
sbars
Самый интересный вопрос - как обстоит дело с обработкой исключений?
abetkin Автор
Исключения всплывают). Эксепшн, возникший в асинхронной функции, например в httpx клиенте, можно поймать в синхронной вьюшке - попробуйте сами)