Некоторые уже видели мои статьи про добавление асинхронности в 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)


  1. sbars
    21.10.2022 19:31

    Самый интересный вопрос - как обстоит дело с обработкой исключений?


    1. abetkin Автор
      21.10.2022 22:04

      Исключения всплывают). Эксепшн, возникший в асинхронной функции, например в httpx клиенте, можно поймать в синхронной вьюшке - попробуйте сами)


  1. 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 в качестве языка для серьезных проектов.


    1. Murtagy
      22.10.2022 03:02
      +2

      Ну, есть же fastapi, twisted и другие решения.
      У нас на twisted + klein приложение обрабатывает миллионы запросов в сутки, не вижу особых мотиваторов на что-то переезжать.


      1. Murtagy
        22.10.2022 03:08

        Питон в продакшене у огромных контор и вполне себе работает. О серьезных проектах на flask или других threaded-wsgi в прочем сейчас уже речь не идет. Либо django для батерек из коробки, либо что-то асинхронное - fastapi, starlite и тд.
        Все асинхронные либы используем сразу, синхронные пихаем в тредпул.


        1. splatt
          22.10.2022 03:49

          Что вы используете/советуете для ORM? Последний раз когда я смотрел (пару лет назад), адекватных замен SQLAlchemy или Django ORM, да что бы еще и с полноценной поддержкой asyncio просто не было.

          Например, у нас в проекте 120+ таблиц/моделей SQLAlchemy, огромное количество связей между ними, кэширования, joinedload/noload, lazy, итд. Создавать и поддерживать такой функционал без полноценной ORM это ад.

          Можно поподробнее про тредпул? Каким образом вы это делаете и как это решает концептуальную проблему с GIL?


          1. mayorovp
            22.10.2022 10:22
            +1

            GIL является же проблемой для CPU-bound потоков, но для IO-bound оно никогда проблемой не было, ни концептуальной, ни какой-то ещё


          1. mariner
            22.10.2022 10:50

            Алхимия асинхронная с 1.4 работает хорошо.


          1. mariner
            22.10.2022 10:51

            Есть ещё Tortoise ORM асинхронный клон Django ORM. Ormar - интересная надстройка над алхимией.


          1. Murtagy
            22.10.2022 21:18

            Рекомендаций по ORM не дам - у нас просто асинхронный коннектор к БД и круды. Необходимости делать множественные джойны отсутствует, так как основной объект имеет часть инфы как binary-json (postgres). Часть реляционной схемы съедена, что имеет свои минусы и плюсы.
            Год назад проверял по алхимии и она было только частично async, сейчас вроде они полностью мигрировали. Есть асинхронные ORM вроде tortoise, не пользовался по серьезному.
            Тредпул это просто имитация асинхронности для блокирующего кода. Условно у нас есть эндпоинт который читает с базы и допустим делает запрос куда-нибудь, функция которая делает запрос уже написана и использует requests - блокирующую либу. Мы делаем threads.deferToThread (это в twisted, в асинкио есть аналоги) и задачка помещается в очередь на выполнение тредпулом. Запрос это IO задача, то есть поток сразу уходит спать пока не получит ответ. Проблема с GIL может быть только для CPU-bound задач. Возможно потому что ORM у нас нет - проблем с загрузкой процессора тоже нет


  1. whoisking
    21.10.2022 22:05
    +1

    библиотеки более верхнего уровня, вроде django-rest-framework и других, тоже работают без всяких модификаций

    Разве можно асинхронный queryset обернуть в Response из DRF? Или я что-то не так понял?)