Читатель увидел статью про GIL и asyncio
Читатель увидел статью про GIL и asyncio

Не прошло и полутора лет, как у меня всё‑таки дошли руки написать эту статью.

Сегодня я расскажу очередную историю о том, как приходилось дружить синхронный и асинхронный мир в Python, а точнее про то, как и зачем я встраивал асинхронность в большое и достаточно нагруженное Django‑приложение.

Предыстория

? Если вам не интересен ответ на вопрос «Зачем?», то смело переходите к разделу «Реализация».

Началось всё с того, что моя команда столкнулась с проблемой в одном из сервисов, который выполняет много операций ввода‑вывода, а именно запросов в БД и HTTP‑запросов во внешние системы. Проблема заключалась в том, что таймауты имели тенденцию не срабатывать. То есть, мы ожидали, что через условные 300 мс мы получим какой‑нибудь ReadTimeout, но получали его только через 400 или даже 500 мс.

Зачастую на одно входящее событие из шины данных требовалось выполнить десятки исходящих запросов. Как я уже упомянул, приложение было написано на классическом Django, поэтому для распараллеливания задач мы использовали потоки.

Помимо потоков под бизнес-логику, в нашем приложении также были служебные потоки под Sentry, Jaeger, Kafka consumer (Heartbeat), Kafka producer (Sender), RabbitMQ (Heartbeat), Prometheus и, возможно, ещё какие-то, о которых я уже забыл.

If you know what I mean
If you know what I mean

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

У нас были и метрики, и трассировки, но всё указывало на то, что поток, ожидающий IO просто поздно получал управление. А если учесть, что стандартный интервал переключения в Python равен 5 мс, то несложная математика при 30 потоках как раз даёт те самые 30 * 5 = 150 мс. Понятно, что такой подсчет очень грубый, GIL отпускается на вводе‑выводе, но в то же время и 5 мс — это не предел. Switch interval — это только сигнал активному потоку, что пора бы поделиться процессорным временем, так что по факту активный поток может и дольше удерживать GIL, если текущая итерация цикла виртуальной машины слишком затянется.

Но уважаемый не собирался отдавать GIL
Но уважаемый не собирался отдавать GIL

Конечно, мы попробовали его потюнить, и сокращение switch interval в 20 раз до 0.25 мс даже дало неплохой результат — производительность приросла на 10–15%. Дальнейшее сокращение приводило уже к деградации, видимо теперь сказывалось переключение контекста. Но озвученная изначально проблема всё равно до конца изжита не была.

Так как же сократить влияние GIL? Правильно, убрать потоки в принципе!

Но если бы это было так просто, то в Python не было бы кучи реализаций sync_to_async, async_to_sync, например, как в asgiref или anyio.

Бо́льшая часть наших потоков — это воркеры под HTTP‑запросы, с них то мы и начали. Из всем известных вариантов замены имеются процессы, асинхронность и, пожалуй, гринлеты.

Процессы — хороший вариант, полностью решает проблему с GIL, но создаёт много сложностей с IPC. В целом, оно неплохо работает, но плохо масштабируется. Я не хотел бы, чтобы в будущем 1 под моего приложения содержал пару‑тройку десятков процессов.

Процессы требуют ресурсов, IPC - усложнения логики
Процессы требуют ресурсов, IPC - усложнения логики

Гринлеты — честно говоря, после того, как я погрузился в мир Stackless Python, greenlet, gevent, мне стало ещё больше непонятно, почему этот подход не взлетел в Python‑сообществе, и почему победил asyncio. Собственно, из‑за проблем с поддержкой библиотек, мы дальше тестирования здесь не ушли.

Асинхронность — думаю, всем понятно: отлично подходит под IO и нативно в самом Python. Но переписать с нуля критически важный и активно развивающийся сервис, содержащий более 100 000 строк кода, ни один здравомыслящий Product owner не даст. Поэтому пришлось искать альтернативы.

Используем ресурсы с умом
Используем ресурсы с умом

Почему бы не внедрять асинхронность постепенно, частями?

Варианта здесь, как минимум, два. Запускать EventLoop в отдельном потоке или в отдельном процессе. Второй вариант был оставлен на потом, как более сложный, на случай, если первый не даст результатов. Итак, реализация.

Реализация

На самом деле, всё просто. Запускаем поток, в нем запускаем цикл событий, в цикл отправляем наши задачи. Конец (спасибо за прочтение!).

IO-задачи отправляются из синхронного приложения в отдельный асинхронный поток
IO-задачи отправляются из синхронного приложения в отдельный асинхронный поток

Для работы с потоками мы использовали concurrent.futures.ThreadPoolExecutor, поэтому я решил переиспользовать тот же интерфейс для облегчения интеграции. Для HTTP — всем известную requests, так что для выполнения запросов асинхронно логично было взять httpx, авторы которой стремятся соблюдать тот же API.

Как было (упрощённо):

import requests
from concurrent.futures import ThreadPoolExecutor

def sync_get_user(user_id):
  with requests.Session as client:
    return client.get(f'https://service/user?id={user_id}')

with ThreadPoolExecutor() as ex:  # много потоков
    future = ex.submit(sync_get_user, user_id)
    result = future.result()

Как стало:

import httpx
from aiofutures import AsyncExecutor

async def async_get_user(user_id):
  async with httpx.AsyncClient as client:
    return await client.get(f'https://service/user?id={user_id}')

with AsyncExecutor() as ex:   # один поток
    future = ex.submit(async_get_user, user_id)
    result = future.result()

То есть, с AsyncExecutor основная логика приложения работает точно также, как работала бы с ThreadPoolExecutor, но при этом сокращается влияние GIL и используются преимущества asyncio.

По факту остался только 1 рабочий поток и служебные потоки библиотек
По факту остался только 1 рабочий поток и служебные потоки библиотек

Что мы получаем на выходе: у нас есть запросы во внешние сервисы, реализованные в синхронном и асинхронном вариантах, с одинаковыми сигнатурами, и полностью совместимые Executor»ы. Мы можем легко переключаться между реализациями, например, с помощью переменных окружения.

Итого

Какой результат мы получили:

  • Сократили количество потоков в среднем с 30 до 10, чем сократили влияние GIL.

    • Как следствие, повысили производительность сервиса.

  • Избавились от неработающих таймаутов.

  • Подготовили плацдарм для переноса остальной части приложения на асинхронные рельсы.

Что касается последнего пункта — при хорошей архитектуре приложения, замена транспортного слоя, например, синхронных клиентов Kafka и RabbitMQ, на асинхронные, представляется задачей тривиальной.

Наибольшую сложность в стандартном Django‑приложении вызовет вопрос ORM. Увы, его решать либо самостоятельно, либо ждать ещё не один год. Недавний релиз Django 5.0, к сожалению, к решению проблемы нас не приблизил.

Кстати

Если вы встречали похожую проблему, но ещё не решили её, то можете воспользоваться моей библиотечкой aiofutures. Буду рад, если вы докинете свои Issue и Pull request»ы.

Жду вашу конструктивную критику, а также истории о подобном опыте в комментариях, спасибо!

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


  1. vchin
    25.09.2024 20:28

    gevent был прекрасен ..


    1. KazakovDenis Автор
      25.09.2024 20:28

      Почему же был, последний релиз в этом году) Насколько помню, мы использовали какую-то функциональность psycopg2, которая не была им поддержана + адаптеры для gevent очень редко обновлялись, поэтому мы отказались от его использования


  1. amatoravg
    25.09.2024 20:28

    Можете рассказать чуть подробнее, какого функционала вам не хватило в асинхронном ORM Django 5?


    1. mstudiodad
      25.09.2024 20:28
      +1

      Настоящей асинхронности, а не усложненного тредпула


    1. KazakovDenis Автор
      25.09.2024 20:28
      +1

      Как коллега уже верно подметил, по состоянию на Django 5.0 в ORM есть только псевдо-асинхронность. DSF предоставили нам интерфейсы и синтаксис для асинхронной работы - и это хорошо, это открывает дорогу к переписыванию ORM на асинхронный лад. Но на самом деле, все асинхронные методы представляю собой просто отправку синхронного в отдельный поток (пример). То есть потоков будет создано по количеству параллельных вызовов, что либо никак не повлияет на производительность, либо отразится на ней отрицательно.


  1. Borjomy
    25.09.2024 20:28

    Просто перепишите критические части на C++ и не надо будет так извращаться. Надо четко представлять: НЕ ПОДХОДИТ питон для нагруженных приложений. Он слишком медленный. 5 мс реакции! Да 20 лет назад и то шустрее программы работали.


    1. KazakovDenis Автор
      25.09.2024 20:28
      +1

      Проблема конкретно здесь не в том, что Python медленный, а в том, что у его потоков есть единая точка синхронизации - GIL. Современные веб-приложения - это на 99% IO-нагрузка, поэтому скорость самого языка не играет здесь ключевую роль.

      Насчёт переписывания на другой язык в целом - это плохая идея для команды, которая не владеет этим самым языком :) переписать на С, С++, Go, Rust было бы допустимо, если бы эти языки хотя бы использовались в компании, но нашими соседями были только Java-разработчики. Переписать этот кусок кода на другой язык в данном контексте значило бы сделать его неподдерживаемым.

      Тем не менее, даже если бы мы переписали часть своего приложения, то мы бы всё равно не смогли никак переписать используемые библиотеки - они бы также зависели от GIL.

      А вообще, в приведённом примере же нет никаких извращений, используются только стандартные инструменты: threading, asyncio и всем известные интерфейсы.


      1. Hardcoin
        25.09.2024 20:28
        +1

        Старая отмирающая школа - "просто перепишите на Х/с использованием У"

        Проще не станет, но придётся решать проблемы другого рода (сейчас вы хотя бы утекающие указатели не ищите). asyncio хорошее решение. А захочется переписать - всегда есть Cython :)


    1. UncleJonathan
      25.09.2024 20:28
      +2

      C++ — сложный язык, толковых разработчиков найти в разы сложнее, времени на разработку и поддержку, отладку, будет уходить больше.

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

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

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


  1. aax
    25.09.2024 20:28

    То есть, мы ожидали, что через условные 300 мс мы получим какой‑нибудь ReadTimeout, но получали его только через 400 или даже 500 мс.

    Как только стал смотреть легальные сайты в обход ТСПУ, они стали грузиться моментально, от чего уже стал отвыкать за последние годы. Просто объективный научный факт.