Всем привет! Я работаю на собственном проекте Максилекта. Это высоконагруженная AdTech платформа, включающая Ad Exchange сервер и сопутствующие компоненты. Но в этой статье речь пойдет не совсем о проекте. Я бы хотел поговорить об асинхронщине в задачах подобного масштаба. Просто на примеры из проекта мне будет удобно ссылаться.
Я не буду подробно останавливаться на том, что такое асинхронность. Подход старый, в интернете есть чудовищное количество информации. Но расскажу о некоторых наблюдениях - будет пища для ума в контексте того, стоит ли тащить ее в свои проекты.
Статья основана на вопросах, которые мы обсуждали на внутреннем техническом митапе.
Предположим, у нас есть поток данных (stream). Наблюдатели подписываются на этот поток, могут читать данные из него и реагировать на них - как-то их обрабатывать или создавать свои данные. В классическом (синхронном) варианте поток (thread) делает запрос во внешнюю систему и ждет ответа - блокируется до прихода ответа. В асинхронном варианте он продолжает выполнять другую работу. Как только от внешней системы приходит ответ, поток (thread) включается в работу и обрабатывает его.
Асинхронность нужна в первую очередь в микросервисной архитектуре, когда на проекте есть много взаимодействий между небольшими кусками системы по API. В такой архитектуре большую часть времени сервисы ждут ответа своих соседей. Чтобы использовать потоки более эффективно, придумали асинхронные вызовы и реактивное программирование в целом.
Для реализации асинхронного подхода мы в своем проекте используем Spring. Внутри он основан на отличной библиотеке Reactive Streams, которая реализует одноименную спецификацию. У этой библиотеки очень много возможностей и хорошая документация. Spring WebFlux - фреймворк для разработки API - полностью основан на Reactive Streams.
Парадигма реактивного программирования выглядит красиво - никто никого не ждет. Код при этом написан не в императивном стиле, а скорее в декларативном - вы описываете, как хотите обрабатывать потоки данных, и получается довольно красиво. Однако в плане производительности я бы не сказал, что всё однозначно хорошо. Далее приведу пару примеров, с которыми мы столкнулись на нашем проекте.
Пример 1
У нас есть высоконагруженный сервис - REST API, которое отвечает на запрос, приходящий по HTTP. Сам он делает множество запросов во внешние системы, т.е. очень много ждет. Казалось бы, это тот самый кейс, когда стоит применять реактивный подход.
Изначально сервис был написан именно в блокирующем виде. Поток делал запрос во внешний сервис и ждал ответа. Это приводило к тому, что у сервиса было огромное количество потоков - около 2000 - и большая часть из них просто ждала. Однако при этом какой-то полезной работой в каждый момент времени занималось не более сотни.
В таком режиме сервис функционировал несколько лет.
В какой-то момент я занялся оптимизацией этого сервиса и попробовал переделать его в неблокирующем стиле, чтобы потоки не ждали ответов внешних систем. Формально поток в системе - вещь не бесплатная, он отъедает память, добавляет переключение контекста. Так что я ожидал ускорения работы сервиса.
В результате потоков действительно стало меньше - 500 вместо 2000, причем никто из них не ждал, а все чем-то занимались. Кажется, что система должна теперь потреблять меньше ресурсов. Но по факту изначальные 2000 потоков для сервера - это не такая уж высокая нагрузка. Разницы между 500 и 2000 потоками в системе мы не заметили - нагрузка на CPU не изменилась. Разве что могли гордиться тем, что применили модную концепцию.
Можно было бы подумать, что мы открыли себе путь для масштабирования, раз потоки теперь не заканчиваются. Но практика показала, что каждый вызов внешней системы занимает файловый дескриптор, а со временем они просто заканчиваются - мы не можем до бесконечности копить внешние запросы.
В нашем случае дескрипторы заканчивались, если наш собственный сервис по каким-то причинам замедлялся. Входящая нагрузка при этом не уменьшалась, так что у нас доходило до 100 тыс. дескрипторов. Чтобы избежать переполнения, мы поставили ограничение на одновременное количество внешних запросов (семафор).
Пример 2
Второй эксперимент провели на еще более нагруженном сервисе. Он не делает внешних вызовов и изначально был написан в неблокирующем стиле (через Spring использовался неблокирующий API Tomcat). По сути неблокирующая реализация была применена там, где она не нужна.
Нагрузка на сервис настолько высокая, что для него нам были важны даже микрооптимизации - даже те, которые в обычном коде ничего не значат. Когда у меня закончились идеи, что же можно еще улучшить, я попробовал переписать его в блокирующем режиме с пулом потоков. В итоге сервис начал работать на 15% быстрее. Т.е. использование Spring и неблокирующего Tomcat - это оверхед по производительности. В большинстве случаев - когда речь идет про сотни запросов в секунду - он будет незаметен, но когда в секунду обрабатываются десятки тысяч запросов, асинхронная реализация отъедает ресурсы.
Таким образом, наши примеры показывают, что асинхронщина, возможно, и имеет смысл, если в коде присутствует много внешних вызовов и если вам вообще нравится реактивный стиль программирования. Но также это может создать вам дополнительный оверхед в плане производительности.
Дисклаймер: вероятно, мы могли бы получить другие результаты, если бы использовали не Spring и Tomcat, а что-то иное. Но на данном проекте с данным стеком имеем то, что имеем.
P.S. Мы публикуем наши статьи на нескольких площадках Рунета. Подписывайтесь на нашу страницу в VK или на Telegram-канал, чтобы узнавать обо всех публикациях и других новостях компании Maxilect.
Комментарии (13)
pin2t
20.01.2025 15:18Асинхронщина устарела, она была придумана когда процессоры были в основном одноядерные. Теперь же процессоры в основном многоядерные. Чтобы утилизировать все ядра все равно надо использовать многопоточность. А раз так, то тогда уж гораздо проще изначально использовать многопоточность и блокирующие вызовы, без асинхронщины, так будет гораздо проще структура программы.
Плюс реализация потоков постоянно улучшается, потоки уже не жрут столько памяти и переключаются быстро.
jobber_man
20.01.2025 15:18В результате потоков действительно стало меньше - 500 вместо 2000, причем никто из них не ждал, а все чем-то занимались.
Разницы между 500 и 2000 потоками в системе мы не заметили - нагрузка на CPU не изменилась.
Вот тут не очень понятно, цифры как-то не бьются. Ядер-то у вас сколько на сервере? Какая нагрузка в RPS и сколько занимает обработка одного запроса?
"Все чем-то занимались" звучит как будто у вас 500 ядер были загружены. Если вдруг у вас и вправду такой жирный сервер, то для случая, когда "большую часть времени сервисы ждут ответа своих соседей", это очень странный выбор железа.
И если в модели "один запрос - один поток" вам хватало 2000 потоков на таком количестве ядер, то это выглядит скорее как CPU-bound нагрузка, что не бьётся с "ждут ответа своих соседей".
Если же ядер в сервере сильно меньше 500, то асинхронщину вы как-то неправильно приготовили. Количество потоков в идеале должно быть примерно равно количеству ядер.
Условно, если у вас 32 или 64 ядра, то 500 потоков не сильно лучше 2000, куча времени будет тратиться на ворочание тредами.
Но это не бьётся уже с утверждением "все чем-то занимались". Большая часть потоков в такой кофигурации может заниматься только ожиданием.
Maxilect Автор
20.01.2025 15:18Ядер-то у вас сколько на сервере? 16/32
Какая нагрузка в RPS? ~25000
сколько занимает обработка одного запроса? 200мс на 95 процентиле
звучит как будто у вас 500 ядер были загружены. Нагрузка на CPU ~25%
И если в модели "один запрос - один поток" вам хватало 2000 потоков на таком количестве ядер, то это выглядит скорее как CPU-bound нагрузка, что не бьётся с "ждут ответа своих соседей". Если же ядер в сервере сильно меньше 500, то асинхронщину вы как-то неправильно приготовили. Количество потоков в идеале должно быть примерно равно количеству ядер. Условно, если у вас 32 или 64 ядра, то 500 потоков не сильно лучше 2000, куча времени будет тратиться на ворочание тредами. Но это не бьётся уже с утверждением "все чем-то занимались". Большая часть потоков в такой кофигурации может заниматься только ожиданием.
Поток веб-сервера, который обрабатывает http-запрос, мы не блокировали. Вместо этого у нас был отдельный пул потоков, который занимался обработкой http запросов и ожиданием ответов от соседей. Размер этого пула у нас был выставлен в 2000 с запасом, но в каждый момент времени активно было не более 100-150. Оставшиеся потоки или ожидали ответов или были неактивны.
Эта реализация была изменена в пользу отказа от пула потоков и отказа от блокировок при ожидании ответов.
jobber_man
20.01.2025 15:182000 потоков / 25000 RPS = 80 мс в среднем. 32(16) / 25000 = 1.28 (0.64) мс CPU time на запрос (при 100% загрузке). 1.28 / 80 ≈ 1-2% времени на CPU, остальное ожидание. 1 мс на вычисления выглядит, конечно, невероятно круто для спринга, но в целом явный io-bound, для которого неблокирующая асинхронщина самое то.
32 ядра физически не могут выполнять более 32 потоков. 100-150 "активных" это явный артефакт сбора статистики.
500 потоков в неблокирующем режиме вам не нужно, 32 самый максимум. Скорее всего, запросы к бэкэндам у вас были блокирующими. Асинхронщина такого не любит, ей нужен non-blocking io. Иначе преимуществ вы не увидите. Похоже, ваш эксперимент в очередной раз это подтвердил.
vanxant
20.01.2025 15:18Асинхронщина, внезапно, небесплатна. Если в обычном коде фреймы активации функций компактно лежат на стеке, то в лапше async/await размазаны по куче, со всеми вытекающими. У неблокируещего ввода-вывода под капотом тоже, как правило, весьма упитанные системные структуры данных (минимум 1 бит на каждый возможный файловый дескриптор). Тащем-та, накладные расходы на ещё один поток (не процесс) вполне могут оказаться меньше накладных расходов на переключение асинхронных контекстов.
SerBuryat
20.01.2025 15:18Здравствуйте, спасибо за небольшой пост, очень знакомо и близко. Тоже хочу провести внутренний тех митап по асинхронщине и реактивы.
Правильно я понимаю, что во втором примере у вас были в основном cpu-bound задачи, поэтому обычная многопоточка, которая даёт попроще распарралелить задачи на ядра, дала больше оптимизации, нежели асинхронка, которая больше нацелена на io-bound задачи?
И приходим мы к тому что реактив по сути нужен там, где io-bound, scheduled задачи, которые большинство времени держат потоки в состоянии sleep/wait, а там, где cpu-bound задачи, нам нужен параллелизм через классическую многопотчку в жабке?
Maxilect Автор
20.01.2025 15:18Правильно я понимаю, что во втором примере у вас были в основном cpu-bound задачи, поэтому обычная многопоточка, которая даёт попроще распарралелить задачи на ядра, дала больше оптимизации, нежели асинхронка, которая больше нацелена на io-bound задачи? Да, верно
И приходим мы к тому что реактив по сути нужен там, где io-bound, scheduled задачи, которые большинство времени держат потоки в состоянии sleep/wait, а там, где cpu-bound задачи, нам нужен параллелизм через классическую многопотчку в жабке? В нашем случае получилось так
akseug
20.01.2025 15:18Примеры того как не надо делать.
Для начала не плохо бы разделить асинхронное от реактивного.
Неблокирующий Tomcat? Зачем такие сложности? А приготовили точно правильно? Проще и правильнее было использовать Netty.
500 потоков это много, стремиться надо к 1 поток на 1 ядро.
В примере 2 не раскрыто на чем скорость +15%, дамп сравнивали? Я лишь могу догадываться, но может вы в реактивной части сильно много объектов создаёте? Может на каждую операцию у вас новый map/flatMap и т.д.? А в императивном стиле завернули это все в 3-4 метода и рады росту скорости?
VGoudkov
20.01.2025 15:18Посмотрите где там наш любимый Spring. https://www.techempower.com/benchmarks/#hw=ph&test=fortune§ion=data-r22&l=zik0vz-cn3 Возможно Вы сочтёте, что нужно выбрать другой вид нагрузки (тест), но в любом случае, Spring, при всех его достоинствах - не лидер в производительности.
N4N
Очередное подтверждение, что любой инструмент нужно применять осмысленно, а не только потому что это модно. По факту применение асинхронщины не настолько широко распространено, не только потому, что это немного сложнее чем однопоточка, но и в основном из-за того, что даже это не сильно большое усложнение архитектуры приложения не приносит кратного улучшения производительности, а в определенных кейсах даже может ее замедлить.