Статьи

Spring Reactive уже устарел?

Помимо внедрения зависимостей Spring только для решения 1/5 проблемы инверсии управления , Spring Reactive базируется на цикле событий. В то время как существуют другие популярные решения, управляемые циклом событий (NodeJS, Nginx), однопоточный цикл событий — это маятниковое колебание в обратном направлении от потока на запрос (пулы потоков). С циклами событий, конкурирующими с потоком за запрос, не существует ли какой-либо модели, которая лежит в основе их обоих? Ну вообще да!

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

Проблемы с резьбой

Цикл событий

Прежде всего, «резьбовое соединение»? Почему это беспокойство? Хорошо для циклов событий однопотоковый характер требует, чтобы весь ввод / вывод осуществлялся асинхронно. Если необходимо заблокировать базу данных или HTTP-вызов, он заблокирует единый поток цикла событий и задержит систему. Это ограничение само по себе является большой проблемой связи, так как для того, чтобы перейти в режим Reactive, все ваши операции ввода-вывода связаны, чтобы теперь работать асинхронно. Это означает, что больше нет ORM, таких как JPA, чтобы упростить доступ к базам данных (поскольку JPA требует блокирования вызовов базы данных). Да, что-то, что раньше убирало 40-60% кода котельной плиты в приложениях, теперь не бесполезно (наслаждайтесь повторной записью!)

Помимо ограничения ввода-вывода в вашем решении использовать шаблоны Reactive, возможность использовать несколько процессоров ограничена, поскольку существует только один поток. Хорошо, экземпляры механизма Reactive дублируются для каждого ЦП, но они не могут совместно использовать состояние. Многопоточные последствия совместного использования состояния между двумя циклами событий сложны. Реактивное программирование достаточно сложно, не говоря уже о добавлении многопоточности. Да, связь между циклами событий может осуществляться через события. Однако использование этого для синхронизации дублированных копий общего состояния между циклами событий создает проблемы, которых просто избегают. По сути, вам говорят, чтобы вы проектировали свои реактивные системы, чтобы избежать этого с неизменностью.

Таким образом, вы застряли связаны с одним потоком. Ну и что? Хорошо, если у вас есть вычислительно дорогие операции, такие как криптография безопасности (JWT), это создает проблемы планирования. Находясь в одном потоке, эта операция должна быть завершена, прежде чем что-либо еще может быть предпринято. С несколькими потоками операционная система может разделять время на другие потоки для выполнения других менее ресурсоемких запросов. Однако у вас есть только один поток, так что все это прекрасное планирование потоков операционной системы теперь потеряно. Вы застряли в ожидании завершения дорогостоящих операций с интенсивным использованием процессора, прежде чем обслуживать что-либо еще.

О, пожалуйста, просто игнорируйте эти проблемы! Нам, разработчикам, нравится производительность. Цель Reactive — повысить производительность и масштабируемость. Меньшие потоки позволяют уменьшить накладные расходы, чтобы улучшить пропускную способность. Хорошо, да, у меня будут более эффективные производственные системы, потенциально снижающие затраты на оборудование. Тем не менее, будет намного медленнее создавать и улучшать эту производственную систему из-за ограничений связывания, возникающих из-за однопоточных циклов событий. Не говоря уже о необходимости переписывать алгоритмы, чтобы не перегружать процессор. Учитывая нехватку разработчиков по сравнению с избыточным предложением облачного оборудования, рассуждать о масштабных затратах можно только для тех редких, значительно больших систем.

Мы много теряем, собираясь Реактивно. Возможно, дело в том, что мы недостаточно продумали это. Следовательно, возможно, почему платформы Reactive предостерегают от изменения целой продажи. Они обычно указывают, что шаблоны Reactive работают только для небольших и менее сложных систем.

Поток на запрос (пулы потоков)

С другой стороны, шаблоны потоков на запрос (например, Servlet 2.x) используют пулы потоков для обработки масштаба. Они назначают поток для обслуживания запроса и масштабируют, имея несколько (как правило, объединенных) потоков.

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

Чтобы увидеть эту проблему, просто посмотрите на вызов метода:

1
Response result = object.method(identifier);

Следует ли реализовать способ следующим образом:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
@Inject Connection connection;
@Inject HttpClient client;
 
public Result method(Long identifier) {
 
  // Retrieve synchronous database result
  ResultSet resultSet = connection.createStatement()
    .executeQuery("<some SQL> where id = " + identifier);
  resultSet.next();
  String databaseValue = resultSet.getString("value");
 
  // Retrieve synchronous HTTP result
  HttpResponse response = client.send("<some URL>/" + databaseValue);
 
  // Return result requiring synchronous results to complete
  return new Result(response.getEntity());
}

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

В то время как вызов базы данных является синхронным, вызов HTTP также вынуждает нижестоящую систему отвечать синхронно. Мы не можем изменить HTTP-вызов на асинхронный, поскольку поток запроса хочет продолжить с результатом, возвращаемым из метода. Эта синхронная связь с потоком запроса не только ограничивает вызов, но и ограничивает систему, которая должна обеспечивать синхронный ответ. Следовательно, связь потоков между запросами может загрязнять ваши другие системы и, возможно, всю вашу архитектуру. Неудивительно, что шаблон микро-сервисов REST синхронных HTTP-вызовов так популярен! Это шаблон, который заставляет себя опускаться в вашей системе. Похоже, что поток-на-запрос и Reactive разделяют это же мнение о том, чтобы заставить все сверху вниз поддерживать себя.

Потоки для поддержки ввода / вывода

Таким образом, проблемы заключаются в следующем.

Однопоточные циклы событий:

  • привязать вас только к асинхронному общению (простой код JPA больше не доступен)
  • просто избегает многопоточности, так как два потока, выполняющие события из очереди событий, создают значительные проблемы с синхронизацией (вероятно, замедляют решение и приводят к ошибкам параллелизма, которые трудно закодировать для лучших разработчиков)
  • потерять преимущество планирования потоков, что операционные системы потратили значительные усилия на оптимизацию

Хотя решения для потоков по запросу:

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

Качание маятника между пулами потоков и Reactive однопоточным фактически может рассматриваться как переход от синхронного обмена (поток на запрос) к асинхронному обмену (однопоточные циклы событий). Остальные проблемы — это ограничения реализации потоковой модели, созданной специально для поддержки каждого типа связи. Плюс, учитывая связь в нисходящих системах, которую представляет синхронная связь, это колебание маятника для асинхронной связи не так уж плохо.

Итак, вопрос в том, почему мы вынуждены выбирать только один стиль общения? Почему мы не можем использовать синхронные и асинхронные стили общения вместе?

Ну, мы не можем помещать асинхронные вызовы в синхронные вызовы методов. Там нет возможности для обратных вызовов. Да, мы можем заблокировать ожидание обратного вызова, но Reactive будет считать себя превосходным по масштабу из-за дополнительных накладных расходов, связанных с этим. Поэтому нам нужен асинхронный код, чтобы разрешить синхронные вызовы.

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

Реактив имеет ответ. Используйте планировщик:

1
2
3
Mono blockingWrapper = Mono.fromCallable(() -> {
  return /* make a remote synchronous call */
}).subscribeOn(Schedulers.elastic());

Код взят из http://projectreactor.io/docs/core/release/reference/#faq.wrap-blocking

Да, теперь мы можем делать синхронные вызовы в цикле событий. Проблема решена (ну вроде).

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

В любом случае, мне кажется, это скорее обходной путь, чем реальное решение проблемы. Ой, подождите, все должно быть реактивным сверху вниз, чтобы решить эту проблему. Только не блокируйте звонки и не меняйте все ваши драйверы и весь технологический стек на Reactive. Весь «изменить все так, как нам нужно, таким образом, что он интегрируется только с нами», кажется очень близким к привязке к поставщику технологий — на мой взгляд, в любом случае.

Следовательно, можем ли мы рассмотреть решение, которое допускает синхронные вызовы и не слишком сильно полагается на то, что разработчик делает это правильно? Почему да!

Инвертирующая муфта

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

  • асинхронные функции выполняются с потоком цикла событий
  • синхронные функции, выполняемые с потоком из планировщика

Управление потоком выполнения функции в значительной степени зависит от правильного понимания ее разработчиком. Разработчик имеет достаточно на своей тарелке сосредоточиться на создании кода для удовлетворения требований к функциям. Теперь разработчик тесно вовлечен в многопоточность приложения (что-то в потоке на запрос всегда несколько отвлекается от разработчика). Эта близость к многопоточности значительно увеличивает кривую обучения для создания чего-либо Reactive. Плюс к этому разработчик потеряет много волос, когда они выйдут в 2 часа ночи, пытаясь заставить код работать для этого крайнего срока или производственного исправления.

Так можем ли мы избавить разработчика от необходимости правильно настраивать потоки? Или, что более важно, где мы даем контроль над выбором потока?

Давайте посмотрим на простой цикл событий:

01
02
03
04
05
06
07
08
09
10
public interface AsynchronousFunction {
  void run();
}
 
public void eventLoop() {
  for (;;) {
    AsynchronousFunction function = getNextFunction();
    function.run();
  }
}

Ну, единственное, на что мы можем ориентироваться — это сама асинхронная функция. Используя Executor для указания потока, мы можем улучшить цикл обработки событий следующим образом:

01
02
03
04
05
06
07
08
09
10
11
public interface AsynchronousFunction {
  Executor getExecutor();
  void run();
}
 
public void eventLoop() {
  for (;;) {
    AsynchronousFunction function = getNextFunction();
    function.getExecutor().execute(() -> function.run());
  }
}

Теперь это позволяет асинхронной функции указывать требуемую многопоточность, как:

  • использование потока событий цикла осуществляется через синхронного исполнителя: getExecutor () {return (runnable) -> runnable.run (); }
  • использование отдельного потока для синхронных вызовов осуществляется через Executor с поддержкой пула потоков: getExecutor () {return Executors.newCachedThreadPool (); }

Управление инвертировано, так что разработчик больше не несет ответственности за указание потока. Функция теперь определяет поток для выполнения себя.

Но как мы можем связать Executor с функцией?

Мы используем ManagedFunction инверсии управления :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
public interface ManagedFunction {
  void run();
}
 
public class ManagedFunctionImpl
    implements ManagedFunction, AynchronousFunction {
 
  @Inject P1 p1;
  @Inject P2 p2;
  @Inject Executor executor;
 
  @Override
  public void run() {
    executor.execute(() -> implementation(p1, p2));
  }
 
  private void implementation(P1 p1, P2 p2) {
    // Use injected objects for functionality
  }
}

Обратите внимание, что включены только соответствующие подробности ManagedFunction. Пожалуйста, смотрите Инверсия (Соединение) Управления для более подробной информации о ManagedFunction.

Используя ManagedFunction, мы можем связать Executor с каждой функцией для расширенного цикла обработки событий. (На самом деле, мы можем вернуться к исходному циклу событий, поскольку Executor инкапсулирован в ManagedFunction).

Так что теперь разработчику больше не нужно использовать планировщики, поскольку ManagedFunction заботится о том, какой поток использовать для выполнения логики функции.

Но это только перемещает проблему разработчика, получающего это право от кода до конфигурации. Как мы можем сделать возможным уменьшить ошибку разработчика при указании правильного потока (исполнителя) для функции?

Выбор исполняющего потока

Одним из свойств ManagedFunction является то, что все объекты являются Dependency Injected. Если не введена зависимость, нет ссылок на другие аспекты системы (и статические ссылки крайне не рекомендуется). Следовательно, метаданные Внедрения зависимостей ManagedFunction предоставляют подробную информацию обо всех объектах, используемых ManagedFunction.

Знание объектов, используемых функцией, помогает определить асинхронный / синхронный характер функции. Для использования JPA с базой данных требуется объект Connection (или DataSource). Для синхронных вызовов микро-сервисов необходим объект HttpClient. Если ManagedFunction не потребует ничего из этого, вероятно, можно с уверенностью предположить, что блокирование связи не осуществляется. Другими словами, если ManagedFunction не имеет внедренного HttpClient, он не может выполнять вызовы синхронной блокировки HttpClient. Следовательно, ManagedFunction безопасна для выполнения потоком цикла событий и не останавливает все приложение.

Следовательно, мы можем определить набор зависимостей, которые указывают, требует ли ManagedFunction выполнение отдельным пулом потоков. Поскольку мы знаем все зависимости в системе, мы можем классифицировать их как асинхронные / синхронные. Или, более уместно, безопасно ли использовать зависимость в потоке цикла событий. Если зависимость небезопасна, то ManagedFunctions, требующие этой зависимости, выполняются отдельным пулом потоков. Но какой пул потоков?

Мы просто используем пул потоков? Итак, Reactive Schedulers дает гибкость в использовании / повторном использовании различных пулов потоков для различных функций, связанных с блокировкой вызовов. Следовательно, нам нужна аналогичная гибкость в использовании нескольких пулов потоков.

Мы используем несколько пулов потоков, сопоставляя пулы потоков с зависимостями. Хорошо, это немного, чтобы получить вашу голову вокруг. Итак, давайте проиллюстрируем на примере:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
public class ManagedFunctionOne implements ManagedFunction {
  // No dependencies
  // ... remaining omitted for brevity
}
 
public class ManagedFunctionTwo implements ManagedFunction {
  @Inject InMemoryCache cache;
  // ...
}
 
public class ManagedFunctionThree implements ManagedFunction {
  @Inject HttpClient client;
  // ...
}
 
public class ManagedFunctionFour implements ManagedFunction {
  @Inject EntityManager entityManager;
  // meta-data also indicates transitive dependency on Connection
  // ...
}

Теперь у нас есть конфигурация потока следующим образом:

зависимость Пул потоков
HttpClient Пул потоков один
соединение Второй поток

Затем мы используем зависимости для сопоставления ManagedFunctions с пулами потоков:

ManagedFunction зависимость душеприказчик
ManagedFunctionOne,
ManagedFunctionTwo
(нет в пуле потоков) Event Loop Thread
ManagedFunctionThree HttpClient Пул потоков один
ManagedFunctionFour Соединение (как транзитивная зависимость EntityManager) Второй поток

Решение о пуле потоков (Executor) для использования для ManagedFunction теперь является просто отображением конфигурации. Если зависимость вызывает блокирующие вызовы, она добавляется в сопоставления пула потоков. ManagedFunction, использующая эту зависимость, больше не будет выполняться в цикле потока событий, что позволяет избежать остановки приложения.

Кроме того, вероятность пропущенных блокирующих вызовов значительно снижается. Поскольку относительно легко классифицировать зависимости, это оставляет меньше шансов пропустить блокирующие вызовы. Плюс, если зависимость пропущена, это только изменение конфигурации отображений пула потоков. Это исправлено без изменений кода. Что-то особенно полезное, поскольку приложение растет и развивается. Это не похоже на Reactive Schedulers, которые требуют изменений кода и серьезного обдумывания разработчиком.

Поскольку исполняющий поток для выполнения ManagedFunction теперь контролируется платформой (а не кодом приложения), он эффективно инвертирует управление исполняющим потоком. Больше не работает многопоточность кода разработчика. Каркас настраивает его на основе характеристик зависимостей ManagedFunctions.

OfficeFloor

Это все хорошо в теории, но покажи мне рабочий код!

OfficeFloor ( http://officefloor.net ) представляет собой реализацию инверсии шаблонов управления потоками, обсуждаемых в этой статье. Мы находим фреймворки слишком жесткими с их моделями потоков, которые вызывают обходные пути, такие как Reactive Schedulers. Мы ищем базовые шаблоны для создания структуры, которая не требует таких обходных путей. Примеры кода можно найти в руководствах, и мы ценим все отзывы.

Обратите внимание, что, хотя OfficeFloor следует инверсии управления потоками, его фактическая модель потоков более сложна, чтобы принимать во внимание другие аспекты (например, контекст зависимости, состояние мутирования, локальность потоков, сходство потоков, обратное давление и снижение блокировки для повышения производительности). Это, однако, темы для других статей. Но, как подчеркивается в этой статье, многопоточность для приложений OfficeFloor представляет собой простой файл конфигурации, основанный на сопоставлениях зависимостей.

Вывод

Инверсия управления потоком позволяет функции указывать свой собственный поток. Поскольку поток управляется внедренным исполнителем, этот шаблон называется « Внедрение потока». При разрешении инъекции выбор потока определяется конфигурацией, а не кодом. Это освобождает разработчика от потенциально подверженной ошибкам и ошибочной задачи кодирования потоков в приложения.

Дополнительным преимуществом Thread Injection является то, что конфигурации сопоставления потоков можно адаптировать к машине, на которой выполняется приложение. На машине с множеством процессоров можно настроить больше пулов потоков, чтобы использовать преимущества планирования потоков операционной системой. На меньших машинах (например, встроенных) может быть больше повторного использования пулов потоков (возможно, даже нет для одноцелевых приложений, которые могут переносить блокировку, чтобы сократить счет потоков). Это не повлечет за собой никаких изменений кода в вашем приложении, только изменения конфигурации.

Кроме того, вычислительно дорогие функции, которые могут связать цикл обработки событий, также могут быть перемещены в отдельный пул потоков. Просто добавьте зависимость для этого вычисления в сопоставления пула потоков, и все ManagedFunctions, выполняющие вычисления, теперь не задерживают поток цикла событий. Гибкость Thread Injection не просто поддерживает синхронную / асинхронную связь.

Так как Thread Injection полностью зависит от конфигурации, оно не требует изменений кода. Это фактически не требует какого-либо многопоточного кодирования от разработчика вообще. Это то, что Реактивные Планировщики не в состоянии предоставить.

Таким образом, вопрос заключается в том, хотите ли вы привязать себя к однопоточному циклу событий, который на самом деле является просто реализацией единственного назначения для асинхронного ввода-вывода? Или вы хотите использовать что-то более гибкое?

Смотрите оригинальную статью здесь: Spring Reactive уже устарел? Инверсия резьбового соединения

Мнения, высказанные участниками Java Code Geeks, являются их собственными.