Асинхронный django: в защиту DEP-9

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Здравствуй, дорогой читатель, тема этой публикации - DEP-9 и его защита. DEP-9 - это "RFC" для асинхронности в django. Если что, этот RFC не был сделан полностью, поэтому я буду защищать только ту часть, которая сделана: мало ли, как могли бы сделать всё остальное!

Отвечу сразу на вопрос, который я задал в предисловии - потому что поддерживать интригу - не в моём стиле. Он заключается в следующем распространённом мнении, что, поскольку django так и не смог избавиться от блокирующего ввода-вывода в большей части своей кодовой базы - ORM, при использовании django в асинхронном приложении ничего не остаётся, как вызывать функции этого ORM в отдельном потоке. Такая чехарда между синхронными и асинхронными потоками не может пагубно не сказаться на производительности, и вообще, не идёт ни в какое сравнение с нативной асинхронностью.

И дам сразу ответ на этот вопрос. Django действительно использует блокирующий ввод-вывод при работе с базой данных - некоторые другие фреймворки используют асинхронный. Это равноценные варианты, производительность одинакова плюс-минус - в том числе, при работе с key-value базами, очередями и так далее. Однако есть тип проблемы, которая, с развитием микросервисов, стала довольно распространённой. Угадаете, какой?

Это - вызов стороннего сервиса, по http, например. Он длится - не так, чтобы очень долго - пользователь может и подождать. Но это неоправданно долго в том смысле, что один из потоков простаивает зря. Ещё и время отклика у этого стороннего сервиса не гарантированное. Вот для решения этой проблемы и существуют все эти адаптеры и запуск в другом потоке. Что касается производительности - всё как было, так и осталось - одинаковая плюс-минус. Каких-то особенных проблем с этим подходом нет. Это - если кратко, но есть нюансы, читайте дальше.

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

С позволения читателя, я буду использовать для иллюстрации свой собственный новый "синтаксис". Потому что не может быть статья на хабре без экзотики. Короче говоря, всё, что находится под асинхронным контекстным менеджером io, выполняется в другом потоке:

async def myview(request):
    # blocking code
    ...
    
    async with io:
        # this goes to separate thread
        async with httpx.AsyncClient() as client:
            response = await client.get(url)
        ...
    
    # blocking code
    ...

"io" - потому что "with asyncio". То, что перед функцией написано async - не обращайте на это внимания: функция, на самом деле, блокирующая. "async" всего лишь означает, что эта функция - генератор. Да, такой дурацкий синтаксис. Если интересно, про него можно прочитать в моей новогодней статье. В рамках же этой статьи, читатель имеет полное моральное право с ним не соглашаться: синтаксис не важен, мы могли с тем же успехом использовать для асинхронного кода отдельную функцию.

Что происходит в этом примере? Наша вьюшка содержит запрос на сторонний сервис, тот самый - длительный и с негарантированным откликом. Мы решаем эту проблему запуском в другом потоке, при этом разбивая вьюшку на 3 секции - блокирующую, асинхронную и снова блокирующую. Такая вот "крупноблоковая асинхронность" у нас. Можно считать, что это 3 разные функции. Вообще говоря, все 3 могут выполняться в разных потоках.

Как они могут выполняться? Скорее всего,у нас будет тредпул из потоков-воркеров, которые будут выполнять блокирующий код - пусть их будет 2 или 3. Также нам нужен поток, который будет выполнять асинхронный код - с event loop-ом и корутинами. Отвлечёмся пока от существующих стандартов: не будем ограничивать себя WSGI, ASGI или чем-то другим.

  1. 1-я блокирующая секция выполняется и запускает асинхронную задачу, представленную 2-й секцией.

  2. Поток-воркер, который выполнял 1-ю секцию, теперь свободен, и берёт в обработку блокирующие секции других вьюшек

  3. Тем временем, асинхронная 2-я секция выполнилась и ставит в очередь на выполнение в тредпул 3-ю секцию.

Итак, 3-я секция стоит в очереди в тредпуле, ожидая свободного воркера - немного придётся подождать. В рамках блокирующего подхода, нас это не сильно смущает: нам важно, чтобы воркеры были максимально загружены, и чтобы нагрузка была разбита на достаточно малые части. В таком случае, мы можем расчитывать в итоге на нормальную производительность.

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

Вернёмся снова к реальности и вспомним, что обычно мы имеем дело с ASGI приложениями и асинхронными вьюшками. Что это меняет? Ну, во первых, асинхронные вьюшки начинаются и заканчиваются асинхронной секцией - значит, появляются лишние секции. Но это небольшая проблема: мы говорили, что экономить только нужно на блокирующих секциях. Но стойте - тут мы замечаем одну лишнюю блокирующую секцию! Угадаете, какую? Первую! В смысле, ту, которая в нашем примере была первой. Теперь, мы должны ставить её в очередь и ждать, пока не освободится воркер, в то время как раньше мы начинали выполнять её сразу.

Ну что же - подход, который использует django, не идеален - в мире нет ничего идеального. Есть зато пространство для улучшений.

Подход, который использует DEP-9, адекватен. Он никак не противоречит асинхронным фреймворкам, и в частности, моему проекту с гринлетами - fibers. По поводу гринлетов, кстати, существует тоже заблуждение, будто бы гринлеты легковесные, а потоки тяжёлые, поэтому версия с гринлетами намного лучше по производительности. Вот, например, что пишет автор sql-алхимии в переписке со мной (я тогда не собирался использовать гринлеты и доказывал ему, что гринлеты - это плохо).

greenlets are extremely lightweight and are in practical terms not even measurable regarding performance overhead. The performance hit is when you are using threadpools, which I recall seeing that Django was using right now for asyncio. That's a huge hit. the single greenlet context switch, not at all.

Справедливости ради, следует сказать, что zzzeek не спец по django, так что это было всего лишь его предположение. Но оно неверно. Гринлеты, действительно, легковесные, но мы не можем сравнивать гринлеты в алхимии и потоки в django: это, что называется, apples and oranges. В случае гринлетов, мы "смешиваем" асинхронный код с кодом, вообще не содержащим ввода-вывода, в случае же django, мы смешиваем асинхронный код с блокирующим - это две большие разницы. Мы же с вами теперь знаем, что суть в другом: просто, sql-алхимия с гринлетами использует асинхронный ввод-вывод, а django - блокирующий.

Ну и - в заключение - я думаю, асинхронный ввод-вывод скоро также появится в django. Я, конечно, говорю сейчас о своём проекте fibers: больше на горизонте не видно ничего. Возможно даже, что это будет более популярная опция. Есть все причины так считать:

  1. Асинхронные сервисы уже популярны, FastAPI - async-only. "Даже django стал асинхронным" - звучит как ещё один аргумент.

  2. Коммерческие проекты тяготяют к универсальности. Зачем деплоить половину приложения как WSGI и половину - как ASGI, если можно задеплоить только ASGI, и всё будет работать?

  3. Если говорить о стеке библиотек или фреймворков - разумно поддерживать либо только блокирующий, либо только асинхронный ввод-вывод, но не оба. Иначе все библиотеки придётся поставлять в двух вариантах. И, конечно, выберут асинхронный, потому что "всё равно он иногда нужен".

Если бы мы говорили не о django, а о другом фреймворке, я бы предположил, что при появлении асинхронной версии он быстро скатится к async-only. Однако, в случае django, мне сложно это себе представить, честно говоря. Одним словом, мой прогноз - и тот и другой способ ввода-вывода будет хорошо поддерживаться - к выигрышу сообщества.

Я надеюсь, что моя статья внесла чуть больше ясности в то, как "асинхронность" работает в django, и теперь в Вашей команде будет меньше споров по этому поводу. А может, больше? Обязательно напишите потом в комментариях.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Опрос
0% Автор прошаренный 0
0% Автор балбес 0
Никто еще не голосовал. Воздержались 2 пользователя.
Источник: https://habr.com/ru/post/711722/


Интересные статьи

Интересные статьи

Всем привет! Меня зовут Кирилл Сотников, я работаю в Центре Кибербезопасности и Защиты Ростелекома. И сегодня я хочу поделиться небольшим исследованием по отключению/обходу антивирусов и EDR в операци...
Нередко при работе с Bitrix24 REST API возникает необходимость быстро получить содержимое определенных полей всех элементов какого-то списка (например, лидов). Традиционн...
История сегодня пойдёт про автосервис в Москве и его продвижении в течении 8 месяцев. Первое знакомство было ещё пару лет назад при странных обстоятельствах. Пришёл автосервис за заявками,...
Как-то у нас исторически сложилось, что Менеджеры сидят в Битрикс КП, а Разработчики в Jira. Менеджеры привыкли ставить и решать задачи через КП, Разработчики — через Джиру.
Мы публикуем видео с прошедшего мероприятия. Приятного просмотра.