Асинхронный django: в защиту DEP-9
- суббота, 21 января 2023 г. в 00:50:24
Здравствуй, дорогой читатель, тема этой публикации - 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-я блокирующая секция выполняется и запускает асинхронную задачу, представленную 2-й секцией.
Поток-воркер, который выполнял 1-ю секцию, теперь свободен, и берёт в обработку блокирующие секции других вьюшек
Тем временем, асинхронная 2-я секция выполнилась и ставит в очередь на выполнение в тредпул 3-ю секцию.
Итак, 3-я секция стоит в очереди в тредпуле, ожидая свободного воркера - придётся немного подождать. В рамках блокирующего подхода, нас это не сильно смущает: нам важно, чтобы воркеры были максимально загружены, и чтобы их нагрузка была разбита на достаточно малые части. В таком случае, мы можем расчитывать в итоге на нормальную производительность.
Учитывая, что причина, по которой мы вообще используем несколько секций внутри функции - это наличие долгих операций (http запрос, в нашем примере), временем на "переключение" между потоками можно пренебречь. Но обратите внимание: количество блокирующих секций внутри функции важно! Именно перед выполнением блокирующей секции мы ждём, пока не освободится воркер. Так что, если есть возможность сэкономить на какой-нибудь блокирующей секции, то делайте это - это уже совет, который применим к "реальной жизни" - то есть, Вашему проекту на django.
Вернёмся теперь снова к реальности и вспомним, что обычно мы имеем дело с ASGI приложениями и асинхронными вьюшками. Что это меняет? Ну, как минимум, теперь (асинхронные) вьюшки начинаются и заканчиваются асинхронной секцией - увеличивается количество асинхронных секций. Количество блокирующих секций остаётся тем же.
HOUSTON WE HAVE A PROBLEM
Автор сам немного запутался. Возможно, выйдет другая статья, где главный мессадж поменяется на противоположный EPIC FAIL
Я надеюсь, что моя статья внесла чуть больше ясности в django и асинхронность, и теперь в Вашей команде будет меньше споров по этому вопросу. А может быть, наоборот, больше? Обязательно напишите потом в комментариях.