Здравствуйте, читатели хабра! В этой статье я постараюсь убедить вас в том, что блокирующий ввод-вывод и использование тредов - жизнеспособная и самодостаточная модель - как для работы с реляционными базами, так и для написания веб-сервисов вообще.

Вы, возможно, скажете - идея не нова, в период популярности 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 не нужны пока асинхронному питону - лишнее тому подтверждение.

Опрос делать в этот раз не буду, но очень жду комментов, велкам!

Комментарии (11)


  1. zelenin
    05.12.2022 14:59
    +5

    стабильно чуть быстрее, но ненамного - от 10 до 20%


  1. Yuribtr
    05.12.2022 16:20
    +7

    У меня получилось сделать некоторые бенчмарки: я сравнивал asyncpg и
    psycopg2. Результаты - asyncpg стабильно чуть быстрее, но ненамного - от
    10 до 20%. При этом, asyncpg и psycopg2 ведут себя одинаково при
    увеличении числа подключений - то есть, при параллельном доступе. Я
    тестировал на сравнительно несложных селектах и инсертах - вы можете потестировать на чём-нибудь ещё.

    Как я предполагаю преимущество asyncpg будет более заметно при относительно тяжелых запросах. По идее не должен блокироваться поток во время длительного ожидания выполнения запросов, при исчерпании лимита подключений. Для имитации медленных запросов на БД можно попробовать pg_sleep.

    В целом, я считаю - то, что всем вдруг понадобилась асинхронность, не
    имеет под собой объективных причин. Лично я скорее бы использовал
    обычные потоки.

    Ну с потоками легко поймать race condition, да и код становится сложнее писать. К тому же тесты показывают что asyncio работает чуть быстрее чем threading ввиду отсутствия необходимости переключения потоков, и настоящего параллелизма нет ни там ни там, ввиду GIL.

    Современные веб-приложения, как правило - всего лишь, фронтенд к базе
    данных - поэтому интересно, как можно сравнить между собой блокирующий и
    асинхронный подходы в смысле запросов к базе

    На самом деле задачи асинхронных фреймворков могут быть шире чем просто доступ к БД. Например в микросервисной архитектуре только небольшая часть сервисов работают с БД. Остальные работают с другими МС, либо отправляют нотификации в мессенджеры, почту и брокеры. Для отправки запросов на другие МС удобно использовать также асинхронные библиотеки типа httpx или aiohttp client. Если их скрестить с FastApi и aiopika получается бомба. Так что не так все однозначно ))

    Причём, Вы не знаете, как он устроен: в том же ASGI приложении Вам не
    дают доступ к физическому подключению по вебсокету, потому что это
    особенность реализации

    Тут кстати не понял, что имелось ввиду. Например в FastApi (Starlette) при подключении вебсокета у вас есть экземпляр подключения с которым вы можете далее делать что угодно: читать из него, писать в него, закрывать, отслеживать дисконнеты. И это очень удобно делать именно в асинхронном стиле.


    1. abetkin Автор
      05.12.2022 18:23
      -3

      настоящего параллелизма нет ни там ни там, ввиду GIL

      Настоящий параллелизм есть и там и там. На вызовах сишного libpq GIL отпускается

      Например в FastApi (Starlette) при подключении вебсокета у вас есть экземпляр подключения

      Вот starlette. Где он, этот экземпляр? Я вижу только send и receive, о которых я писал


      1. Yuribtr
        05.12.2022 19:22

        Настоящий параллелизм есть и там и там. На вызовах сишного libpq GIL отпускается

        Мое утверждение касалось сравнения asyncio vs threading в CPython, а не библиотеки PostgreSQL.

        Вот starlette. Где он, этот экземпляр? Я вижу только send и receive, о которых я писал

        FastApi (Starlette) вам предоставляет класс WebSocket в котором есть весь нужный функционал - чтение из вебсокета, запись в него, закрытие, определение статуса соединения и отлов события отключения. Вроде как все основные функции на месте. Пример реализации можете глянуть здесь.


        1. abetkin Автор
          05.12.2022 19:50

          Он не самодостаточный: ему нужны ещё библиотеки для работы, например, unicorn[standard]. Потому что он сам не работает с вебсокетами


  1. kai3341
    05.12.2022 18:07
    +4

    Автор. При собеседовании на уровень Junior требуется знать разницу между потоками и корутинами. Ты её не понимаешь.


    1. abetkin Автор
      05.12.2022 18:14
      -5

      хорошо, что я не собеседуюсь на джуниора :)


      1. abetkin Автор
        05.12.2022 20:18

        ¯_(ツ)_/¯


  1. RH215
    05.12.2022 20:54
    +2

    Дело в том, что достоинства всей этой пляски с гринлетами - это совместимость с синхронным (блокирующим) кодом - которой у asyncio нет.

    Отлично, нет текущего синхронного кода - wsgi не используем, всё просто.


  1. DirectX
    05.12.2022 22:50
    +2

    Не знаю, всё же использовать Python для веба... Ну такое... Не для того он предназначен. Да, сделать на Django неспешную админку для внутреннего продукта вполне себе неплохая идея, но не более того. А для высокопроизводительного сервиса это всё равно, что пытаться дотюнить педальную машинку до использования на немецком автобане. И ни WSGI, ни асинхронность тут кардинально не помогут.

    Казалось бы, есть успешный пример Node.JS, который смог. Но он смог в достаточно специфичном кейсе асинхронных веб серверов, главным образом благодаря удачному сочетанию для этого случая движка V8 и концепции планировщика Event Loop, реализованной через libuv. В этой связке асинхронность там была изначально, причём не простая, а фактически реализуемая для таких вещей как работа с сетью на очень низком системном уровне.

    Python и его веб-фреймворки, с другой стороны, в силу своей супер универсальности не могут похвастаться ни супербыстрым интерпретатором (в котором должна быть реализована компиляция, чтобы добиться сравнимых с V8 результатов), ни низкоуровневым планировщиком для асинхронных вызовов.

    Концептуально в мире Python ближе всего к Node что-то вроде uvloop. Но всё равно это полумеры.

    Всё сказанное выше - ИМХО, лично давно не имел дел с веб-фреймворками на Python, может быть с тех пор там стало всё очень хорошо (но это не точно). При этом против самого по себе Python ничего не имею, в своих нишах - язык отличный.


    1. RH215
      06.12.2022 17:57

      может быть с тех пор там стало всё очень хорошо (но это не точно).

      Нормально там всё. Штуковины вроде starlette вполне шустры, если делать поправку на медленность вычислений, что для проксирования запросов в БД и лёгкой логики не так важно. То есть для типового, умеренно нагруженного сервиса, этого более чем достаточно. А если уже мы упираемся в скорость python, то проще взять Go, а то и C++.

      в силу своей супер универсальности

      Не, мода на django-подобные монстры прошла.