Комментарии 19
Асинхронщина устарела, она была придумана когда процессоры были в основном одноядерные. Теперь же процессоры в основном многоядерные. Чтобы утилизировать все ядра все равно надо использовать многопоточность. А раз так, то тогда уж гораздо проще изначально использовать многопоточность и блокирующие вызовы, без асинхронщины, так будет гораздо проще структура программы.
Плюс реализация потоков постоянно улучшается, потоки уже не жрут столько памяти и переключаются быстро.
В результате потоков действительно стало меньше - 500 вместо 2000, причем никто из них не ждал, а все чем-то занимались.
Разницы между 500 и 2000 потоками в системе мы не заметили - нагрузка на CPU не изменилась.
Вот тут не очень понятно, цифры как-то не бьются. Ядер-то у вас сколько на сервере? Какая нагрузка в RPS и сколько занимает обработка одного запроса?
"Все чем-то занимались" звучит как будто у вас 500 ядер были загружены. Если вдруг у вас и вправду такой жирный сервер, то для случая, когда "большую часть времени сервисы ждут ответа своих соседей", это очень странный выбор железа.
И если в модели "один запрос - один поток" вам хватало 2000 потоков на таком количестве ядер, то это выглядит скорее как CPU-bound нагрузка, что не бьётся с "ждут ответа своих соседей".
Если же ядер в сервере сильно меньше 500, то асинхронщину вы как-то неправильно приготовили. Количество потоков в идеале должно быть примерно равно количеству ядер.
Условно, если у вас 32 или 64 ядра, то 500 потоков не сильно лучше 2000, куча времени будет тратиться на ворочание тредами.
Но это не бьётся уже с утверждением "все чем-то занимались". Большая часть потоков в такой кофигурации может заниматься только ожиданием.
Ядер-то у вас сколько на сервере? 16/32
Какая нагрузка в RPS? ~25000
сколько занимает обработка одного запроса? 200мс на 95 процентиле
звучит как будто у вас 500 ядер были загружены. Нагрузка на CPU ~25%
И если в модели "один запрос - один поток" вам хватало 2000 потоков на таком количестве ядер, то это выглядит скорее как CPU-bound нагрузка, что не бьётся с "ждут ответа своих соседей". Если же ядер в сервере сильно меньше 500, то асинхронщину вы как-то неправильно приготовили. Количество потоков в идеале должно быть примерно равно количеству ядер. Условно, если у вас 32 или 64 ядра, то 500 потоков не сильно лучше 2000, куча времени будет тратиться на ворочание тредами. Но это не бьётся уже с утверждением "все чем-то занимались". Большая часть потоков в такой кофигурации может заниматься только ожиданием.
Поток веб-сервера, который обрабатывает http-запрос, мы не блокировали. Вместо этого у нас был отдельный пул потоков, который занимался обработкой http запросов и ожиданием ответов от соседей. Размер этого пула у нас был выставлен в 2000 с запасом, но в каждый момент времени активно было не более 100-150. Оставшиеся потоки или ожидали ответов или были неактивны.
Эта реализация была изменена в пользу отказа от пула потоков и отказа от блокировок при ожидании ответов.
2000 потоков / 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. Иначе преимуществ вы не увидите. Похоже, ваш эксперимент в очередной раз это подтвердил.
1 мс на вычисления выглядит, конечно, невероятно круто для спринга Да, если готовить спринг так, как написано в его документации (через @RestController
и пр.), то производительность деградирует очень сильно. Нам пришлось использовать разные полу-легальные хаки для того, чтобы заставить его работать быстро.
100-150 "активных" это явный артефакт сбора статистики. Возможно. Мы брали метрики прямо из нашего пула потоков.
Вы точно знаете, как работают потоки в операционой системе?
Асинхронщина, внезапно, небесплатна. Если в обычном коде фреймы активации функций компактно лежат на стеке, то в лапше async/await размазаны по куче, со всеми вытекающими. У неблокируещего ввода-вывода под капотом тоже, как правило, весьма упитанные системные структуры данных (минимум 1 бит на каждый возможный файловый дескриптор). Тащем-та, накладные расходы на ещё один поток (не процесс) вполне могут оказаться меньше накладных расходов на переключение асинхронных контекстов.
Здравствуйте, спасибо за небольшой пост, очень знакомо и близко. Тоже хочу провести внутренний тех митап по асинхронщине и реактивы.
Правильно я понимаю, что во втором примере у вас были в основном cpu-bound задачи, поэтому обычная многопоточка, которая даёт попроще распарралелить задачи на ядра, дала больше оптимизации, нежели асинхронка, которая больше нацелена на io-bound задачи?
И приходим мы к тому что реактив по сути нужен там, где io-bound, scheduled задачи, которые большинство времени держат потоки в состоянии sleep/wait, а там, где cpu-bound задачи, нам нужен параллелизм через классическую многопотчку в жабке?
Правильно я понимаю, что во втором примере у вас были в основном cpu-bound задачи, поэтому обычная многопоточка, которая даёт попроще распарралелить задачи на ядра, дала больше оптимизации, нежели асинхронка, которая больше нацелена на io-bound задачи? Да, верно
И приходим мы к тому что реактив по сути нужен там, где io-bound, scheduled задачи, которые большинство времени держат потоки в состоянии sleep/wait, а там, где cpu-bound задачи, нам нужен параллелизм через классическую многопотчку в жабке? В нашем случае получилось так
Теперь пора открыть для себя virtual threads
Примеры того как не надо делать.
Для начала не плохо бы разделить асинхронное от реактивного.
Неблокирующий Tomcat? Зачем такие сложности? А приготовили точно правильно? Проще и правильнее было использовать Netty.
500 потоков это много, стремиться надо к 1 поток на 1 ядро.
В примере 2 не раскрыто на чем скорость +15%, дамп сравнивали? Я лишь могу догадываться, но может вы в реактивной части сильно много объектов создаёте? Может на каждую операцию у вас новый map/flatMap и т.д.? А в императивном стиле завернули это все в 3-4 метода и рады росту скорости?
Неблокирующий Tomcat? Зачем такие сложности? А приготовили точно правильно? Если бы мы его готовили неправильно, то думаю под 25000 RPS он бы совсем не работал. В любом случае этот тест проводился около 2 лет назад. Сейчас мы перешли на undertow.
В примере 2 не раскрыто на чем скорость +15% Сравнивали по двум показателям:
Среднее значение графиков нагрузки на CPU
Flamegraph из async-profiler
Может вы в реактивной части сильно много объектов создаёте? Может на каждую операцию у вас новый map/flatMap и т.д.? А в императивном стиле завернули это все в 3-4 метода и рады росту скорости? Это всё возможно, но только если речь про код spring/tomcat. Если же говорить про наш код, который отвечает за нашу бизнес логику, то изменения в нём были минимальные и не влияли на производительность.
Посмотрите где там наш любимый Spring. https://www.techempower.com/benchmarks/#hw=ph&test=fortune§ion=data-r22&l=zik0vz-cn3 Возможно Вы сочтёте, что нужно выбрать другой вид нагрузки (тест), но в любом случае, Spring, при всех его достоинствах - не лидер в производительности.
Вы и правы и не правы одновременно. Если использовать спринг так, как написано в его документации (через @RestController
и пр.), то да, для наших задач его производительности не хватает. Однако это можно решить. Например можно сделать так, чтобы запросы в функцию, которая создаёт основную нагрузку, шли "мимо" спринга напрямую в tomcat/undertow/jetty. А все остальные запросы к другим функциям, которые нагрузки не имеют, работали как обычно. Тогда мы с одной стороны сохраняем всё удобство спринга (кроме 1 функции), а с другой - имеем время работы менее 1мс под нагрузкой.
Статья написана поверхностно и более того присутствуют ошибки, которые выдают принципиальное непонимание рассматриваемой темы. Например, у автора были ожидания, что перейдя на реактивный подход latency одного запроса должно уменьшиться. На практике всё очень зависит от конкретных библиотек/серверов, но с теоретической точки зрения ожидать такое странно, если вы понимаете, как работает event loop, крутящийся внутри вашей "асинхронщины" и какую задачу он решает. Ну и конечно незаслуженно обойдён вниманием Loom, который в ближайшее время (после релиза 24 версии) сделает необходимость писать асинхронный код для задачи повышения утилизации железа весьма сомнительной.В качестве рекомендации "что почитать", я дам вам две ссылки: на мой комментарий, где я в трех параграфах просто и доступно пытаюсь рассказать про асинхронность в Java и где здесь место для Loom https://habr.com/ru/companies/spring_aio/articles/838912/comments/#comment_27217910, но если у вас есть время то лучше почитать блестящую статью Браина Гоетца, думаю, что после её прочтения у вас не останется вопросов: https://www.infoq.com/articles/java-virtual-threads
Нужна ли асинхронщина на проектах: пара наблюдений про Spring и неблокирующее API для самых маленьких