Сервлет-3 Async Context, как делать асинхронные записи?

Описание проблемы

Servlet-3.0 API позволяет отделить контекст запроса/ответа и ответить на него позже.

Однако, если я попытаюсь написать большой объем данных, что-то вроде:

AsyncContext ac = getWaitingContext() ;
ServletOutputStream out = ac.getResponse().getOutputStream();
out.print(some_big_data);
out.flush()

Он может блокировать - и он блокирует тривиальные тестовые примеры - как для Tomcat 7, так и для Jetty 8. Учебники рекомендуют создать пул потоков, который обрабатывать такую ​​настройку - ведьма обычно является контрположительной для традиционной архитектуры 10K.

Однако, если у меня есть 10 000 открытых подключений и пул потоков, пусть говорят 10 потоков, достаточно даже для 1% клиентов, имеющих низкоскоростные соединения или просто заблокированные соединение для блокировки пула потоков и полностью блокировать реакцию кометы или значительно замедлить его.

Ожидаемая практика заключается в получении уведомления о готовности к записи или уведомлении о завершении ввода-вывода и чем продолжать нажимать данные.

Как это можно сделать с помощью API Servlet-3.0, то есть как я могу получить:

  • Уведомление о асинхронном завершении операции ввода-вывода.
  • Получите неблокирующий ввод-вывод с уведомлением о готовности.

Если это не поддерживается API-интерфейсом Servlet-3.0, существуют ли какие-либо API-интерфейсы для веб-сервера (например, Jetty Continuation или Tomcat CometEvent), которые позволяют обрабатывать такие события по-настоящему асинхронно, не создавая асинхронный ввод-вывод с использованием пула потоков.

Кто-нибудь знает?

И если это невозможно, вы можете подтвердить его ссылкой на документацию?

Демонстрация проблемы в примерном коде

Я добавил код ниже, который эмулирует поток событий.

Примечания:

  • он использует ServletOutputStream, который бросает IOException для обнаружения отключенных клиентов
  • отправляет сообщения keep-alive, чтобы убедиться, что клиенты все еще там
  • Я создал пул потоков для "эмулирования" асинхронных операций.

В таком примере я явно определил пул потоков размером 1, чтобы показать проблему:

  • Запустить приложение
  • Запуск от двух терминалов curl http://localhost:8080/path/to/app (дважды)
  • Теперь отправьте данные с помощью curd -d m=message http://localhost:8080/path/to/app
  • Оба клиента получили данные
  • Теперь приостановите действие одного из клиентов (Ctrl + Z) и отправьте сообщение еще раз curd -d m=message http://localhost:8080/path/to/app
  • Обратите внимание, что другой не приостановленный клиент либо ничего не получил, либо после того, как сообщение было передано, перестали получать запросы keep-alive, потому что другой поток заблокирован.

Я хочу решить такую ​​проблему без использования пула потоков, потому что с 1000-5000 открытыми соединения. Я могу очень быстро исчерпать поток.

Пример кода ниже.


import java.io.IOException;
import java.util.HashSet;
import java.util.Iterator;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.LinkedBlockingQueue;

import javax.servlet.AsyncContext;
import javax.servlet.ServletConfig;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.ServletOutputStream;


@WebServlet(urlPatterns = "", asyncSupported = true)
public class HugeStreamWithThreads extends HttpServlet {

    private long id = 0;
    private String message = "";
    private final ThreadPoolExecutor pool = 
        new ThreadPoolExecutor(1, 1, 50000L,TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>());
        // it is explicitly small for demonstration purpose

    private final Thread timer = new Thread(new Runnable() {
        public void run()
        {
            try {
                while(true) {
                    Thread.sleep(1000);
                    sendKeepAlive();
                }
            }
            catch(InterruptedException e) {
                // exit
            }
        }
    });


    class RunJob implements Runnable {
        volatile long lastUpdate = System.nanoTime();
        long id = 0;
        AsyncContext ac;
        RunJob(AsyncContext ac) 
        {
            this.ac = ac;
        }
        public void keepAlive()
        {
            if(System.nanoTime() - lastUpdate > 1000000000L)
                pool.submit(this);
        }
        String formatMessage(String msg)
        {
            StringBuilder sb = new StringBuilder();
            sb.append("id");
            sb.append(id);
            for(int i=0;i<100000;i++) {
                sb.append("data:");
                sb.append(msg);
                sb.append("\n");
            }
            sb.append("\n");
            return sb.toString();
        }
        public void run()
        {
            String message = null;
            synchronized(HugeStreamWithThreads.this) {
                if(this.id != HugeStreamWithThreads.this.id) {
                    this.id = HugeStreamWithThreads.this.id;
                    message = HugeStreamWithThreads.this.message;
                }
            }
            if(message == null)
                message = ":keep-alive\n\n";
            else
                message = formatMessage(message);

            if(!sendMessage(message))
                return;

            boolean once_again = false;
            synchronized(HugeStreamWithThreads.this) {
                if(this.id != HugeStreamWithThreads.this.id)
                    once_again = true;
            }
            if(once_again)
                pool.submit(this);

        }
        boolean sendMessage(String message) 
        {
            try {
                ServletOutputStream out = ac.getResponse().getOutputStream();
                out.print(message);
                out.flush();
                lastUpdate = System.nanoTime();
                return true;
            }
            catch(IOException e) {
                ac.complete();
                removeContext(this);
                return false;
            }
        }
    };

    private HashSet<RunJob> asyncContexts = new HashSet<RunJob>();

    @Override
    public void init(ServletConfig config) throws ServletException
    {
        super.init(config);
        timer.start();
    }
    @Override
    public void destroy()
    {
        for(;;){
            try {
                timer.interrupt();
                timer.join();
                break;
            }
            catch(InterruptedException e) {
                continue;
            }
        }
        pool.shutdown();
        super.destroy();
    }


    protected synchronized void removeContext(RunJob ac)
    {
        asyncContexts.remove(ac);
    }

    // GET method is used to establish a stream connection
    @Override
    protected synchronized void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {

        // Content-Type header
        response.setContentType("text/event-stream");
        response.setCharacterEncoding("utf-8");

        // Access-Control-Allow-Origin header
        response.setHeader("Access-Control-Allow-Origin", "*");

        final AsyncContext ac = request.startAsync();

        ac.setTimeout(0);
        RunJob job = new RunJob(ac);
        asyncContexts.add(job);
        if(id!=0) {
            pool.submit(job);
        }
    }

    private synchronized void sendKeepAlive()
    {
        for(RunJob job : asyncContexts) {
            job.keepAlive();
        }
    }

    // POST method is used to communicate with the server
    @Override
    protected synchronized void doPost(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException 
    {
        request.setCharacterEncoding("utf-8");
        id++;
        message = request.getParameter("m");        
        for(RunJob job : asyncContexts) {
            pool.submit(job);
        }
    }


}

В приведенном выше примере используются потоки для предотвращения блокировки... Однако, если количество блокирующих клиентов больше, чем размер пула потоков, он блокирует.

Как это можно реализовать без блокировки?

Ответ 1

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

Здесь мой переход к классу асинхронных сервлетов слегка адаптирован для вашего случая some_big_data:

import java.io.IOException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

import javax.servlet.AsyncContext;
import javax.servlet.AsyncEvent;
import javax.servlet.AsyncListener;
import javax.servlet.ServletConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletResponse;
import javax.servlet.annotation.WebInitParam;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

import org.apache.log4j.Logger;

@javax.servlet.annotation.WebServlet(urlPatterns = { "/async" }, asyncSupported = true, initParams = { @WebInitParam(name = "threadpoolsize", value = "100") })
public class AsyncServlet extends HttpServlet {

  private static final Logger logger = Logger.getLogger(AsyncServlet.class);

  public static final int CALLBACK_TIMEOUT = 10000; // ms

  /** executor service */
  private ExecutorService exec;

  @Override
  public void init(ServletConfig config) throws ServletException {

    super.init(config);
    int size = Integer.parseInt(getInitParameter("threadpoolsize"));
    exec = Executors.newFixedThreadPool(size);
  }

  @Override
  public void service(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException {

    final AsyncContext ctx = req.startAsync();
    final HttpSession session = req.getSession();

    // set the timeout
    ctx.setTimeout(CALLBACK_TIMEOUT);

    // attach listener to respond to lifecycle events of this AsyncContext
    ctx.addListener(new AsyncListener() {

      @Override
      public void onComplete(AsyncEvent event) throws IOException {

        logger.info("onComplete called");
      }

      @Override
      public void onTimeout(AsyncEvent event) throws IOException {

        logger.info("onTimeout called");
      }

      @Override
      public void onError(AsyncEvent event) throws IOException {

        logger.info("onError called: " + event.toString());
      }

      @Override
      public void onStartAsync(AsyncEvent event) throws IOException {

        logger.info("onStartAsync called");
      }
    });

    enqueLongRunningTask(ctx, session);
  }

  /**
   * if something goes wrong in the task, it simply causes timeout condition that causes the async context listener to be invoked (after the fact)
   * <p/>
   * if the {@link AsyncContext#getResponse()} is null, that means this context has already timed out (and context listener has been invoked).
   */
  private void enqueLongRunningTask(final AsyncContext ctx, final HttpSession session) {

    exec.execute(new Runnable() {

      @Override
      public void run() {

        String some_big_data = getSomeBigData();

        try {

          ServletResponse response = ctx.getResponse();
          if (response != null) {
            response.getWriter().write(some_big_data);
            ctx.complete();
          } else {
            throw new IllegalStateException(); // this is caught below
          }
        } catch (IllegalStateException ex) {
          logger.error("Request object from context is null! (nothing to worry about.)"); // just means the context was already timeout, timeout listener already called.
        } catch (Exception e) {
          logger.error("ERROR IN AsyncServlet", e);
        }
      }
    });
  }

  /** destroy the executor */
  @Override
  public void destroy() {

    exec.shutdown();
  }
}

Ответ 4

Мы не можем привести к тому, что записи будут асинхронными. Мы реально должны жить с ограничением, что, когда мы пишем что-то клиенту, мы ожидаем, что сможем сделать это быстро и можем рассматривать его как ошибку, если мы этого не сделаем. То есть, если наша цель - как можно быстрее передавать данные клиенту и использовать блокирующий/неблокирующий статус канала в качестве способа управления потоком, нам не повезло. Но если мы отправляем данные с низкой скоростью, с которыми клиент сможет справиться, мы можем хотя бы быстро отключить клиенты, которые не читают достаточно быстро.

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

Фокус в том, что все методы для вывода вывода, которые просто бросают IOExceptions, на самом деле делают нечто большее: в реализации все вызовы на вещи, которые могут быть прерываны() ed, будут обернуты чем-то вроде этого (взято из Jetty 9):

catch (InterruptedException x)
    throw (IOException)new InterruptedIOException().initCause(x);

(Я также отмечаю, что этого не происходит в Jetty 8, где InterruptedException регистрируется, и цикл блокировки сразу же повторен. Предположительно, вы делаете, чтобы убедиться, что ваш контейнер сервлета хорошо себя ведет, чтобы использовать этот трюк.)

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

Я изменил свой код, чтобы поток основного таймера выполнял задание, чтобы связать время в каждой записи, прежде чем мы начнем запись, и задание будет отменено, если запись завершится быстро, что она должна.

Последнее замечание: в хорошо реализованном контейнере сервлетов, в результате чего сброс ввода-вывода должен быть безопасным. Было бы неплохо, если бы мы могли поймать InterruptedIOException и попробовать написать позже. Возможно, мы хотели бы предоставить медленным клиентам подмножество событий, если они не могут идти в ногу с полным потоком. Насколько я могу судить, в Jetty это не совсем безопасно. Если вы выбрали запись, внутреннее состояние объекта HttpResponse может быть недостаточно согласованным, чтобы позднее можно было повторно ввести запись. Я ожидаю, что нецелесообразно пытаться протолкнуть контейнер сервлета таким образом, если не будут указаны конкретные документы, которые я пропустил, предлагая эту гарантию. Я думаю, идея состоит в том, что соединение предназначено для отключения, если происходит IOException.

Здесь код с модифицированной версией RunJob:: run() использует громоздкую простую иллюстрацию (на самом деле мы хотели бы использовать основной поток таймера здесь, а не разворачивать одну запись за запись, которая глупо).

public void run()
{
    String message = null;
    synchronized(HugeStreamWithThreads.this) {
        if(this.id != HugeStreamWithThreads.this.id) {
            this.id = HugeStreamWithThreads.this.id;
            message = HugeStreamWithThreads.this.message;
        }
    }
    if(message == null)
        message = ":keep-alive\n\n";
    else
        message = formatMessage(message);

    final Thread curr = Thread.currentThread();
    Thread canceller = new Thread(new Runnable() {
        public void run()
        {
            try {
                Thread.sleep(2000);
                curr.interrupt();
            }
            catch(InterruptedException e) {
                // exit
            }
        }
    });
    canceller.start();

    try {
        if(!sendMessage(message))
            return;
    } finally {
        canceller.interrupt();
        while (true) {
            try { canceller.join(); break; }
            catch (InterruptedException e) { }
        }
    }

    boolean once_again = false;
    synchronized(HugeStreamWithThreads.this) {
        if(this.id != HugeStreamWithThreads.this.id)
            once_again = true;
    }
    if(once_again)
        pool.submit(this);

}

Ответ 6

Я быстро просмотрел вашу листинг, поэтому я, возможно, пропустил некоторые моменты. Преимущество потока пулов заключается в совместном использовании ресурсов потоков между несколькими задачами. Возможно, вы можете решить свою проблему, разделив ответы keepAlive на различные http-соединения, вместо того, чтобы группировать их все одновременно.