Асинхронность в питоне — это хайп, не стоит отказываться от блокирующего кода
- вторник, 6 декабря 2022 г. в 00:43:46
Здравствуйте, читатели хабра! В этой статье я постараюсь убедить вас в том, что блокирующий ввод-вывод и использование тредов - жизнеспособная и самодостаточная модель - как для работы с реляционными базами, так и для написания веб-сервисов вообще.
Вы, возможно, скажете - идея не нова, в период популярности Django и Rails - чего только не перепробовали для нормальной реализации реалтайма: и long-polling, и микросервисный подход а-ля django channels 1, и django channels 2 - всегда получается что-то не то. Это, конечно, так, но - всё равно, не стоит доверять коллективному разуму чересчур.
Кстати, предлагаю решение для реалтайма: отлично можно слать сообщения по вебсокету и в обычном WSGI-хэндлере. Если, конечно, предположить, что у нас есть сторонний асинхронный сервер, который принимает вместо нас ws-коннекты - выполняя роль прокси, таким образом. Будем использовать этот сервер как прокси и дальше: подключимся к нему по вебсокету с нашего WSGI-сервера - и будем слать через него ws-сообщения нашим клиентам: "прокси"-сервер получает сообщение, видит конечного получателя - и шлёт ему такое же. Внутри WSGI-хэндлера будет обычный блокирующий вызов:
def view(request):
...
send(client_id=11, message="All done.")
Подчеркну, что последний вызов блокирующий: мы ждём успешной отправки. При существующем подключении, сообщения отправляется быстро. Я не предлагаю использовать этот подход на практике: на практике можно сделать ещё проще.
Но начнём по-порядку. Современные веб-приложения, как правило - всего лишь, фронтенд к базе данных - поэтому интересно, как можно сравнить между собой блокирующий и асинхронный подходы в смысле запросов к базе. Моё мнение - подходы эквивалентны. Работа с одновременным доступом к базе строится вокруг подключений, причём их оптимальное количество невелико. В результате, вполне подходит модель 1 поток = 1 подключение.
У меня получилось сделать некоторые бенчмарки: я сравнивал asyncpg и psycopg2. Результаты - asyncpg стабильно чуть быстрее, но ненамного - от 10 до 20%. При этом, asyncpg и psycopg2 ведут себя одинаково при увеличении числа подключений - то есть, при параллельном доступе. Я тестировал на сравнительно несложных селектах и инсертах - вы можете потестировать на чём-нибудь ещё.
У asyncpg есть свои фирменные бенчмарки, я их запускал - в них она ведёт себя очень хорошо - всё равно, не так хорошо, как на их графиках. Всего в 2 раза лучше, чем psycopg2 - на простых селектах. Причём, если убрать этот бессмысленный параметр cursor_factory, преимущество снижается до 1.5 раз. Чем ещё обусловлена разница между их бенчмарками и моими - не знаю. Я смотрел их код - он, вроде, адекватный.
Я не исключаю, что у asyncpg есть некоторые преимущества перед psycopg2 (вроде, используются prepared statements, где это возможно), но к асинхронности они не имеют отношения. В общем - не бойтесь использовать синхронные драйверы: они такие же быстрые.
Теперь - о веб-приложениях: я считаю, что старомодный WSGI так же хорош и годится для тех же юзкейсов, что и новомодные.
Скажу об одном обстоятельстве: после появления псевдо-стандарта ASGI, стали появляться и различные сервера с его поддержкой, в том числе, не питоновские - как, например, nginx unit. В связи с этим, реальность такова: асинхронный веб-сервер, с реализацией тех же вебсокетов - обычно он уже есть, его можно считать как данность. Причём, Вы не знаете, как он устроен: в том же ASGI приложении Вам не дают доступ к физическому подключению по вебсокету, потому что это особенность реализации. Учитывая это обстоятельство, давайте сравним блокирующую и асинхронную модель - Вы увидите, что у последней нет каких-то особых преимуществ.
Во-первых, отправлять сообщения по вебсокету блокирующим образом - это возможно (используя имеющийся у нас асинхронный сервер). Поток будет ждать, пока сообщение отправится. Здесь можно вспомнить пример, который я приводил вначале: принцип тот же самый, только подключаться по вебсокету к "прокси"-серверу нет необходимости, можно напрямую использовать его API. Стандарт ASGI, правда, для этого не подойдёт: он сделан для asyncio исключительно. Но небольшая его модификация позволит это делать.
Теперь, по поводу ASGI: стандарт этот - недоразумение. И появился он при странных обстоятельствах - и сам получился странным. Нет, хорошо, конечно, что он вообще есть, но он мог быть и лучше.
Например, в нём есть понятие scope - это соответствует подключению (это для вебсокетов больше имеет смысл). Есть также асинхронные функции read и write - чтобы читать и писать, в рамках этого подключения. Само ASGI приложение - это одна асинхронная функция:
async def your_app(scope, receive, send):
...
В итоге, каждому подключению по вебсокету соответствует корутина - вроде, всё логично. На самом деле - нет. Главная деталь в том, что ASGI - это спецификация для приложения "внутри" асинхронного сервера, а не для самого этого сервера. Сам сервер уже есть, работа с вебсокетами в нём уже реализована, задача приложения - пользоваться им - а не копировать его в миниатюре или что-то ещё.
Что мне не нравится в ASGI? Первое: функции send и receive могут быть как блокирующими, так и асинхронными - нужна спецификация, основанная на колбэках, потому что колбэки - это универсальный интерфейс.
Второе: вместо функций send и receive для текущего подключения было бы гораздо полезнее иметь айдишники подключений и возможность отослать (или принять) сообщение по любому айдишнику. Ведь у сервера есть физические объекты подключений - он нам может сообщать какие-то айдишники для них - по которым сам потом сможет их идентифицировать. Но этого нет - в результате, нам нужно хранить в памяти интерпретатора список функций send - чтобы иметь возможность отправить сообщение произвольному клиенту. И это работает, пока наше приложение запущено в 1 процесс, а не несколько. Одним словом, ASGI - довольно странный протокол, на мой взгляд.
Если говорить о том, что можно добавить в WSGI - ничего, он работоспособен, как есть. Нужен только ещё API для работы с вебсокетами - аналог ASGI, но для блокирующего ввода-вывода. Формат сообщений можно оставить, как в ASGI.
И последнее, о чём обещал рассказать - своём проекте по добавлению в джанго асинхронного бэкенда. Он есть, он работает (делает это при помощи махинаций с гринлетами - тех самых, которые использует sqlalchemy), но я решил его не развивать.
Дело в том, что достоинства всей этой пляски с гринлетами - это совместимость с синхронным (блокирующим) кодом - которой у asyncio нет. И - как следствие - поддержка уже имеющихся библиотек (django). Ни то, ни другое не является самоцелью. Библиотек в питоне достаточно - использующая asyncio нативно, без гринлетов, подойдёт, наверно, лучше. Мне кажется - совершенно нормально, если django будет поддерживать только блокирующий ввод-вывод, а какие-то библиотеки - наоборот, только asyncio. Разделение труда.
В целом, я считаю - то, что всем вдруг понадобилась асинхронность, не имеет под собой объективных причин. Лично я скорее бы использовал обычные потоки. И, конечно, всё вышесказанное не относится к случаям с по-настоящему интенсивным вводом-выводом. Но, во-первых, кто станет использовать питон для этого? Тот факт, что современные библиотеки вроде io_uring не нужны пока асинхронному питону - лишнее тому подтверждение.
Опрос делать в этот раз не буду, но очень жду комментов, велкам!