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