Статьи

Ограниченная полезность AsyncContext.start ()

Некоторое время назад я столкнулся с тем, какова цель AsyncContext.start (…) в Servlet 3.0? вопрос. Цитирование Javadoc вышеупомянутого метода :

Заставляет контейнер отправлять поток, возможно из пула управляемых потоков, для запуска указанного Runnable.

Напомним, что AsyncContext — это стандартный способ, определенный в спецификации Servlet 3.0, для асинхронной обработки HTTP-запросов. По сути, HTTP-запрос больше не привязан к HTTP-потоку, что позволяет нам обрабатывать его позже, возможно, используя меньшее количество потоков. Оказалось, что спецификация предоставляет API для обработки асинхронных потоков в другом пуле потоков. Сначала мы увидим, как эта функция полностью сломана и бесполезна в Tomcat и Jetty, а затем обсудим, почему ее полезность в целом сомнительна.

Наш тестовый сервлет просто будет спать в течение заданного количества времени. В нормальных обстоятельствах это убивает масштабируемость, потому что хотя спящий сервлет не потребляет ЦП, но спящий HTTP-поток, связанный с этим конкретным запросом, потребляет память — и никакой другой входящий запрос не может использовать этот поток. В нашей тестовой настройке я ограничил количество рабочих потоков HTTP до 10, что означает, что только 10 одновременных запросов полностью блокируют приложение (оно не отвечает извне), даже если само приложение почти полностью бездействует. Итак, ясно, что сон — враг масштабируемости.

@WebServlet(urlPatterns = Array("/*"))
class SlowServlet extends HttpServlet with Logging {
 
  protected override def doGet(req: HttpServletRequest, resp: HttpServletResponse) {
    logger.info("Request received")
    val sleepParam = Option(req.getParameter("sleep")) map {_.toLong}
    TimeUnit.MILLISECONDS.sleep(sleepParam getOrElse 10)
    logger.info("Request done")
  }
}

Сравнительный анализ этого кода показывает, что среднее время ответа близко к параметру сна, если количество одновременных подключений меньше количества потоков HTTP. Неудивительно, что время отклика начинает расти, как только мы превышаем количество потоков HTTP. Одиннадцатое соединение должно ждать завершения любого другого запроса и освобождать рабочий поток. Когда уровень параллелизма превышает 100, Tomcat начинает сбрасывать соединения — слишком много клиентов уже поставлено в очередь.

Так что насчет необычного метода AsyncContext.start () (не путайте с ServletRequest.startAsync ())? В соответствии с JavaDoc я могу передать любой Runnable, и контейнер будет использовать некоторый пул управляемых потоков для его обработки. Это поможет частично, так как я больше не блокирую рабочие потоки HTTP (но еще один поток где-то в контейнере сервлета используется). Быстрое переключение на асинхронный сервлет:

@WebServlet(urlPatterns = Array("/*"), asyncSupported = true)
class SlowServlet extends HttpServlet with Logging {
 
  protected override def doGet(req: HttpServletRequest, resp: HttpServletResponse) {
    logger.info("Request received")
    val asyncContext = req.startAsync()
    asyncContext.setTimeout(TimeUnit.MINUTES.toMillis(10))
    asyncContext.start(new Runnable() {
      def run() {
        logger.info("Handling request")
        val sleepParam = Option(req.getParameter("sleep")) map {_.toLong}
        TimeUnit.MILLISECONDS.sleep(sleepParam getOrElse 10)
        logger.info("Request done")
        asyncContext.complete()
      }
    })
  }
}

Сначала мы включаем асинхронную обработку, а затем просто перемещаем sleep () в Runnable и, возможно, в другой пул потоков, освобождая пул потоков HTTP. Быстрый стресс-тест показывает немного неожиданные результаты (здесь: время отклика в зависимости от количества одновременных соединений):

Угадайте, что время отклика
точно такое же, как и при отсутствии асинхронной поддержки вообще (!) После более внимательного изучения я обнаружил, что при вызове AsyncContext.start () Tomcat отправляет заданную задачу обратно … пулу рабочих потоков HTTP, то же самое тот, который используется для всех запросов HTTP! По сути, это означает, что мы выпустили один поток HTTP, чтобы использовать другой через миллисекунды (возможно, даже тот же). Вызывать AsyncContext.start () в Tomcat абсолютно бесполезно
, Я понятия не имею, это ошибка или особенность. С одной стороны, это явно не то, что намеревались разработчики API. Предполагалось, что контейнер сервлета управляет отдельным независимым пулом потоков, поэтому пул рабочих потоков HTTP все еще можно использовать. Я имею в виду, что весь смысл асинхронной обработки состоит в том, чтобы избежать пула HTTP. Tomcat делает вид, что делегирует нашу работу другому потоку, хотя он все еще использует исходный пул рабочих потоков.

Так почему я считаю, что это особенность? Поскольку Jetty «ломается» точно таким же образом … Независимо от того, работает ли он как задумано или является только плохой реализацией API, использование AsyncContext.start () в Tomcat и Jetty бессмысленно и только излишне усложняет код. Это ничего не даст, приложение работает при высокой нагрузке точно так же, как если бы вообще не было асинхронной логики.

Но как насчет использования этой функции API в
правильных реализациях, таких как
IBM WAS ? Это лучше, но, тем не менее, API, как он есть, не дает нам многого в плане масштабируемости. Объясним еще раз: весь смысл асинхронной обработки заключается в возможности отделить HTTP-запрос от основного потока, предпочтительно путем обработки нескольких соединений с использованием одного и того же потока.

AsyncContext.start () запустит предоставленный Runnable в отдельном пуле потоков. Ваше приложение все еще отзывчиво и может обрабатывать обычные запросы, в то время как длительный запрос, который вы решили обработать асинхронно, обрабатывается в отдельном пуле потоков. Лучше, к сожалению, пул потоков и
поток для каждого соединения все еще остается узким местом. Для JVM не имеет значения, какой тип потоков запущен — они все еще занимают память. Таким образом, мы больше не блокируем рабочие потоки HTTP, но наше приложение не является более масштабируемым с точки зрения одновременных долгосрочных задач, которые мы можем поддерживать.

В этом простом и нереалистичном примере со спящим сервлетом мы можем фактически поддерживать тысячи одновременных (ожидающих) соединений, используя асинхронную поддержку Servlet 3.0 только с одним дополнительным потоком — и без AsyncContext.start (). Ты знаешь как? Подсказка:
ScheduledExecutorService .

Постскриптум: Скала Боже

Я почти забыл. Несмотря на то, что примеры были написаны на Scala, я еще не использовал какие-либо интересные языковые функции. Вот один из них: неявные преобразования. Сделайте это доступным в вашей области:

implicit def blockToRunnable[T](block: => T) = new Runnable {
 def run() {
  block
 }
}

И вдруг вы можете использовать блок кода вместо создания экземпляра Runnable вручную и явно: 

asyncContext start {
 logger.info("Handling request")
 val sleepParam = Option(req.getParameter("sleep")) map { _.toLong}
 TimeUnit.MILLISECONDS.sleep(sleepParam getOrElse 10)
 logger.info("Request done")
 asyncContext.complete()
}

Sweet!