Новый взгляд на асинхронность в Python: в лучших традициях gevent, но ещё лучше

fb9ca6f04dcd4bc7db58acd23cf72ab8.jpg

Некоторые уже видели мои статьи про добавление асинхронности в django. Этот пост не об этом: вопрос более широкий и посвящён асинхронности в целом. И подход совсем другой.

Кстати, вопрос с асинхронным django тоже решился — как побочный эффект. Между прочим, собираюсь использовать это в продакшене при первой возможности.

Итак, асинхронность в стиле gevent — что бы это могло быть? Читайте под катом. На картинке — иллюстрация к сказке Киплинга «Слонёнок», слева — Двухцветный Питон, Скалистый Змей.

Сначала немного предисловия. Своего рода источником вдохновения стала sql-алхимия с её странным плагином для асинхронности. Если кто не знает, алхимия использует гринлеты в качестве мостика между синхронным и асинхронным кодом.

У меня даже состоялась небольшая переписка с Майком Байером (автором sql-алхимии), как раз по вопросу использования там гринлетов, где я выразил своё мнение, что, мол, дешёвая штука, никуда не годная, антипаттерн, который просится в учебник. Я готов был представить неопровержимые аргументы, но потом немного подумал… и ещё немного подумал — и решил, что в этом что-то есть.

Что нам даёт хак с гринлетами: он позволяет вызывать асинхронные функции внутри синхронных. Такие скрытые асинхронные функции, получается: вызываются как обычные, а под капотом реализацию имеют асинхронную.

Реализован вышеуказанный хак в репозитории greenhack — мной, по рецептам sql-алхимии.

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

Мы имеем возможность вызывать (скрытые) асинхронные функции как обычные, но функция, в которой мы это делаем, сама должна быть обычной, то есть, объявленной без слова async

Возникает вопрос, зачем нам такие извращения нужны — сейчас вы всё поймёте.

Во-первых, мы получаем своего рода gevent: мы пишем код как синхронный, под капотом же он использует асинхронный ввод-вывод. Причём, интеллигентный gevent: никакого monkey-patching-а, мы сами пишем асинхронную реализацию для нужных функций. Если gevent берёт ваш синхронный код, как он есть, и магически превращает его в асинхронный, то в нашем случае — нет, нужно позаботиться о том, чтобы все наши библиотеки поддерживали асинхронный ввод-вывод и такой способ запуска.

Возьмём, к примеру, django. Достаточно для него написать асинхронный database backend — и вуаля, он становится асинхронным! У меня будет пример с кодом, так что сами увидите.

Во-вторых, мы можем поддержать синхронный и асинхронный ввод-вывод одновременно: это может регулироваться всего одной настройкой. И это — уже преимущество перед традиционным подходом, в asyncio Вы такого не сделаете. Для веб разработки, допустим, асинхронный ввод-вывод — всегда предпочтительней, но что, если у нас какой-нибудь сервис ML, и всё, что он обычно делает — это запускает tensorflow?

Итак, как это всё выглядит на практике: вот пример с кодом. Я решил взять нетривиальную django view и сделать её асинхронной. Покажу сразу то, что получилось, в репозитории это лежит здесь.

from kitchen.models import Order

@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, и большую часть кода для него — тоже.

© Habrahabr.ru