await anywhere, взгляд на третью сторону медали: sync vs async vs …

Любое решение имеет срок жизни, даже самое классное, надёжное и современное.
/Json Statement/

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

Интерлюдия

Мы разрабатываем сервис по работе с самозанятыми. Он обеспечивает бесперебойные выплаты самозанятым, получает от них чек и всячески упрощает рутинную работу нашим клиентам. Несмотря на достаточно простой бизнес-процесс в самом сервисе, вместе с зависимостями картина будет примерно такой:

Сервис и его «друзья»

Сервис и его «друзья»

Это реальная схема, но из неё удалены названия других сервисов, которые так или иначе участвуют в нашем процессе, либо мы являемся частью их процесса. Наш же сервис отмечен зелёным.

Стек достаточно тривиальный: FastAPI для приёма запросов и быстрого прототипирования API и Django для работы с БД и админки. Обе технологии дарят нам свои плюсы и все счастливы.

Но подарены были и минусы. Пятница, вечер. Проблемы начались когда один из белых квадратиков начал засылать запросов больше, чем мы могли переварить.

Несварение

Поскольку наш стек подразумевает синхронную работу с БД, то все методы API так же синхронные. FastAPI это позволяет и делегирует starlette запуск синхронных обработчиков в потоках, а тот в свою очередь anyio.

Количество потоков (thread) строго отрегулировано и, благодаря репликации, их общее число на все кластеры позволяло обрабатывать поток запросов с большим запасом. Это верно до тех пор, пока время выполнения метода не начинает уходить в закат. Тогда минимальный RPS сервиса, основанного на потоках, можно выразить так:

RPS_{min} = \frac{Threads}{MethodExecutionTime_{max}}

И при достаточной интенсивности запросов значение максимального RPS становится очень близко к минимальному. Что и произошло. Метод проверки статуса самозанятого вышел на пик своей популярности. И когда поток всех входящих запросов перевалил за число, которое мы способны переварить, начали отваливаться запросы, обслуживающие фронт. Началось несварение.

Первая, вторая и третья помощь

Заплатка пришла так же быстро, как и беда. Теперь мы отдавали 429 HTTP Status Code агрессору, ограничив метод половиной доступных нам потоков.

Конечно, так жить нельзя, и поскольку узких мест у нас два (на входе и на выходе), то каждый из них заслуживает внимания.

На выходе: при работе с источником мы теперь тонко чувствуем его натуру, и если он подаёт признаки недоступности, то мы это запоминаем на короткий срок и повторно не обращаемся к нему, сигнализируя о недоступности ошибкой в ответ.

На входе же в приложении нужно что-то делать с потоками, и мы сделали.

Рукопожатие с асинхронностью

Популярность метода проверки статуса в его простоте. Он по сути является прокси к нашим налоговым органам, а значит других зависимостей нет, и его можно переписать без проблем на асинхронный манер. Настал час для выбранной связки FastAPI❣️Django проявить основной плюс — гибкость — замена def на async def .

Пара часов с учётом адаптации тестов и копипастой синхронного кода, и современное ПО готово к проду! Однако обещать не значит жениться. И современные версии либ с современными настройками упорно не хотели дружить с несколько позабытыми прокси-серверами: SSLV3_ALERT_HANDSHAKE_FAILURE

Чтобы выяснить, кто кого не любит и за что, а тем более как их помирить, пришлось потратить ещё какое-то время. Мы обязательно к этому вернёмся, а тем временем мысль шла далеко вперёд, в поисках решения для всего сервиса, ведь не ровен час, и мы уже так просто не отделаемся.

288e8218bf7d095025c903a43e9c5e1f.png

Синхронным метод мы оставить не можем. Переписать на асинхронный — можем, однако в перспективе такой подход приведёт к полному переписыванию проекта, как только будет затронута существенная часть БЛ. Зная всё это, нам нужен другой, третий путь, третья сторона медали, как угодно. Время нужно выиграть во что бы то ни стало.

Пятница, вечер, ситуация чуть стабильнее, но полыхает ещё ярко.

Зелёная нить

9afc1ceaf2e5a1475a707e567b76eff5.png

Теперь, чтобы нам понимать друг друга, придётся немного изучить основы такой технологии как greenlet и познакомиться с концепцией Green Threads в целом.

В общем случае Wikipedia даёт исчерпывающее описание:

green threads — это потоки выполнения, управление которыми вместо операционной системы производит виртуальная машина. <…> Управление ими происходит в пользовательском пространстве, а не пространстве ядра, что позволяет им работать в условиях отсутствия поддержки встроенных потоков.

Если вам кажется, что это актуально только для Python, то рекомендую заглянуть на википедию, список языков интересным образом удивит.

Говоря своими словами green threads позволяют реализовать кооперативную многозадачность и снизить расходы на создание потоков, так как ОС знать про них ничего не будет, а значит все накладные расходы на их изоляцию становятся не нужны.

Для CPython этим занимается библиотека greenlet, и далее, чтобы не путать с нативным потоками, я буду говорить просто — гринлеты.

Гринлеты

«Открытий век мы проложили, идя нехоженной тропой. А впереди всё словно в пЫли, как будто брошено тобой»
/Инженер-реабилитолог/

Настолько старая технология, что о ней мало кто слышал, а если и слышал, то от своих прадедов, ведь первая версия была в 2006 году:

f89d52218b713c3cf09f7b8fe84693f6.png

На деле же это очень маленькая библиотека, несмотря на долгий жизненный путь. И уже с поддержкой Python 3.12.

83e9d788e58c7b3cf382afe43bdef286.png

А что до популярности, то только гитхаб насчитывает >300к репозиториев.

595d63a79ab4372a43a379c8cb940a2b.png

Кто ты? Что ты?

Гринлеты — это микропотоки на ручном управлении. Микро — значит они в разы меньше потребляют ресурсов по сравнению с обычными (нативными) потоками. Потоки — значит они ведут себя как потоки и обладают всеми их свойствами.

Давайте посмотрим чуть глубже, в то как они устроены. Библиотека дает нам два главных примитива:

  • класс greenlet для запуска функции в гринлете (микропотоке);

  • функцию greenlet.switch(*args, **kwargs) для переключения от одного гринлета к другому для передачи потока выполнения. Эта же функция позволяет передавать данные между гринлетами наподобие generator.send(val). Если же гринлет ещё не был запущен, то данные будут использованы как параметры для функции, которую необходимо запустить в гринлете.

Посмотрим как это выглядит на примере, взятом из документации. Я его немного изменил, чтобы было видно, как передаются данные через switch .

0d7e605b91d21f4a13ffca29d85aff73.png

Мы создали гринлеты, но ещё ничего не запустили. Для того, чтобы передать управление и запустить гринлет, нужно сделать switch() .

8891c94fc0c189d8c73399580789a03f.png

Важный момент, на который стоит обратить внимание — строчка 'test1 done'. Именно она вернулась из функции switch, поскольку return работает аналогично switch, но возвращает нас к тому, кто запустил гринлет.

Если наложить данный процесс на timeline, то выглядеть это будет так:

Рукописи не горят

Рукописи не горят

Как ты устроен?

Работает это очень просто, но очень непросто это понять. Важной особенностью гринлетов является то, что они целиком и полностью написаны на Си и Си++.

А с какой стороны ты? :)

А с какой стороны ты? :)

085ff1ef01a08c4eff00ccbde352bc15.png

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

Поэтому под каждую платформу есть код на ассемблере, который это делает. На скриншоте выше это вызов slp_switch.

25910eed3e57d9ddf7459c5b371782a3.png

И каждый щелчок switch приводит к переключению состояния потока внутри Python. По сути, то же самое делает операционная система, имитируя многозадачность.

Для тех же, кто хочет подробно изучить как и зачем это работает, есть статья, объясняющая реализацию корутин на разных языках.

И что с тобой делать?

Делать можно многое. Как вы поняли, сами по себе гринлеты не позволяют решить проблему напрямую. К асинхронности они имеют точно такое же отношение, как данный рассказ к кулинарии (очень далёкое или нет?!).

Вспомним, на чём мы остановились. У нас есть синхронная функция, внутри которой вызывается другая синхронная функция. И таких функций и вызовов может быть N, , а дерево вызовов может уходить очень глубоко. И нам необходимо превратить её в асинхронную.

Пример вложенности вызовов на псевдокоде

Пример вложенности вызовов на псевдокоде

Пример реальной вложенности вызовов

Пример реальной вложенности вызовов

Асинхронные функции — это те, которые передают управление во время ожидания. Нет ожидания — нет асинхронности, нет передачи управления — нет асинхронности. Есть несколько проектов, которые делают подход к снаряду. И первым попался на глаза greenletio.

greenletio говорит: «Не надо переписывать весь код, достаточно спрятать его за гринлетом, а I/O переложить на плечи asyncio». Проще говоря, любую синхронную функцию можно превратить в нативную асинхронную. Звучит неплохо. А главное выглядит просто:

Декоратор greenletio.async_

Декоратор greenletio.async_

Вы декорируете вашу функцию через greenletio.async_, и ей остаётся только сделать switch при блокирующей операции и пробросить через него awaitable объект, который будет обработан стандартным способом через await. Но даже это greenletio делает за вас. greenletio.patch_blocking заменит реализацию стандартных пакетов на ту, которая будет себя вести именно так.

greenletio/green/threading.py

greenletio/green/threading.py

Пусть вас не смущает функция await_, это ещё один примитив доступный в комплекте greenletio. Он позволяет дожидаться асинхронных функций, внутри это обычный greenlet.switch

Этот же подход используется в SQLAlchemy для поддержки асинхронного драйвера.

sqlalchemy/util/concurrency.py

sqlalchemy/util/concurrency.py

Этакий портал для awaitable-объектов, который переносит их снизу вверх, минуя весь стек и необходимость заменять def на async def .

Ты предал меня, greenletio… why?!

Проект удалось завести, хоть и не сразу. Автор проекта поддерживает только публичное API модулей, а вот костыли, даже если они от авторов CPython — нет.

https://github.com/miguelgrinberg/greenletio/issues/12

Однако из-за особенностей реализации часть пакетов использовать становится просто невозможно. Особенность эта заключается в том, что Lock.acquire() может быть вызван за пределами «портала». Например, когда библиотека импортируется или вы отдаёте метрики в асинхронном обработчике (без портала), используя prometheus_client, который внутри себя использует блокировки для контроля доступа.

В блеклист попали следующие либы:

  • uvicorn

  • prometheus_client

  • sentry_idk

И вот на включении Sentry код начал падать. Я понял, что дальше бороться просто нет смысла, инструменту не хватает функционала для обеспечения работоспособности такого сложносочинённого проекта.

Единственный выход из ситуации — как-то научиться await-ить ожидание в любой момент, будь мы в контексте гринлета или eventloop-а, и делать это в любой функции, будь она объявлена как def или async def .

И… это уже сделано, 9 лет назад.

В чем сила брат?

«Видел eventloop? Это я закрутил»
/Json Statement/

asyncio-gevent — форк и реинкарнация более старых аналогов, переписанный на современный лад и для современных версий gevent и Python.

Немного о том, что это и для чего:

README.md

  • running asyncio on gevent (by using gevent as asyncio’s event loop);

  • running gevent on asyncio (by using asyncio as gevent’s event loop, still work in progress);

  • converting greenlets to asyncio futures;

  • converting futures to asyncio greenlets;

  • wrapping blocking or spawning functions in coroutines which spawn a greenlet and wait for its completion;

  • wrapping coroutines in spawning functions which block until the future is resolved.

Первая реализация этого подхода появилась в 2014 году, вместе с asyncio. И это даже работало для python 2.7, так как был бэкпорт asyncio для python2.

Но почему он?

gevent — библиотека для организации кооперативного доступа к сетевым ресурсам, использующая гринлеты и libuv/libev для организации eventloop. Во времена, предшествующие asyncio, gevent обладает двумя важными для нас качествами:

  • проверен временем и используется в крупных и сложных проектах (нет серьезных проблем с манкипатчингом);

  • реализует асинхронность для гринлетов (мы помним, что сами гринлеты за это не отвечают).

Использование asyncio-gevent в режиме asyncio поверх gevent является production-ready решением по заявлению автора и позволяет переключаться из гринлета в корутину и обратно абсолютно нативным для текущего контекста способом, будь то greenlet.join() (обертка от gevent) или await future.

Для этого нужно соблюсти всего пару условий:

  • вызвать gevent.monkey.patch_all() как можно раньше;

  • установить EventLoopPolicy: asyncio.set_event_loop_policy(asyncio_gevent.EventLoopPolicy())

В сумме со способностью gevent пропатчить всю стандратную библиотеку мы без изменения кода автоматически получаем честную асинхронность для нашего синхронного кода. Теперь он наравне с корутинами будет бороться за процессорное время внутри шедулера eventloop-а.

Реализация asyncio поверх gevent asyncio_gevent/event_loop.py

Реализация asyncio поверх gevent
asyncio_gevent/event_loop.py

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

Now — что в итоге?

8e634e78866d61813bf1bffed07f9a8b.png

Решение на asyncio-gevent завелось с полпинка, и работает у нас в проде. Никаких проблем с библиотеками нет, всё совместимо.

Для интеграции с fastapi и django пришлось проделать несколько трюков:

  • пропатчить sync_to_async / async_to_sync версии из asgiref (Django aio) и anyio (FastAPI aio) ****на функции из asyncio-gevent;

  • забрать реализацию asgiref.local.Local прямо из мастера, так как её переписали на православные contextvars, иначе были проблемы с доступом к админке джанги по нестандартному URL;

  • научить asyncio-gevent пробрасывать контекст (патчи протестированы и будут законтрибьючены).

Выходные были спасены, обошлось без бессонных ночей.

Понедельник

Любой понедельник начинается с новостей. И мы не исключение. Нас радостно встретили DBA с вопросами «куда вам столько коннектов к БД?».

В связке Fastapi+Django есть важный патч для anyio, который гарантирует закрытие коннекта после выполнения запроса, как это делается в обычном Django.

Этот же патч забыли применили неправильно для asyncio-gevent, пришлось переписывать. Заодно прикрутили пул коннектов, иначе он открывался на каждый запрос.

Остальное можно выразить в цифрах после небольшого нагрузочного теста:

native-threads (x10)

asyncio-gevent

asyncio

max (RPS)

12

25

SSL_HANDSHAKE_ERROR

Number of Users

Before Deny of Service

40

800

-

AVG (response time)

-

-

-

RPS довольно низкий в обоих случаях. На то есть причины. Во-первых, мы ограничены внешней системой и скоростью её ответа. Во-вторых, у нас внутри используется connection-pool до неё, чтобы свой аппетит также ограничивать. Однако во время тестирования всё же удалось какое-то время отдавать по 25 запросов в секунду. Самое интересное, что это было сделано на пике нагрузки.

Number of Users Before Deny of Service — столько потребовалось пользователей, чтобы уложить сервис на лопатки (в основном по memory).

AVG (response time) рос пропорционально росту числа пользователей, без изменений для каждого из вариантов.

Выводы

  • это действительно production-ready решение, как и пишет автор asyncio-gevent;

  • будьте готовы заплатить CPU/RAM, магии не бывает и у вас будут те же проблемы, что и с asyncio при росте размера eventloop. Скейлите горизонтально, про этот способ забывать не стоит;

  • знать о проблеме во всех деталях ≠ знать как её решать;

  • «Magic» As a Service:

    • gevent и greenlet оказались не такими уж страшными и магическими технологиями. Отведённая им роль на уровне инфраструктуры позволила не усложнять код, но увеличить время жизни проекта и его пропускную способность и даже условно бесплатно;

    • любая хорошо развитая технология неотличима от магии, это не повод её бояться и/или не применять.

  • когда есть рабочее решение — никому нет дела до SSL_HANDSHAKE_ERROR.

© Habrahabr.ru