Всем привет! Я работаю на собственном проекте Максилекта. Это высоконагруженная 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, а что-то иное. Но на данном проекте с данным стеком имеем то, что имеем.