Команда Spring АйО перевела и адаптировала доклад «Concurrency in Spring Boot Applications: Making the Right Choice» Андрея Шакирина с последнего Devoxx Belgium.
В докладе автор рассказал про различные подходы по работе с concurrency в целом, а также именно в Spring Boot приложениях.
Главная цель этой статьи состоит не в том, чтобы глубоко погружаться в разные подходы к concurrency. Это, скорее, общий обзор, дающий представление о том, какие подходы к concurrency лучше соответствуют различным сценариям использования в рамках разных типов приложений.
Прежде всего мы пройдемся по самым распространенным вопросам о concurrency, таким как: почему нам нужна concurrency, почему вообще нужно запускать что-то в параллельном режиме и какие сценарии использования concurrency существуют в мире разработки ПО.
Затем мы перечислим и опишем опции, которые существуют в Java и в Spring Boot, а также достоинства и недостатки каждого подхода. В конце мы проанализируем, почему и какой сценарий использования лучше подходит тому или иному подходу к concurrency.
Итак, почему мы должны выбирать один или другой подход к concurrency для специфических сценариев использования?
Сценарии использования concurrency
Веб-сервер
Первый очень распространенный сценарий, который встречается в приложениях на практике — это бэкенд собственной разработки, своего рода веб-сервер, включающий удаленное API. Данный веб-сервер обслуживает не один запрос в секунду, он принимает многочисленные запросы, и написанные под данный сервер клиентские приложения должны быть структурированы таким образом, чтобы работать с этим сервером в режиме concurrency.
Обычно в этом сценарии программист может повлиять не на многое: он только конфигурирует, сколько потоков и какого типа производит веб-приложение, но не реализует сам механизм.
Следующий сценарий, весьма похожий на первый, — это когда приложение прослушивает какую-то очередь, какую-то систему, выдающую сообщения.
Как и в предыдущем случае, мы принимаем какие-то сообщения, и мы не обязаны делать это последовательно. Напротив, мы можем создавать несколько потоков, несколько слушателей, которые будут работать параллельно, так что мы можем потреблять сообщения также в параллельном режиме. Опять-таки, наш код должен подходить для этого. Но, как правило, мы не контролируем и этот механизм.
Так каковы же основные сценарии применения concurrency внутри нашей бизнес-логики? Довольно типичный случай — это Fork and Join.
Fork and Join
Если наша задача может быть выполнена какими-то чанками параллельно то мы можем распараллелить некоторые операции и затем подождать выполнения этих задач и потом выполнить агрегирование результатов этих операций.
Этот сценарий можно разбить на несколько подвариантов. Итак, в одном случае у нас также есть вот это:
В данном случае у нас есть некая задача, требовательная к ресурсам CPU, например, операции асимметричного шифрования или сжатия видео.
Примером Fork and Join также могут быть операции, так или иначе связанные с вводом-выводом (input/output). В рамках таких операций мы можем делать запросы в базу данных, чтобы получать результаты этих запросов, мы можем вызвать удаленный сервис, можем работать с файловой системой или с брокером сообщений.
Существует также подвариант, включающий сложную логику. Представьте себе, что у нас есть потоки данных (data streams). В проекте, состоящем из фронтенда и бэкенда, возможно реализовать функциональность щелчка мышью по представлению таких потоков в интерфейсе, но при этом вся логика по обработке такого события реализована на бэкенде, что требует аккуратного сопоставления id потоков в обоих компонентах приложения в реальном времени.
Еще один подвариант этого случая. Иногда наши задачи взаимосвязаны.
Понятие взаимосвязанности по сути означет следующее: если одна из задач закончилась неудачей, не имеет смысла продолжать выполнение другой задачи, надо прервать и ее тоже.
Либо наоборот, если одна из задач успешна, тогда тоже нет смысла продолжать, если результат уже понятен.
Fire and Forget
Еще один, третий, случай concurrency — это fire and forget, то есть мы просто посылаем email или нотификацию, посылаем сообщение в брокер, но нас не интересует, когда и как законится этот процесс, и мы не используем результат этой отправки в нашем основном рабочем процессе. Наш основной рабочий алгоритм просто работает независимо от ее результата.
Таковы наиболее распространенные сценарии использования concurrency в приложениях. Давайте теперь посмотрим на то, какие опции по concurrency мы имеем в настоящее время в Java и в Spring Boot.
Java и Spring Boot опции для concurrency
Прежде всего мы, конечно, можем использовать Executors и Futures. Это не так часто происходит в проектах, но все же многие люди используют классическое Future. Мы также можем применить Completable Future, что является более современной, а значит, более предпочтительной опцией. Spring Boot имеет аннотацию @Async для обоих случаев, что помогает реализовать требуемый функционал меньшим количеством кода.
Кроме того, мы имеем Reactive Paradigm, Reactive Programming, и эту парадигму также все еще можно использовать.Кроме того, начиная с Java 21, у нас есть виртуальные потоки и в будущем у нас появится Structured concurrency.
Давайте пройдемся по каждой опции и посмотрим, какие есть достоинства и недостатки у каждой из них и как они соотносятся с упомянутыми выше сценариями использования.
Futures & Executors
Давайте начнем с классических Futures & Executors. В Spring Boot у нас есть возможность конфигурировать их как бины, так что я могу сконфигурировать разные виды executors. Например, здесь для одного бина я установил большое количество параметров, а для другого просто использую стандартный фиксированный пул executor’ов.
@Configuration
public class AsyncConfig {
@Bean(name = “taskExecutor")
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(3); // Number of core threads
executor.setMaxPoolSize(S); // Maximum number of threads
executor.setQueueCapacity(10); // Queve capacity before rejecting tasks
executor.setThreadNamePrefix("Async-");
executor.initialize();
return executor;
}
@Bean(name = "executorService")
public ExecutorService executorService(){
// Configure a thread pool with a fixed number of threads
return Executors.newFixedThreadPool(3);
}
}
Когда нам необходимо отправить нашу задачу на исполнение в executor, мы можем сделать это вручную, то есть, по сути, объявить executor, заинжектировать его в сервис и отправить задачу на исполнение. Альтернативное решение — использовать аннотацию @Async из Spring Boot.
Она сделает эту работу за нас. В этом случае достаточно аннотировать наш метод аннотацией @Async
и отправить сообщение во Future
. В этом случае переменная типа AsyncResult
будет являться оберткой для результата в Future
. Этот вариант является deprecated, и сейчас принято использовать CompletableFuture
, но такой устаревший код все еще часто встречается на проектах.
Как выполнять задачи в параллельном режиме
@GetMapping("/run-tasks-future")
public String runTasksFuture() throws InterruptedException,
ExecutionException {
Future < String > task1 = taskServiceFuture.processTask1();
Future < String > task2 = taskServiceFuture.processTask2();
Future < String > task3 = taskServiceFuture.processTask3();
String result = task1.get();
String result2 = task2.get();
String result3 = task3.get();
return "Results: " + resultl + ", " + result2 + ", " + result3;
}
Мы создаем задачу, используя сервис, и затем вызываем метод get()
. Он блокирует ее до тех пор, пока не закончится другая задача или пока другая задача не упадет с неудачным завершением. И даже если task1
займет больше времени, чем task2
и task3
— task2
и task3
могут уже завершиться — блокировка сохранится.
CompletableFuture
Это немного более современный способ работать с concurrency, и в этом случае executors те же самые. Мы также можем отправить наши задачи на выполнение вручную с помощью метода supplyAsync()
.
@Service
public class TaskServiceStandalone {
public CompletableFuture < String > processTaski() {
return CompletableFuture.supplyAsync(() - > {
try {
Thread.sleep(2000);
return "Task 1 completed";
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
}
Но мы также можем использовать ключевое слово @Async
:
@Service
public class TaskService {
@Async
public CompletableFuture < String > processTask1() throws InterruptedException {
Thread.steep(2000);
return CompletableFuture.completedFuture("Task 1 completed");
}
@Async
public CompletableFuture < String > processTask2() throws InterruptedException {
Thread.sleep(3000);
return CompletableFuture.completedFuture("Task 2 completed");
}
}
В этом случае будет возвращен результат типа CompletableFuture
.
Что действительно приятно с CompletableFuture, мы можем комбинировать задачи, как нам хочется, используя операции combine и compose (как вы можете видеть на иллюстрации, соответствующие методы в коде называются thenCombine()
и thenCompose()
).
Например, в первом случае, где применяется thenCombine()
, три задачи будут выполнены параллельно.
Далее мы агрегируем результаты двух первых задач и затем агрегируем результат третьей задачи. Если же говорить о thenCompose()
, как показано во втором примере, три задачи будут выполнены параллельно, а четвертая будет выполнена последовательно и будет использовать результат предыдущих вызовов.
Также интересно, что у обоих методов есть версия с ключевым словом Async в названии.
То есть, мы можем выполнять операции thenCombineAsync()
и thenComposeAsync()
. Но ведь мы и так запускаем наши процессы в параллельном режиме, зачем нам нужно еще больше асинхронности?
По крайней мере, для случая операции Compose это немного более понятно, потому что мы запускаем ее последовательно, но для Combine у нас уже есть параллельный запуск, так зачем же нам нужен дополнительный Async?
На самом деле все просто. Ключевое слово Async просто говорит нам в случае с Compose, что наша последовательная задача определенно будет использовать другой поток, так что мы берем поток из executor’а, который мы передаем, и делаем запуск в другом потоке, чтобы не влиять на основной поток. А в случае с Combine в отдельном потоке будет работать агрегирующая функциональность. И мы можем предоставить для этого executor.
Так что, если мы посмотрим на то, как это работает, в случае с просто Compose, worker используется повторно,мы запускаем две последовательные задачи и используем один и тот же поток.
Если же мы делаем это в режиме Async, вторая задача все еще последовательная, но она запускается в другом потоке.
В случае с Combine опять-таки, если мы используем просто Combine, один поток, worker-поток, будет переиспользоваться.
А в случае с Async берется другой поток для выполнения операции комбинирования.
Теперь уясним, каковы сценарии использования для Future, CompletableFuture и Async. Также следует понимать, что при использовании аннотации @Async
Spring Boot выполняет не только submit()
или supplyAsync()
, он может также ловить исключения и передавать их в CompletableFuture
при неудачном завершении последнего.
Главные сценарии использования в этом случае — это задачи типа fork & join или fire & forget, для которых требуется, чтобы мы могли создавать и запускать задачи в режиме concurrency.
Spring Boot аннотация @Async
помогает отлавливать исключения и также оборачивает шаблонный код (boilerplate code), чтобы отправлять задачи на исполнение, поэтому мы очень рекомендуем к ней присмотреться, если вы еще не используете эту аннотацию.
Используя Future, мы получаем механизм для запуска и получения результата, но CompletableFuture — это более современный и предпочтительный стиль для программирования операция Combine и Compose для задач. Это все, что можно сказать о сценариях использования для опции Futures and Executors, поэтому двигаемся дальше.
Проблема блокирования ввода/вывода
Существует проблема, которую трудно решить, используя подход Future или Completable Future. В чем она состоит? Представьте себе, что у нас три задачи, как на иллюстрации выше, и каждая задача прикреплена к платформенному потоку. С третьей задачей все довольно просто, это просто задача на вычисление, и поток используется достаточно хорошо, но с первой и второй задачей все несколько сложнее, потому что мы должны подождать ответа от базы данных или от удаленного API, и в этом случае платформенный поток здесь блокируется. Мы просто ждем, но ничего не происходит.
Как решить эту проблему? В этом случае использование платформенного потока обходится весьма дорого и не является оптимальным, поэтому возможное решение для проблемы следующее: мы можем разделить нашу длинную обязательную задачу в несколько маленьких лямбд (callback’ов), и задача фреймворка в этом случае — организовать эти callback’и оптимальным образом и минимизировать использование платформенного потока.
Если платформенный поток ждет, он может в это же время сделать что-то, относящееся к другому callback’у или к другому вычислению.
Идем дальше.
Reactive Paradigm
Имплементацией вышеупомянутого фреймворка является Reactive Paradigm. Как это работает в Reactive Paradigm?
У нас есть события, у нас есть publisher и subscriber, где мы в основном регистрируем наши лямбды (callback’и). События могут приходить в publisher из разных источников. Они могут прийти в результате завершения вызова удаленного API. Или наш запрос к базе данных завершен, и у события есть результат.
Может также существовать некий поток (stream) данных, который мы обрабатываем. Очень важная часть Reactive Paradigm — это цикл событий, который получает события из очереди и распределяет их между callbacks и publisher.
Также интересной функцией Reactive Paradigm является контроль backpressure.
Subscriber может сказать: «Да, здесь очень много данных, и я не могу продолжать с этим работать, так что, пожалуйста, либо проведите буферизацию, либо замедлите продакшен». Это то, что представляет собой Reactive.
Пример Reactive — Rest controller
Давайте посмотрим на то, как реализовать Reactive в Spring Boot.
@RestController
public class UserClickController {
private final UserClickService userClickService;
public UserClickController(UserClickService userClickService) {
this.userClickService = userClickService;
}
@GetMapping("/user-groups/{userGroupId}/clicks")
public Flux < UserClicks > getUserClicks(@PathVariable String userGroupId) {
return userClickService.getUserGroupClicks(userGroupId);
}
}
Когда мы работаем с Reactive стеком, очень важно следить за тем, чтобы весь стек был Reactive. Поэтому, если у нас, например, контроллер, не является Reactive, или база данных не является Reactive, это не имеет смысла, потому что где-то происходит блокировка, и цикл событий будет ждать в блокировке. Поэтому здесь мы используем WebFlux, мы просто возвращаем Flux
, то есть потоки (streams) от контроллера и репозиторий.
package com.example.async.demo.reactive;
import com.example.async.demo.reactive.model.User;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import reactor.core.publisher.Flux;
public interface UserRepository extends ReactiveCrudRepository < User, String > {
Flux < User > findByUserGroup(String userGroup);
}
В этом случае мы реализовали Reactive репозиторий для базы данных, и в качестве бизнес логики в этом случае мы имеем поток (stream) данных о пользователях из репозитория, события Click, и есть некоторая логика для использования flatMap
, чтобы объединить эти потоки (streams) вместе.
То есть, мы, базируясь на ID, коррелируем потоки и объединяем их, и это называется Reactive.
В Spring Boot есть очень хорошая поддержка стека Reactive, уровень API предоставляет WebFlux контроллеры и клиенты. Через бизнес-логику вы также можете использовать zip, flatMap, filter и другую функциональность.
Также для работы с базами данных в Spring Boot присутствует хорошая поддержка многочисленных драйверов СУБД, таких как Redis, Neo4j и другие. То же самое можно сказать и о поддержке брокеров сообщений, есть очень хорошая поддержка стека Reactive, достаточно просто запустить драйвер, и он работает из коробки.
Подытожим. Reactive Paradigm может решить множество проблем, как например асинхронная обработка без блокирования, более эффективное использование платформенных потоков, она довольно хорошо масштабируется и обеспечивает контроль backpressure.
Но есть и обратная сторона вопроса. Любая опция, имеющая свои преимущества, обязательно будет иметь и какие-то недостатки.
Главным недостатком Reactive Paradigm по праву считается сложный код. Отладка этого кода тоже очень сложная, так что работа с реактивным кодом занимает больше времени, чем работа с другими опциями, и все еще вы будете находить многочисленные ошибки при отладке и запуске приложения.
Кроме того, необходимо, чтобы все зависимости тоже были reactive, чтобы готовое решение можно было запустить. И если у вас присутствуют только задачи, требовательные к ресурсам CPU, то подход, основанный на Reactive Paradigm, не имеет особого смысла.
Соответственно, использовать стек Reactive рекомендуется только если у вас есть требования объединять ваши потоки данных, у вас есть очень сложная логика комбинирования Kafka потоков (streams) или Kafka таблиц (tables).
Виртуальные потоки (Virtual Threads)
И вот вопрос: зачем нам разделять наш код, наши императивные задачи на маленькие callbacks и организовывать все это в циклы событий, превращая простой код в сложный? Не может ли виртуальная машина Java взять на себя немножко больше ответственности, чтобы сделать это самостоятельно?
И ответом на этот вопрос является использование виртуальных потоков. Как это работает? По умолчанию используются платформенные потоки Java Virtual Machine, базирующиеся на корутинах платформы, и они весьма дорогостоящие, поэтому этот вариант годится только для того, чтобы зарезервировать стек между половиной мегабайта и мегабайтом. При использовании же виртуальных потоков JVM создает легковесные потоки, которые стоят только один-два килобайта.
По итогу стандартный ноутбук может управлять миллионом виртуальных потоков. И действительно приятной возможностью является то, что виртуальные потоки могут закрепляться за платформенными потоками, но как только виртуальный поток вызывает какую-то блокирующую I/O операцию, связанную с базой данных или удаленным API, он будет немедленно откреплен.
Поскольку эти потоки весят так мало, не имеет смысла создавать пул потоков, так что мы можем очень легко создавать и удалять их по мере необходимости. В этом случае открепленный виртуальный поток просто автоматически удаляется. И когда в этом возникнет необходимость, новый виртуальный поток будет создан и прикреплен, возможно, уже к другому платформенному потоку.
Это помогает использовать наши платформенные потоки рациональнее. Они не должны ждать I/O операций, и как только наш результат готов, виртуальный поток будет снова прикреплен к платформенному потоку и сможет продолжать.
Активация виртуальных потоков в Spring Boot очень простая.
@Configuration
public class AsyncConfigVT {
@Bean(name = "executorService")
public ExecutorService executorService() {
return Executors.newVirtualThreadPerTaskExecutor();
}
}
Нам лишь надо создать и предоставить другой executor для виртуального потока.
Compose & Combine
Вопрос состоит в том, нужны ли нам Future и CompletableFuture при работе с виртуальными потоками. Решает это нашу проблему или нет? Нужны ли они нам при выборе опции виртуальных потоков?
Итак, если у вас есть виртуальные потоки и все, которые виртуальная машина делает за нас, надо ли нам создавать наши задачи и отправлять их на выполнение в executors? Или мы можем просто написать блок кода, и будет работать?
@GetMapping("/run-tasks-vt")
public String runTasks() throws InterruptedException, ExecutionException {
Future < String > taskl = taskService.processTask1();
Future < String > task2 = taskService.processTask2();
Future < String > task3 = taskService.processTask3();
String combinedResult = task1.get() + "," + task2.get() + "," + task3.get();
String composedResult = taskService.processTask4(combinedResult).get();
return "Results: " + combinedResult + "; " + composedResult;
}
В целом в данном коде присутствует все то же самое, что и раньше, то есть это не просто какая-то магия, присутствующая в виртуальных потоках. Так что давайте посмотрим на другой код, давайте вообразим, что у нас есть два репозитория: репозиторий пользователей и репозиторий задач, и нам необходимо получить пользователей из репозитория, получить задачу и отправить ее на выполнение во внешний сервис.
@Service
public class TaskServiceVT {
private final UserRepositry userRepositry;
private final TaskRepositry taskRepository;
private final TaskExternalService taskExternalService;
public TaskServiceVT(UserRepository userRepository,
TaskRepository taskRepository,
TaskExternalService taskExternalService) {
this.userRepositry = userRepository;
this.taskRepository = taskRepository;
this.taskExternalService = taskExternalService;
}
}
@Async
public CompletableFuture < List < String >> submitTasks(String userGroupId) {
List < Users > users = userRepositry.findByGroupId(userGroupId);
List < Task > tasks = taskRepository.findActiveTasks(userGroupId);
List < String > ids = taskExternalService.submitTasks(users, tasks);
return CompletableFuture.completedFuture(ids);
}
Даже если мы активируем виртуальные потоки, это не будет автоматически распараллеливать наш запуск, он будет последовательным. Возможно, имеет смысл распараллелить все эти запросы, потому что они полностью независимые, они просто используют групповой ID и извлекают пользователей, а задачи работают полностью независимо, но переход к параллельному выполнению не произойдет “из коробки” только потому, что мы теперь работаем с виртуальными потоками, то есть они все еще выполнят их последовательно.
При этом, как только у нас появится блокирующий API в репозитории базы данных или во внешнем сервисе, платформенный поток будет откреплен и не сможет быть использован для другой задачи, так что, если наше приложение содержит много запросов и ограниченное количество платформенных потоков, оно будет работать эффективно, потому что такое же количество платформенных потоков будет обслуживать больше запросов от пользователей. Однако сам по себе одиночный запрос не станет быстрее.
Он даже может стать немного медленнее, но в целом использование платформенных потоков будет организовано более оптимально при использовании виртуальных потоков.
Так что переход к использованию виртуальных потоков не будет являться замещением для наших Futures и CompletableFutures. Это лишь способ, позволяющий использовать платформенные потоки в приложении наилучшим образом.
В Spring Boot также возможно активировать виртуальные потоки в Tomcat. Нам надо только задать свойства. Это очень просто, надо лишь сказать приложению, что “свойство включено” (как на иллюстрации выше), и Tomcat, как и в целом все наше приложение, вся бизнес-логика, будет использовать виртуальные потоки из коробки.
К сожалению, виртуальные потоки тоже имеют некоторые недостатки, определенные скрытые ловушки. Во-первых, они не очень эффективны, когда у нас есть только задачи, требовательные к ресурсам CPU. Они очень хороши, когда у вас есть какие-то блокирующие вводы/выводы, что-то вроде вызовов базы данных, вызовов брокера сообщений или внешних сервисов.
Также бывают ситуации, когда виртуальный поток прикреплен к платформенному потоку и не может быть откреплен. Это случается с нативными вызовами и, к сожалению, это может случиться внутри синхронизированного блока. Так что, если у вас есть библиотека, написанная третьей стороной, синхронизированный блок может стать проблемой, открепление виртуального потока может оказаться невозможным.
Этот очень интересный эффект. Например, Jackson использует ThreadLocal, чтобы закешировать некоторые данные в потоке, потому что платформенный поток возвращен в пул, и потом он берет этот поток из пула в следующий раз, и он мог бы повторно использовать эту закешированную информацию, и это не работало бы с виртуальным потоком, потому что виртуальные потоки не помещаются в пул. Они создаются заново каждый раз.
И некоторые библиотеки могут даже показывать худшую производительность на виртуальных потоках.
Что интересно, иногда также возникает ситуация, вызывающая deadlock, которую находят в пулах подключений к базе данных. Идея состоит в том, что если пулы подключений опустошаются, а платформенный поток блокируется, чтобы дождаться, когда подключение вернется, но подключение принадлежит виртуальному потоку, а виртуальный поток ждет платформенного потока, чтобы прикрепиться к нему, и эти два блокера образуют deadlock.
Виртуальные потоки также могут не работать из коробки с некоторыми библиотеками, такими как HttpRequest и Apache HttpClient, потому что иногда эти библиотеки создают платформенный поток в явном виде, который не работает с виртуальным потоком или имеет синхронизированный блокер.
Еще один побочный эффект, который может проявиться в существующих проектах, связан с повышением нагрузки, потому что приложения работают лучше с платформенными потоками. При переходе на виртуальные потоки приложения начинают производить гораздо больше вызовов к внешней системе за то же время, и это может стать проблемой для этих внешних систем.
Поэтому рекомендации для виртуальных потоков следующие:
Избегать синхронизированных блокеров, используя вместо них ReentrantLock
Защищать или как минимум проверять внешние сервисы на способность выживать в условиях превышений по нагрузке
Иногда ThreadPools используются как ограничители (throttling), позволяющие иметь только три или пять или 10 или 20 параллельных потоков, но эта концепция не будет работать с виртуальными потоками, для этого нам нужен другой механизм
Главный случай применения виртуальных потоков — это блокирующие задачи со вводом/выводом, либо просто задачи, требовательные к ресурсам процессора без ввода/вывода; мы все еще можем переключать контекст, используя yield() или разбивать задачу на несколько независимых подзадач. При этом существует возможность делать это параллельно.
Еще одна рекомендация состоит в том, чтобы мигрировать операционное приложение на виртуальные потоки не сразу, а итерациями и использовать нагрузочные тесты, стресс-тесты, canary-релизы, blue-green deployment, поскольку иногда не обходится без побочных эффектов. Поэтому очень важно хорошо тестировать приложение и быть осторожными в продакшен, когда мы активируем виртуальные потоки и помещаем их в настоящий продакшен под реальную прод нагрузку. Если вы хотите узнать больше о виртуальных потоках, можно обратиться к материалам от Виктора и Ханно (Victor Rentea и Hanno Embregts), они оба являются спикерами на Devoxx, у них обоих есть очень хороший подкаст, а также видео и статьи на тему виртуальных потоков.
Structured Concurrency
Эта опция появится в Java и Spring Boot лишь в будущем, но уже очень скоро, и будет использоваться как раз для взаимосвязанных задач, когда, например, первая задача завершилась неудачей, и не имеет смысла продолжать выполнять следующую задачу, и у нас есть над этим контроль. Или наоборот, случай успеха. Первая задача завершилась успешно, и мы можем остановиться и прервать другие потоки, так как желаемый результат уже получен. Именно таков основной сценарий применения Structured concurrency.
Применение concurrency в приложениях
Давайте просуммируем все сказанное и посмотрим, как и когда применять все описанные выше подходы к concurrency. Вкратце процесс принятия решения о выборе наилучшего подхода показан на блок-схеме на иллюстрации. Если же описывать его словами, то это будет выглядеть как описано ниже.
Представим себе, что у нас есть fork & join задачи, и в первую очередь мы проверим, если у нас есть некоторые сложные данные для объединения потоков (streams). Если они у нас есть, возможно, мы можем использовать Reactive Paradigm. Затем проверяем, если ли у нас блокирующий ввод/вывод, что значит, что приложение много работает с вызовами внешних сервисов, баз данных. Если ответ «да», то определенно используем виртуальные потоки, это хороший выбор, но делаем мы это у учетом всех описанных выше недостатков данного подхода, а значит, действуем осторожно. Работаем с итерациями при миграции и осуществляем регулярные проверки через нагрузочные тесты и стресс-тесты.
Если ответ «нет», тогда у нас есть выбор, либо остаться с платформенными потоками, это абсолютно нормально, с Future, CompletableFuture и @Async
, либо все еще используем виртуальные потоки, но немного разбиваем свои задачи, либо с помощью chunks, либо используя yield()
, при этом обеспечивая возможность открепления виртуального потока от платформенного потока и давая возможность для другого потока работать в параллельном режиме.
Для задач типа fire and forget мы можем идти напрямую к последней проверке, потому что вряд ли мы тут имеем какое-то объединение данных, у нас нет интереса к результату при работе с задачами типа fire and forget.
И еще одна маленькая ветка, не поместившаяся в основную блок-схему: если у нас есть взаимосвязанные задачи, тогда это случай для Structed Concurrency, которая появится в следующем году.
На этом все ?.
Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.
PerformanceLabTech
Интересно, спасибо