Сегодня я хочу выяснить, готов ли Project Loom заменить Spring WebFlux при создании высоконагруженных приложений с высокой пропускной способностью.
Проблемы реактивного подхода
WebFlux - замечательная технология с фантастической производительностью, однако:
При использовании реактивного подхода код сложнее писать и сопровождать
Стектрейсы малополезны при разборе ошибок
Все связанные клиенты/библиотеки также должны быть написаны в реактивном стиле
Что такое Project Loom
В статусе превью-фичи с Java 19, разработка стартовала в 2017
Основное нововведение - виртуальные потоки, призванные значительно снизить трудозатраты на написание и сопровождение приложений
Предполагается возможность создавать миллионы виртуальных потоков. (Прим. пер. мне кажется, автору стоило явно выделить основную мысль. Поскольку виртуальные потоки дешевы, то запуск блокирующего кода в таком потоке тоже дешевая операция, т.к. реальный системный поток при этом не блокируется и может заняться чем-нибудь полезным )
Предполагается минимальное вмешательство в существующий код
Стек виртуальных потоков хранится в хипе JVM
Есть мнение, что Project Loom способен решить проблемы применения реактивной парадигмы. Но что насчет производительности?
Тестовый сценарий
Мы собираемся проверить производительность сервиса, выполняющего задачу проксирования запроса к некоему третьему сервису, который возвращает ответ с задержкой в 500мс. Исходный код здесь.
Мы проверим 3 реализации одного и того же сервиса:
Spring Boot (Tomcat) + Project Loom
Spring Webflux
Spring Webflux + Project Loom
Железо:
Все тесты крутятся на серверах AWS EC2
Подопытный сервис крутится на ноде t2.micro (1 CPU, 1 GB)
Третий сервис крутится на ноде t2.medium (2 CPU, 4GB)
Нагрузка создается еще одним внешним EC2
Tomcat + Loom
Необходимо кастомизировать настройки Spring Boot, чтобы Tomcat использовал виртуальные потоки вместо своего стандартного пула. Далее используем обычный контроллер Spring MVC
@Configuration
public class Config {
@Bean
AsyncTaskExecutor applicationTaskExecutor() {
// enable async servlet support
ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
return new TaskExecutorAdapter(executorService);
}
@Bean
TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreadExecutorCustomizer() {
return protocolHandler -> protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
}
}
@RestController
public class Controller {
private final RestTemplate restTemplate = new RestTemplate();
private final String host = "http://test:7000/address/";
@GetMapping("/address/{timeout}")
String getAddress(@PathVariable long timeout) throws URISyntaxException {
URI uri = new URI(host + timeout);
return restTemplate.getForObject(uri, String.class);
}
}
WebFlux
Для реализации с WebFlux я использую http-клиент WebClient. Стандартные настройки немного изменены для поддержки большого числа подключений.
@RestController
public class Controller {
private final WebClient webClient = init();
private final String host = "http://test:7000/address/";
private WebClient init() {
String connectionProviderName = "myConnectionProvider";
HttpClient httpClient = HttpClient.create(ConnectionProvider.builder(connectionProviderName)
.maxConnections(10_000)
.pendingAcquireMaxCount(10_000)
.pendingAcquireTimeout(Duration.of(100, ChronoUnit.SECONDS))
.build()
);
return WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient)).build();
}
private Mono<String> getAddressInternal(long timeout) {
return webClient.get()
.uri(host + timeout)
.exchangeToMono(clientResponse -> clientResponse.bodyToMono(String.class))
.timeout(Duration.ofSeconds(200));
}
@GetMapping("/address-reactive/{timeout}")
Mono<String> getAddress(@PathVariable long timeout) {
return getAddressInternal(timeout);
}
}
WebFlux + Project Loom
Здесь мы будем вызывать некий блокирующий код, но на пуле виртуальных потоков Executors.newVirtualThreadPerTaskExecutor(). Результат вызова блокирующего кода оборачиваем в Mono.
@GetMapping("/address-loom/{timeout}")
Mono<String> getAddressWithLoom(@PathVariable long timeout) {
return Mono.fromFuture(
CompletableFuture
.supplyAsync(() ->
getAddressInternal(timeout).block(),
Executors.newVirtualThreadPerTaskExecutor()
));
}
Результаты
Tomcat + Loom показывает неудовлетворительные результаты (Прим. пер. Есть основания полагать, что "просто" Tomcat не показал бы даже и таких). Пропускная способность невысока из-за высокой активности GC (~50CPU).
Связка Tomcat + Loom неспособна справиться с нагрузкой в 4k параллельных запросов, а для 8k запросов уже происходит OOM.
После анализа heap dump ясно, что почти вся память занята инстансами SocketWrapper, созданными Tomcat. Это легко объяснить, т.к. дизайн Tomcat предполагает модель "1 запрос - 1 поток". Поэтому обертки сокетов слишком "тяжелы", и использование их в связке с виртуальными потокам неэффективно.
Сравним профили WebFlux и WebFlux + Loom
Профили нагрузки на память, CPU и GC похожи. Поэтому мы наблюдаем похожую пропускную способность, хотя для 10k запрос Loom даже вырывается вперед.
Итог
Project Loom это "game changer". Мы показали, что виртуальные потоки эффективны, и позволяют писать простой привычный блокирующий код, который может быть столь же , как код реактивный/неблокирующий. Это означает, что мы сможем легко мигрировать наш блокирующий код на Loom и продолжать использовать код нереактивных библиотек типа Hibernate. Но мы все еще нуждаемся в связке Netty+WebFlux в качестве обертки для блокирующего кода, т.к. Tomcat пока что by-design не подходит для этой задачи.
P.S. Ограничения Project Loom
Системный поток все же может быть заблокирован, если внутри виртуального потока есть:
Вызовы нативного кода
Синхронизированный участок кода/метод. Решение: использовать ReentrantLock и -Djdk.tracePinnedThreads=full
Это означает, что существующие библиотека должны быть отрефакторены с заменой ключевого слова synchronized на ReentrantLock. См. https://github.com/pgjdbc/pgjdbc/issues/1951