Новый взгляд на асинхронность в Python: в лучших традициях gevent, но ещё лучше
- суббота, 22 октября 2022 г. в 00:47:47
Некоторые уже видели мои статьи про добавление асинхронности в 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, и большую часть кода для него - тоже.