Сегодня я хочу выяснить, готов ли 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 параллельных запросов
Tomcat +Loom для 4k параллельных запросов

Связка Tomcat + Loom неспособна справиться с нагрузкой в 4k параллельных запросов, а для 8k запросов уже происходит OOM.

После анализа heap dump ясно, что почти вся память занята инстансами SocketWrapper, созданными Tomcat. Это легко объяснить, т.к. дизайн Tomcat предполагает модель "1 запрос - 1 поток". Поэтому обертки сокетов слишком "тяжелы", и использование их в связке с виртуальными потокам неэффективно.

Tomcat +Loom: heap dump report
Tomcat +Loom: heap dump report

Сравним профили WebFlux и WebFlux + Loom

Webflux: 8k параллельных запросов
Webflux: 8k параллельных запросов
Webflux+ Loom: 8k параллельных запросов
Webflux+ Loom: 8k параллельных запросов

Профили нагрузки на память, 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

Комментарии (2)


  1. mayorovp
    04.07.2023 09:53

    NioSocketHandler внутри как раз использует synchronized, тут все преимущества Loom теряются. Интересно, можно ли переключить Tomcat из режима nio на обычные сокеты, вроде же Loom как раз хотел именно их случай оптимизировать?


    А вообще, отсутствие поддержки synchronized убивает преимущества Loom. Хотели же сделать нечто что будет ускорять старый код автоматически, а в итоге его всё равно надо переписывать.


  1. sshikov
    04.07.2023 09:53
    +1

    Это означает, что мы сможем легко мигрировать наш блокирующий код на Loom

    К сожалению, он далеко не всегда "наш". Поэтому легко не выйдет. Ну так, для примера — вот у меня маленький проект, три разработчика. Но при этом скажем 200 внедрений, и обрабатывает кучу данных. Но основан он на Apache Spark, который имеет под 2000 контрибьюторов. То есть, фреймворк что мы применяем, где-нибудь так на два-три порядка больше, чем наше приложение. И смигрировать его нашими силами — совершенно нереально. И такая ситуация с переиспользованием кода, когда вы чужого кода используете больше, чем сами пишете — она сегодня совершенно типична.