Статьи

Поддержка Servlet 3.0 Async в Spring и заблуждения о производительности

Можно улучшить производительность серверов приложений с помощью Servlet 3.0 async, но нужно ли это?

Прежде чем мы начнем

Spring облегчает написание приложений Java. С Spring Boot стало еще проще. Spring Boot позволяет нам быстро создавать приложения Spring: создавать и запускать сервер приложений Java со встроенным Tomcat и собственным контроллером менее чем за 5 минут. Хорошо, я признаю, что использовал Spring Initializer для этого: 

В простом проекте Spring Boot, который я создал, сервлет HTTP и контейнер сервлета генерировались автоматически. Сервлет HTTP — это класс Spring  DispatcherServlet  (фактически расширяет HTTP-сервлет), а не   MyController класс. Так почему же  MyController работает код? В конце концов, контейнер сервлетов (в нашем случае Tomcat) распознает только сервлеты. Tomcat вызывает метод http сервлета  doGet для выполнения запроса GET от клиента.  

@RestController
public class MyController {
    @GetMapping(value = "/ruleTheWorld")
    public String rule(){
        return "Ruling...";
    }
}

Весна это все о аннотациях. Мы пометили наш класс как контроллер, добавив  @RestController. При запуске приложения,  DispatcherServlet инициализируется соответствующим образом и содержит карту , которая связывает  / ruleTheWorld с rule()Ходят слухи,  что нам следует избегать архитектуры «поток на запрос», поэтому я решил проверить, как можно выполнить мой запрос в потоке, отличном от потока Tomcat. Как я мог поручить Spring запустить код контроллера в другом потоке? Подсказка: это не аннотация. Я не говорю о том  @Async, что эта аннотация действительно выполняет код асинхронно, но не в сочетании с Tomcat. Tomcat каким-то образом должен знать, что HTTP-сервлет завершил обработку, и что он должен вернуть ответ клиенту. Именно по этой причине, В сервлете 3.0 появился startAsync метод, который возвращает контекст:

public void doGet(HttpServletRequest req, HttpServletResponse resp) {
   ...
   AsyncContext acontext = req.startAsync();
   ...
}

После того, как сервлет HTTP получает  AsyncContext объект, любой поток, который выполняет,  acontext.complete()  запускает Tomcat, чтобы вернуть ответ клиенту. Удивительно, но я получил это поведение, просто вернувшись  Callable:

@RestController
public class MyController {
    @GetMapping(value = "/ruleTheWorld")
    public Callable<String> rule(){
        System.out.println("Start thread id: " + Thread.currentThread().getName());
        Callable<String> callable = () -> {
            System.out.println("Callable thread id: " + Thread.currentThread().getName());
            return "Ruling...";
        };
        System.out.println("End thread id: " + Thread.currentThread().getName());
        return callable;
    }
}

Выход:

Start thread id: http-nio-8080-exec-1
End thread id: http-nio-8080-exec-1
Callable thread id: MvcAsync1

По порядку журналов видно, что поток Tomcat ( http-nio-8080-exec-1 ) вернулся в свой пул до того, как запрос был завершен, и что запрос был обработан другим потоком ( MvcAsync1 ). На этот раз аннотация не участвует. Оказывается, Spring использует возвращаемое значение, чтобы решить, запускать или нет startAsync. То же самое верно, если я возвращаю DeferredResult . Код ниже был извлечен из  репозитория Spring Framework . Обработчик  Callable возвращаемого значения выполняет запрос асинхронно. 

public class CallableMethodReturnValueHandler implements HandlerMethodReturnValueHandler {
...
@Override
public void handleReturnValue(@Nullable Object returnValue, MethodParameter returnType,
ModelAndViewContainer mavContainer, NativeWebRequest webRequest) throws Exception {
  ...
    Callable<?> callable = (Callable<?>) returnValue;
    WebAsyncUtils.getAsyncManager(webRequest).startCallableProcessing(callable, mavContainer);
  }
}

Заблуждения производительности

Так почему бы не вернуть все мои контроллеры Callable? Похоже, мое приложение может масштабироваться лучше, поскольку потоки Tomcat более доступны для приема запросов. Давайте рассмотрим типичный пример контроллера — вызов другого сервиса REST. Другой сервис будет очень просто. Он получает время для сна и спит:

    @RequestMapping(value="/sleep/{timeInMilliSeconds}", method = RequestMethod.GET)
    public boolean sleep(@PathVariable("timeInMilliSeconds") int timeInMilliSeconds) {
        try {
            Thread.sleep(timeInMilliSeconds);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return true;
    }

Мы можем предположить, что  служба сна имеет достаточно ресурсов и не будет нашим узким местом. Кроме того, я установил количество потоков Tomcat  для этого сервиса равным 1000 (у нашего сервиса будет только 10 для быстрого воспроизведения проблем масштабирования). Мы хотим изучить производительность  этого  сервиса:

@RestController
public class MyControllerBlocking {
    @GetMapping(value = "/ruleTheWorldBlocking")
    public String rule() {
        String url = "http://localhost:8090/sleep/1000";
        new RestTemplate().getForObject(url, Boolean.TYPE);
        return "Ruling blocking...";
    }
}

В строке 5 мы видим запрос на сон 1 секунду. Максимальное количество потоков Tomcat в нашем сервисе равно 10. Я буду использовать Gatling для ввода 20 запросов каждую секунду в течение 60 секунд. В целом, хотя все потоки Tomcat заняты обработкой запросов, Tomcat хранит ожидающие запросы в очереди запросов. Когда поток становится доступным, запрос извлекается из очереди и обрабатывается этим потоком. Если очередь заполнена, мы получаем ошибку «Соединение отказано», но, поскольку я не изменил размер по умолчанию (10 000), и мы вводим всего 1200 запросов (20 запросов в секунду в течение 60 секунд), мы не увидим этого , Время ожидания клиента (устанавливается в конфигурации Гатлинга) составляет 60 секунд. Вот результаты:

Некоторые клиенты получили тайм-ауты. Зачем? Гатлинг вызывает 20 запросов в секунду, в то время как наш сервис может обрабатывать 10 запросов каждую 1 секунду, поэтому мы накапливаем 10 запросов в очереди запросов Tomcat каждую секунду. Это означает, что в секунду 60 очередь запросов содержит не менее 600 запросов . Может ли служба обрабатывать все запросы с 10 потоками за 60 секунд (время ожидания клиента)? Ответ — нет. Время обработки одного запроса составляет более 1 секунды . Время отклика = время обработки + время задержки. Поэтому, по крайней мере, один из клиентов будет ожидать ответа более 60 секунд (ошибка тайм-аута). 

Давайте запустим тот же тест с тем же кодом, но вернем Callable<String> вместо  String:

public class MyCallableController {
    @GetMapping(value = "/ruleTheWorldAsync")
    public Callable<String> rule(){
        return () -> {
            String url = "http://localhost:8090/sleep/1000";
            new RestTemplate().getForObject(url, Boolean.TYPE);
            return "Ruling...";
        };
    }
}

Все выглядит хорошо. Все 1200 запросов были успешными. Я даже сократил время ответа с 56,7 секунд до 1,037 секунд (если вы посмотрите на столбец 95% запросов). Что произошло? Как упоминалось ранее, возвращение Callable освобождает поток Tomcat, и обработка выполняется в другом потоке. Другой поток будет управляться исполнителем задачи Spring MVC по умолчанию. Я использую  VisualVM для мониторинга потоков. Давайте сравним ресурсы обоих исполнений. 

Мониторинг блокирующего контроллера

Мониторинг неблокирующего контроллера

Мы фактически улучшили производительность, добавив ресурсы . Значит ли это, что освобождение потоков Tomcat было необходимо в этом случае? Обратите внимание, что запрос к sleep-service все еще блокирует, но он блокирует другой поток (поток выполнения Spring MVC). Согласно графику потоков, похоже, что мы использовали дополнительные 29 потоков. Давайте посмотрим, что произойдет, если мы добавим эти потоки в 10 потоков Tomcat и используем блокирующий подход. Таким образом, следующий тест будет на том же контроллере блокировки , но мы изменим количество потоков Tomcat с 10 до 39. 

Мониторинг блокирующего контроллера с 39 потоками Tomcat

Результат блокирования контроллера с 39 потоками Tomcat

С подходом блокировки (39 потоков) время отклика немного лучше. Мы использовали меньше памяти в куче, хотя у нас больше потоков (всего 53) в начале и в конце по сравнению с диаграммой неблокирующих потоков (30). Мы можем получить автоматическое создание / удаление потоков при использовании неблокирующего подхода, но Tomcat также имеет эту возможность. Дело в том, что это не заметно в небольших количествах. Например, при определении предела в 1000 потоков для  спящего сервиса Tomcat выделил только 25 потоков (в пике 125 потоков).  

Заключение 

Servlet 3.0 async увеличивает сложность кода, в то время как можно достичь аналогичных результатов, просто настроив несколько конфигураций Tomcat.

Преимущество освобождения потоков Tomcat очевидно, когда речь идет об одном сервере Tomcat с несколькими развернутыми WAR . Например, если я развертываю две службы, а service1 требует в 10 раз больше ресурсов, чем  service2 , асинхронная работа с сервлетом 3.0 позволяет нам высвобождать потоки Tomcat и при необходимости поддерживать разные пулы потоков в каждой службе.

Асинхронность сервлета 3.0 также актуальна, если в коде запроса на обработку используется  неблокирующий  API (в нашем случае мы используем блокирующий API для вызова другой службы).

Комментарии приветствуются, особенно если вы можете порекомендовать успешную реализацию асинхронного Servlet 3.0 в аналогичной ситуации.