Project Loom и Spring Boot: тесты производительности
Сегодня я хочу выяснить, готов ли 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