Как стать автором
Обновить

Комментарии 27

А чем это лучше обычных потоков и обычных пулов? Почему не просто пулы в сумме потоков на 500 максимум, а вероятно меньше?

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

А чем это лучше обычных потоков и обычных пулов?
Чем хуже сразу видно

Это типичный вопрос про плюсы/минусы неблокирующего кода и пр.

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

К сожалению, у меня нет точных цифр, но, судя по этому ответу, один context switch может стоить 5-7 микросекунд (а их надо два - чтобы остановить текущий поток, и чтобы вернуться к нему): "I can't find results for nehalem (is there lmbench in phoronix suite?), but for core2 and modern Linux context switch may cost 5-7 microseconds."

Чтобы подобного не происходило, можно использовать неблокирующий код, и способов довольно много:

  1. В некоторых языках есть встроенная поддержка (async/await, корутины, горутины и так далее).

  2. В Java неблокирующие вызовы прописываются явно (то есть, в итоге, системе оставляют callback для вызова, когда все данные уже готовы).

Плюсы неблокирующих вызовов:

  1. Меньше context switching (по сути, мы заменяем вытесняющую многозадачность кооперативной).

  2. Меньше потоков (суммарно).

  3. В некоторых случаях - легче синхронизация (особенно, если приложение использует вообще один поток для определенной операции - тогда можно быть уверенным, что никто другой не выполняет ничего параллельного, а потому можно использовать обычные HashMap для кешей и пр.).

Минусы:

  1. Если не переиспользовать callback'и, то создается большее число объектов на каждый вызов.

  2. Меньше возможностей для оптимизации, так как JVM теперь сложнее аллоцировать объекты на стек и тд.

  3. Код сложнее читать (это неправда для kotlin/scala/C#/go и пр., где поддержка асинхронности добавлена в язык) из-за большого числа callback'ов и пр.

  4. Очень легко допустить ошибки, которые будут увеличивать объем стека - см. пример из Spring

К сожалению, у меня нет точных цифр, но, судя по этому ответу, один context switch может стоить 5-7 микросекунд (а их надо два - чтобы остановить текущий поток, и чтобы вернуться к нему): "I can't find results for nehalem (is there lmbench in phoronix suite?), but for core2 and modern Linux context switch may cost 5-7 microseconds."

А это точно проблема? Попробуйте не выносить в отдельный поток то что выполняется быстрее 10 миллисекунд. Не риалтайм у вас там, нет смысла совсем в мелочи упираться. 0.1% производительности это вроде не очень большая жертва?

Меньше context switching (по сути, мы заменяем вытесняющую многозадачность кооперативной).

А это проблема? Сколько процентов производительности вы бы на этом потеряли?

Большая часть продакшен кода это не про скорость, а про надежность. Допустим рекорды. Плюс нагрузка на gc плюс память, но гарантия что в объекте лежит то что ты в него положил. При правильном их использовании. Чистый обмен производительности на надежность. И это прекрасно.

Меньше потоков (суммарно).

А это проблема? Сколько памяти вы бы на этом потеряли?

В некоторых случаях - легче синхронизация (особенно, если приложение использует вообще один поток для определенной операции - тогда можно быть уверенным, что никто другой не выполняет ничего параллельного, а потому можно использовать обычные HashMap для кешей и пр.).

Точно нет. Обычные мапы в многопоточном окружении вас всегда накажут. В самый неподходящий момент. Вы потом неделю+ будете ловить эту ошибку и все закончится всё равно переписыванием на канкаррент типы. Канкаррент версия мапы это проблема? Насколько она у вас хуже будет (по любому из параметров на ваш выбор)?

Для примера у меня в проде есть канкаррент мапа с мнопоточной записью. Она принимает 5000+ обращений на запись в секунду. На флеймграфе приложения ее вообще не заметно. Вроде не страшно?

Минусы:

Минусы понятные. Ищем плюсы их перевешивающие.

Мы именно про Джаву. В других языках другие проблемы. Разные языки для разного.

На самом деле это как давний вопрос "чем SQL лучше чем FoxPro". Т.е. декларативный vs императивный код.

Reactor это декларативный подход. Пулы это императивный подход. Для разных задач разное подходит. Декларативный же (Reactor) позволяет размышлять в терминах какой результат нужен, для систем с множеством ветвлений/зависимостей результата это помогает. С другой стороны как только вы видите что-то что вы знаете как оптимизировать как процесс выполнения - тут у вас все руки связаны.

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

SQL прекрасен. И писать на нем легко и просто. Может не в этом дело?

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

Под капотом всё равно пул потоков, только работу по управлению ими берёт на себя Reactor.

И?

Управление пулом, запуск на выполнение, ожидание завершения - этого всего нет в коде, всё делает reactor. Я думаю, что можно сравнить с управлением транзакциями JDBC vs JPA.

Вы так описали как будто для этого требуется какая-то значимая работа.

На самом деле:

Создание эксекутора - 1 строка и 1 настройка в которой не страшно промахнуться. Это я про количество потоков. Их можно сделать в разумное число раз больше чем получилась экспертная оценка сколько там надо.

Выполнение задачи экзекутором - 1 строчка.

Все очень чистенько, красиво и любому читающему ваш код понятно что происходит.

Сравните с тем что у вас.

Может быть Вы правы. В своё оправдание могу лишь сказать только то, что реактивное программирование я осваивал на Scala, по книге Akka in Action by Rob Williams, Raymond Roestenburg, Robertus Bakker, и на Java - Практика реактивного программирования в Spring 5 Oлег Докука Игорь Лозинский. Да, функциональный стиль предполагает инкапсуляцию кода непосредственно в операции. Потоки в этом примере запускают: 1001 запрос к базе данных; из базы данных выбираются 2000 документов; все выбранные документы обрабатываются, сериализуются; группируются, отправляются серверу ElasticSearch, обрабатываются ответы - каждый из этапов выполняется асинхронно, данные обрабатываются параллельно. В этом примере время выполнения < 1 сек. (можно посмотреть в последней строке лога). Не думаю, что я бы смог это сделать (организовать параллельное выполнение как между этапами конвейера, так и внутри блоков конвейера) в 2-х строках кода с использованием notify(All), wait, start, lock, tryLock, synchronized, CompletableFuture, Timer. Reactor, в том числе, и позиционируется его создателями как фреймворк избавляющий от вложенности callback'ов. Да и Spring анонсировал завершение поддержки RestTemplate и рекомендует переходить на WebClient и WebFlux, основывающихся на Reactor.

Согласен, реактивное(событийное) программирование не упрощает процесс кодирования, по сравнению с привычным многопоточным, но при правильном использовании позволяет снизить потребность в ресурсах, а фреймворки Reactor, Akka, Akka Stream, ... избавляют нас от рутины управления процессами обработки данных, позволяя сосредоточится на самой обработке.

Зачем вы пишите этот корпоративный/бюрократический буллшит? Я в институте в презентациях и то столько мусора не писал.

Еще раз. Насколько вы снизили потребность в ресурсах? За счет чего? Переключение контекста и расход памяти на потоки это понятные издержки. Сколько они у вас составили?

фреймворки Reactor, Akka, Akka Stream избавляют нас от рутины управления процессами обработки данных, позволяя сосредоточится на самой обработке.

Вы это на полном серьезе пишите? Я уж вижу очень толстый троллинг. Есть мировой консенсус что они затрудняют примерно все. Написание, отдладку, поддержку, стоимость владения кодом, тайм ту маркет и тому подобное. Но есть какие-то кейсы, где этого стоит. Почему у вас такой кейс?

В этом примере время выполнения < 1 сек.

Зачем вы оцениваете процессинг по задержке? Её можно сделать любой покрутив конфижки и уменьшив пакет обрабатываемых данных до одной строки или что там у вас.

Процессинг всегда оценивают по пропускной способности. Сколько гигабайт в секунду вы можете переложить на одном ядре.

Эти оценки часто противоположны. И это нормально.

На Ваши замечания могу сказать, что целью статьи было показать пример решения задачи асинхронного взаимодействия с web-сервисом с использованием Spring Boot/Reactor/WebClient. Оптимизация и оценка производительности - это отдельная интересная тема.

Но есть какие-то кейсы, где этого стоит. Почему у вас такой кейс?

Типичная задача в системах, использующих ElasticSearch, где есть существенная задержка ответа сервиса.

Насколько вы снизили потребность в ресурсах? За счет чего?

Event loop WebClient'а снижает количество потоков ожидающих ответ. Здесь этим занимается 1 поток (.subscribeOn(Schedulers.single()). Подготовка данных многопоточная, отправка запросов - асинхронная однопоточная.

Сам замеры пока не проводил, но говорят что при тех же(и даже меньших) ресурсах обеспечивает лучшую пропускную способность и скорость обработки запросов. https://youtu.be/Y2wMPG-htpE?t=1757

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

Я делал замеры на высокнагруженной системе где на каждый шаг собираются метрики. Код переписанный почти в лоб с реактора на потоки оказался сильно медленней. Потратив в разы больше времени на программирование, чтобы выжать максимум, удалось добиться той-же производительности, по метрикам разница -5%..+5% (на разные этапах). Жалко потерянного времени.

К слову, еще была попытка переписать на корутины Kotlin, это оказался полный провал с проседанием в 7 раз.

Код переписанный почти в лоб с реактора на потоки оказался сильно медленней.

Хотелось бы взглянуть на этот код.

UPD: "сильно медленней" это сколько?

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

Сильно медленней это раза в полтора, но точно уже не помню потому что такой вариант долго не жил в проде.

С Loom не тестировали?

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

Бесполезная работа (память, gc, пр.) лишь растет по мере того как переводишь на корутины. В Reactor это не проблема потому что pull модель и может обрабатывать условно бесконечный объем не требуя пропорционального увеличения ресурсов.

Понятно что все это можно оптимизировать, но если начал то мне лично проще делать это на обычной Java Concurrency, со всеми локами, семафорами и пр. Просто потому что предсказуемо работает. Поэтому Loom наверное тоже не вариант.

PS Довольно большая часть оптимизации (кроме переписывания на lock-free) сводится к тому что бы сделать свой backpressure. Больше положил в очередь - медленно, меньше - машина простаивает. Может лучше больше, но потом выкинуть лишнее. И т.д. Надо искать оптимум, и то как потоки с этим всем работают. Kotlin Flow все таки ограничен и неприменим когда у тебя множество входов. Нигде не получилось его применить. Но может для простого случая он и решает backpressure.

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

Похоже на упущенное обратное давление (backpressure) либо некорректную отмену...


Бесполезная работа (память, gc, пр.) лишь растет по мере того как переводишь на корутины. В Reactor это не проблема потому что pull модель и может обрабатывать условно бесконечный объем не требуя пропорционального увеличения ресурсов.

Вы хотите сказать, что все эти лямбды не потребляют памяти и никак не нагружают gc?


Kotlin Flow все таки ограничен и неприменим когда у тебя множество входов.

Каналы же как раз для этой цели сделаны.

Похоже на упущенное обратное давление (backpressure) либо некорректную отмену...

Да, вы правильно заметили основной посыл моего коментария

Вы хотите сказать, что все эти лямбды не потребляют памяти и никак не нагружают gc?

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

Каналы же как раз для этой цели сделаны.

Да, именно про это я и написал. Что после переписывания на каналы заработало гораздо быстрее. Но все это было все еще в 7 раз медленней тупого кода на Reactor. К этому времени код с корутинами стал выглядеть совсем страшно и весь смысл корутин пропал раз все равно каналы. Было решено переписать на стандартные потоки.

Корутины тоже потребляют пропорционально количеству одновременно обрабатываемых данных. Как и потоки, так что если не терять обратное давление — то разница между всеми тремя подходами только в константном множителе.


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

Корутины тоже потребляют пропорционально количеству одновременно обрабатываемых данных

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

И утверждение ... я вижу сомнительным.

Я рассказал свой опыт переписывания реальной высоконагруженной системы на разные архитектуры. Зачем бы я вам врал?

У вам может быть другая архитектура приложения, другие задачи, другая нагрузка и другие компетенции команды, поэтому ваш опыт может отличаться. Лучше напишите об это, это будет полезней для всех.

Пока нет

Жаль, что у вас нулевое покрытие кода тестами. Тестировать реактивные системы это отдельная интересная тема, которую вы незаслуженно обошли стороной.

Согласен. Не дошли руки.

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации