Статьи

Понимание Spring Reactive: Servlet Async

В предыдущей статье мы обсуждали, как развивались контейнеры сервлетов, и превратили связь между клиентом и сервером в неблокирующую парадигму. В этой статье мы сосредоточимся на эволюции Java-сервлетов (и Spring) в направлении неблокирующего, реактивного мира.

Давайте вспомним запрос потока при получении разъемом NIO:

  1. Несколько потоков (1-4 в зависимости от количества ядер) опрашивают селектор, ища активность ввода-вывода на канале соединения.
  2. Когда селектор видит активность ввода-вывода, он вызывает  handle метод для соединения, и потоку  pool отводится для обработки.
  3.  Thread попытается прочитать соединение и проанализировать его и для HTTP-соединения. Если заголовки запроса завершены, поток продолжает вызывать обработку запроса (в конце концов, он попадает в сервлет) без ожидания какого-либо содержимого.
  4. Как только a  thread отправляется сервлету, он выглядит так, как будто IO сервлета блокирует, и, следовательно, любая попытка чтения / записи данных из   HttpInputStream HttpOutputStream должен блокироваться. Но, поскольку мы используем нижний соединитель NIO, операции ввода-вывода используются  HttpInputStream и  HttpOutputStream являются асинхронными с обратными вызовами. Из-за блокирующего характера Servlet API он использует специальный обратный вызов блокировки для достижения блокировки.

Шаг 4, приведенный выше, разъяснил бы больше термин «имитация блокировки», использованный в предыдущей статье.

Проблемы перед Servlet 3.0

Теперь, возвращаясь к проблемам, которые создает один поток на модель запроса, мы видим, что фактическая обработка запроса, которая по своей природе является блокирующей, выполняется потоком (назовем его потоком запроса) из пула, которым управляет контейнер сервлет. В NIO размер пула потоков по умолчанию равен 200, что означает, что одновременно может обслуживаться только 200 запросов. Проблема с синхронной обработкой запросов заключается в том, что потоки (выполняющие тяжелую работу) выполняются в течение длительного времени, прежде чем отклик исчезнет. Если это происходит в масштабе, в контейнере сервлета со временем заканчиваются потоки — длительные потоки приводят к истощению потоков.

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

Серверный поток заблокирован во время обработки Http-запроса.

Асинхронные Сервлеты в 3.0

Асинхронный сервлет позволяет приложению обрабатывать входящие запросы в асинхронном режиме. Данный поток HTTP-запроса обрабатывает входящий запрос, а затем передает запрос другому фоновому потоку, который, в свою очередь, будет отвечать за обработку запроса и отправляет ответ обратно клиенту. Исходный поток HTTP-запроса вернется в пул HTTP-потока, как только он передаст запрос в фоновый поток, поэтому он станет доступным для обработки другого запроса.

Серверный поток освобождается во время обработки запроса Http

Ниже приведен фрагмент кода о том, как этого можно достичь в Servlet 3.0:

@WebServlet(name="myServlet", urlPatterns={"/asyncprocess"}, asyncSupported=true)
public class MyServlet extends HttpServlet {
    public void doGet(HttpServletRequest request, HttpServletResponse response) {
        OutputStream out = response.getOutputStream();
        AsyncContext aCtx = request.startAsync(request, response);
        //process your request in a different thread
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                String json ="json string";
                out.write(json);
                ctx.complete();
            }
        };
        //use some thread pool executor
        poolExecutor.submit(runnable);
    }
}

Когда  asyncSupported атрибут имеет значение true, объект ответа не фиксируется при выходе из метода. Вызов  startAsync() возвращает  AsyncContext объект, который кэширует пару объектов запрос / ответ. Затем  AsyncContext объект сохраняется в очереди приложения. Без каких-либо задержек  doGet() метод возвращается, и исходный поток запроса перерабатывается. Мы можем настроить запуск пула потоков при запуске сервера, который будет использоваться для обработки запроса. После обработки запроса вы можете позвонить   HttpServletResponse.getOutputStream().write(...), а затем  complete() зафиксировать ответ или вызов,  forward() чтобы направить поток на страницу JSP, которая будет отображаться в результате. Обратите внимание, что страницы JSP — это сервлеты с  asyncSupported атрибутом, который по умолчанию равен false.  complete() запускает контейнер сервлета для возврата ответа клиенту.

Примечание: все это поведение определено выше для сервлетов, которое может быть достигнуто возвращением вызываемого  DeferredResult или  CompletableFuture из Spring Controller.

Этот подход сам по себе может решить проблему исчерпания пула потоков HTTP, но не решит проблему потребления системных ресурсов. В конце концов, для обработки запроса был создан другой фоновый поток, поэтому количество одновременно активных потоков не уменьшится, а потребление системных ресурсов не улучшится. Таким образом, можно подумать, что это не может быть лучшей эволюцией существующего стека. Давайте сначала обсудим его реализацию в Spring, а затем попытаемся выяснить, какие сценарии являются лучшими и имеют высокие оценки для синхронных сервлетов.

Мы будем использовать проект Spring Boot, чтобы выставить две конечные точки — одну  blockingRequestProcessing и другую  asyncBlockingRequestProcessing с помощью функции асинхронного сервлета.

 @GetMapping(value = "/blockingRequestProcessing")

    public String blockingRequestProcessing() {

        logger.debug("Blocking Request processing Triggered");

        String url = "http://localhost:8090/sleep/1000";

        new RestTemplate().getForObject(url, Boolean.TYPE);

        return "blocking...";

    }

    @GetMapping(value = "/asyncBlockingRequestProcessing")

    public CompletableFuture<String> asyncBlockingRequestProcessing(){

        return CompletableFuture.supplyAsync(() -> {

            logger.debug("Async Blocking Request processing Triggered");

            String url = "http://localhost:8090/sleep/1000";

            new RestTemplate().getForObject(url, Boolean.TYPE);

            return "Async blocking...";

        },asyncTaskExecutor);

    }

Обе услуги выше призывают к  RestService электронной ndpoint называется   sleepingServiceМы можем предположить , что спящая служба имеет достаточно ресурсов и не будет нашим узким местом.

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

С помощью этой настройки мы хотим проверить производительность нашего  blockingRequestProcessing сервиса.

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

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

Давайте запустим тот же тест с тем же кодом, но вернем   CompletableFuture — следовательно, это  будет использовать асинхронный сервлет, как описано выше, с исполнителем пула потоков вместо String, как в  asyncBlockingRequestProcessing сервисе.

Все выглядит хорошо. Все запросы были успешными. Я даже сократил время отклика. Что произошло? Как упоминалось ранее, возвращение  Callable освобождает поток Tomcat, и обработка выполняется в другом потоке. Другой поток будет управляться настроенным нами исполнителем задач Spring MVC.

Мы фактически улучшили производительность, добавив ресурсы, то есть количество потоков из пула потоков исполнителя.

Обратите внимание, что запрос к спящему сервису все еще блокируется, но он блокирует другой поток (поток исполнителя Spring MVC). Теперь возникает вопрос — можем ли мы также повысить производительность без использования асинхронного API сервлета и путем увеличения конфигурации потока max tomcat для соединителя NIO? Ответ ДА, но для конкретных случаев использования.

Итак, где мы можем использовать функцию Servlet 3.0 Async?

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

@GetMapping(value = "/asyncNonBlockingRequestProcessing")
    public CompletableFuture<String> asyncNonBlockingRequestProcessing(){
            ListenableFuture<String> listenableFuture = getRequest.execute(new AsyncCompletionHandler<String>() {
                @Override
                public String onCompleted(Response response) throws Exception {
                    logger.debug("Async Non Blocking Request processing completed");
                    return "Async Non blocking...";
                }
            });
            return listenableFuture.toCompletableFuture();
    }

В приведенном выше коде мы используем   функцию AsyncHttpClient, которая вызывает спящий сервис неблокирующим образом. Следовательно, с использованием минимального количества потоков мы могли бы масштабировать наш сервис для одновременного обслуживания большего количества клиентов.

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

На этом мы завершим наше обсуждение функции асинхронного Servlet 3.0. Мы видели, что эта функция изменила способ разработки приложений, и это послужило бы прочной основой для Spring Reactive. Оставайтесь с нами для следующей статьи об этом!

Исходный код этой статьи можно найти здесь .