habrahabr

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

  • суббота, 21 января 2023 г. в 00:50:24
https://habr.com/ru/post/711722/
  • Python
  • Django


Здравствуй, дорогой читатель, тема этой публикации - 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 def - не обращайте на это внимания: функция, на самом деле, блокирующая. Да, такой дурацкий синтаксис. Если интересно, про него можно прочитать в моей новогодней статье. В рамках же этой статьи, читатель имеет полное моральное право с ним не соглашаться: синтаксис не важен, мы могли с тем же успехом использовать для асинхронного кода отдельную функцию.

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

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

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

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

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

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

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

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

HOUSTON WE HAVE A PROBLEM
Автор сам немного запутался. Возможно, выйдет другая статья, где главный мессадж поменяется на противоположный
EPIC FAIL

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

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Опрос
27.27% Автор прошаренный 3
72.73% Автор балбес 8
Проголосовали 11 пользователей. Воздержались 15 пользователей.