Статьи

Является ли асинхронный EJB просто трюком?

В предыдущих статьях ( здесь  и  здесь ) я показал, что создание неблокирующих асинхронных приложений может повысить производительность, когда сервер находится под большой нагрузкой. В EJB 3.1 введена  @Asynchronous аннотация для указания того, что метод вернет свой результат в будущем. Javadocs заявляют, что либо  void или Future должны быть возвращены. Пример службы, использующей эту аннотацию, показан в следующем листинге: 

@Stateless
public class Service2 {

    @Asynchronous
    public Future<String> foo(String s) {
        // simulate some long running process
        Thread.sleep(5000);

        s += "<br>Service2: threadId=" + Thread.currentThread().getId();
        return new AsyncResult<String>(s);
    }
}

Аннотация находится в строке 4. Метод возвращает Future тип  String и делает это в строке 10, оборачивая вывод в  AsyncResult. В тот момент, когда клиентский код вызывает метод EJB, контейнер перехватывает вызов и создает задачу, которую он будет выполнять в другом потоке, чтобы он мог Future немедленно вернуть  . Когда контейнер затем выполняет задачу, используя другой поток, он вызывает метод EJB и использует  AsyncResult для завершения тот, Future который был передан вызывающей стороне. Есть несколько проблем с этим кодом, хотя он выглядит точно так же, как код во всех примерах, найденных в Интернете. Например,  Future класс содержит только блокирующие методы для получения результата Future, а не какие-либо методы для регистрации обратных вызовов, когда это будет завершено. В результате получается код, подобный следующему, что плохо при загрузке контейнера: 

//type 1
Future<String> f = service.foo(s);
String s = f.get(); //blocks the thread, but at least others can run
//... do something useful with the string...

//type 2
Future<String> f = service.foo(s);
while(!f.isDone()){
    try {
        Thread.sleep(100);
    } catch (InterruptedException e) {
        ...
    }
}
String s = f.get();
//... do something useful with the string...

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

Так возможно ли заставить контейнер выполнять методы асинхронно, но написать  клиента,  которому не нужно блокировать потоки? Это. Следующий листинг показывает сервлет, делающий это.

@WebServlet(urlPatterns = { "/AsyncServlet2" }, asyncSupported = true)
public class AsyncServlet2 extends HttpServlet {

    @EJB private Service3 service;

    protected void doGet(HttpServletRequest request, final HttpServletResponse response) throws ServletException, IOException {

        final PrintWriter pw = response.getWriter();
        pw.write("<html><body>Started publishing with thread " + Thread.currentThread().getId() + "<br>");
        response.flushBuffer(); // send back to the browser NOW

        CompletableFuture<String> cf = new CompletableFuture<>();
        service.foo(cf);

        // since we need to keep the response open, we need to start an async context
        final AsyncContext ctx = request.startAsync(request, response);
        cf.whenCompleteAsync((s, t)->{
            try {
                if(t!=null) throw t;
                pw.write("written in the future using thread " + Thread.currentThread().getId()
            + "... service response is:");
                pw.write(s);
                pw.write("</body></html>");
                response.flushBuffer();
                ctx.complete(); // all done, free resources
            } catch (Throwable t2) {
...

Строка 1 объявляет, что сервлет поддерживает асинхронную работу — не забудьте этот бит! Строки 8-10 начинают запись данных в ответ, но интересный бит находится в строке 13, где вызывается асинхронный метод обслуживания. Вместо того, чтобы использовать Future тип возвращаемого значения, мы передаем ему a  CompletableFuture, который он использует, чтобы вернуть нам результат. Как? Хорошо, строка 16 запускает асинхронный контекст сервлета, так что мы все еще можем писать в ответ после  doGet возврата метода. Строки 17 и далее затем эффективно регистрируют обратный вызов,  CompletableFuture который будет вызван, как толькоCompletableFuture завершено с результатом. Здесь нет кода блокировки — ни один поток не заблокирован, ни один поток не опрошен, ожидая результата! Под нагрузкой количество потоков на сервере может быть сведено к минимуму, обеспечивая эффективную работу сервера, поскольку требуется меньше переключений контекста. 

Реализация сервиса показана ниже:

@Stateless
public class Service3 {

    @Asynchronous
    public void foo(CompletableFuture<String> cf) {
        // simulate some long running process
        Thread.sleep(5000);

        cf.complete("bar");
    }
}

Строка 7 действительно ужасна, потому что она блокирует, но притворяется, что это код, вызывающий веб-службу, развернутую удаленно в Интернете или медленной базе данных, с использованием API, который блокирует, как это делают большинство клиентов веб-служб и драйверы JDBC. В качестве альтернативы используйте  асинхронный драйвер  и, когда результат станет доступен, завершите будущее, как показано в строке 9. Затем это сигнализирует,  CompletableFuture что может быть вызван обратный вызов, зарегистрированный в предыдущем листинге. 

Разве это не похоже на простой обратный вызов? Это, безусловно, похоже, и в следующих двух листингах показано решение с использованием пользовательского интерфейса обратного вызова. 

@WebServlet(urlPatterns = { "/AsyncServlet3" }, asyncSupported = true)
public class AsyncServlet3 extends HttpServlet {

    @EJB private Service4 service;

    protected void doGet(HttpServletRequest request, final HttpServletResponse response) throws ServletException, IOException {
...
        final AsyncContext ctx = request.startAsync(request, response);

        service.foo(s -> {
...
            pw.write("</body></html>");
            response.flushBuffer();
            ctx.complete(); // all done, free resources
...
@Stateless
public class Service4 {

    @Asynchronous
    public void foo(Callback<String> c) {
        // simulate some long running process
        Thread.sleep(5000);

        c.apply("bar");
    }

    public static interface Callback<T> {
        void apply(T t);
    }
}

Опять же, в клиенте нет абсолютно никакой блокировки. Но более ранний пример AsyncServlet2 вместе с классом Service3, который использует CompletableFuture, лучше по следующим причинам:

  • API  CompletableFuture допускает исключения / сбои,
  • CompletableFuture Класс предоставляет методы для выполнения обратных вызовов и зависимых задач асинхронно, т.е. в вилочных присоединиться бассейн, так что система в целом выполняется с использованием в качестве нескольких потоков , как это возможно , и поэтому может обрабатывать параллелизм более эффективно,
  • CompletableFuture можно комбинировать с другими, чтобы вы могли зарегистрировать обратный вызов, который будет вызываться только после CompletableFutureзавершения нескольких  сеансов,
  • Обратный вызов вызывается не сразу, а ограниченное число потоков в пуле обслуживает CompletableFutureвыполнение в порядке, в котором они должны выполняться.

После первого листинга я упомянул, что было несколько проблем с реализацией асинхронных методов EJB. Помимо блокировки клиентов, другая проблема заключается в том, что в соответствии с главой 4.5.3  спецификации EJB 3.1 контекст транзакции клиента не распространяется с асинхронным вызовом метода. Если вы хотите использовать аннотацию @Asynchronous для создания двух методов, которые могут работать параллельно и обновлять базу данных в рамках одной транзакции, это не сработает. Это ограничивает использование 

Используя CompletableFuture, вы можете подумать, что вы можете запустить несколько задач параллельно в одном и том же транзакционном контексте, сначала запустив транзакцию, скажем, в EJB-компоненте, затем создав несколько исполняемых модулей и запустив их, используя  runAsync метод, который запускает их в пуле выполнения. , а затем зарегистрируйте обратный вызов для запуска, как только все было сделано с использованием  allOf метода. Но вы можете потерпеть неудачу из-за ряда вещей:

  • Если вы используете транзакции, управляемые контейнером, то транзакция будет зафиксирована после того, как метод EJB, который вызывает запуск транзакции, вернет управление контейнеру — если к тому времени ваш фьючерс не будет завершен, вам придется заблокировать поток, выполняющий метод EJB так что он ожидает результатов параллельного выполнения, а блокирование — это именно то, чего мы хотим избежать,
  • Если все потоки в едином пуле выполнения, выполняющем задачи, заблокированы, ожидая ответа на их вызовы из БД, то вы рискуете создать несоответствующее решение — в таких случаях вы можете попробовать использовать неблокирующий  асинхронный драйвер , но не каждая база данных имеет такой драйвер,
  • Локальное хранилище потоков (TLS) больше не может использоваться, как только задача выполняется в другом потоке, например, в пуле выполнения, потому что выполняющийся поток отличается от потока, который отправил работу в пул выполнения и установил значения в TLS перед отправкой работы,
  • Такие ресурсы  EntityManager не являются поточно-ориентированными . Это означает, что вы не можете передать  EntityManager задачи, переданные в пул, каждая задача должна получить свой собственный  EntityManagerэкземпляр, но создание  EntityManager зависит от TLS (см. Ниже).

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

@Stateless
public class Service5 {

    @Resource ManagedExecutorService mes;
    @Resource EJBContext ctx;
    @PersistenceContext(name="asdf") EntityManager em;

    @Asynchronous
    public void foo(CompletableFuture<String> cf, final PrintWriter pw) {

        //pw.write("<br>inside the service we can rollback, i.e. we have access to the transaction");
        //ctx.setRollbackOnly();

        //in EJB we can use EM
        KeyValuePair kvp = new KeyValuePair("asdf");
        em.persist(kvp);

        Future<String> f = mes.submit(new Callable<String>() {
            @Override
            public String call() throws Exception {
                try{
                    ctx.setRollbackOnly();
                    pw.write("<br/>inside executor service, we can rollback the transaction");
                }catch(Exception e){
                    pw.write("<br/>inside executor service, we CANNOT rollback the transaction: " + e.getMessage());
                }

                try{
                    //in task inside executor service we CANNOT use EM
                    KeyValuePair kvp = new KeyValuePair("asdf");
                    em.persist(kvp);
                    pw.write("...inside executor service, we can use the EM");
                }catch(TransactionRequiredException e){
                    pw.write("...inside executor service, we CANNOT use the EM: " + e.getMessage());
                }
...

В строке 12 нет проблем, вы можете откатить транзакцию, которая автоматически запускается в строке 9, когда контейнер вызывает метод EJB. Но эта транзакция не будет глобальной транзакцией, которая могла быть запущена кодом, который вызывает строку 9. Строка 16 также не представляет проблемы, вы можете использовать  EntityManager для записи в базу данных внутри транзакции, начатой ​​строкой 9. Строки 4 и 18 показывают другой способ выполнения кода в другом потоке, а именно использование ManagedExecutorService представленного в Java EE 7. Но это также не срабатывает каждый раз, когда есть зависимость от TLS, например строки 22 и 31 вызывают исключения, поскольку транзакция, которая запускается в строке 9, не может быть расположен потому, что для этого используется TLS, а код в строках 21-35 выполняется с использованием потока, отличного от кода до строки 19. 

Следующий листинг показывает, что обратный вызов завершения, зарегистрированный в  CompletableFuture строках 11-14, также выполняется в потоке, отличном от строк 4-10, потому что вызов для фиксации транзакции, запущенной вне обратного вызова в строке 6, завершится ошибкой в ​​строке 13. опять же, потому что вызов в строке 13 ищет TLS для текущей транзакции и поскольку строка 13 выполнения потока отличается от потока, который выполнял строку 6, транзакция не может быть найдена. На самом деле приведенный ниже листинг на самом деле имеет другую проблему: поток, обрабатывающий  GET запрос к веб-серверу, запускает строки 6, 8, 9 и 11, а затем возвращает, в какой момент JBoss регистрирует логи  JBAS010152: APPLICATION ERROR: transaction still active in request with status 0 — даже если поток, выполняющий строку 13, может найти транзакцию сомнительно, будет ли он все еще активным или контейнер закроет его.

@Resource UserTransaction ut;

@Override
protected void doGet(HttpServletRequest request, final HttpServletResponse response) throws ServletException, IOException {

    ut.begin();
...
    CompletableFuture<String> cf = new CompletableFuture<>();
    service.foo(cf, pw);
...
    cf.whenCompleteAsync((s, t)->{
...
        ut.commit(); // => exception: "BaseTransaction.commit - ARJUNA016074: no transaction!"
    });
}

 Транзакция явно зависит от потока и TLS. Но не только транзакции основаны на TLS. Возьмем, к примеру, JPA, который либо сконфигурирован для хранения сеанса (то есть соединения с базой данных)  непосредственно в TLS,  либо сконфигурирован для привязки сеанса  к текущей транзакции JTA,  которая, в свою очередь, опирается на TLS. Или, например , проверки безопасности с помощью  Principal которой извлекается из  EJBContextImpl.getCallerPrincipal которых делает вызов  , AllowedMethodsInformation.checkAllowed который затем вызывает  , CurrentInvocationContextкоторый использует протокол TLS и просто возвращается , если контекст не найден в TLS, а не делать надлежащую проверку разрешений , как это сделано на линии 112. 

Эти зависимость от TLS означает, что многие стандартные функции Java EE больше не работают при использовании CompletableFutures или действительно пул разветвленного соединения Java SE или другие пулы потоков, независимо от того, управляются они контейнером или нет. 

Чтобы быть справедливым по отношению к Java EE, то, что я делал здесь, работает так, как задумано! Запуск новых потоков в контейнере EJB фактически запрещен спецификациями. Я помню тест, который я однажды проводил со старой версией Websphere более десяти лет назад — запуск потока вызвал исключение, потому что контейнер действительно строго придерживался спецификаций. Это имеет смысл: не только потому, что контейнер должен управлять числом потоков, но и потому, что зависимость Java EE от TLS означает, что использование новых потоков вызывает проблемы. В некотором смысле это означает, что с помощьюCompletableFuture недопустимо, поскольку использует пул потоков, который не управляется контейнером (пул управляется JVM). То же самое относится и к использованию Java SE  ExecutorService . Java EE 7 ManagedExecutorService — это особый случай — это часть спецификаций, так что вы можете использовать его, но вы должны знать, что это значит. То же самое относится и к  @Asynchronous аннотации на EJB. 

В результате возможно создание асинхронных неблокирующих приложений в контейнере Java EE, но вам действительно нужно знать, что вы делаете, и вам, вероятно, придется обрабатывать такие вещи, как безопасность и транзакции вручную, что в некотором роде задает вопрос о том, почему вы используете контейнер Java EE в первую очередь. 

Так можно ли написать контейнер, который устраняет зависимость от TLS, чтобы преодолеть эти ограничения? Это действительно так, но решение не зависит только от Java EE. Решение может потребовать изменений в языке Java. Много лет назад, до дней внедрения зависимостей, я писал сервисы POJO, которые передавали соединение JDBC от метода к методу, то есть как параметр к методам сервиса. Я сделал это для того, чтобы я мог создавать новые операторы JDBC в одной и той же транзакции, т.е. в том же соединении То, что я делал, не сильно отличалось от того, что должны делать контейнеры JPA или EJB. Но вместо того, чтобы явно передавать такие вещи, как соединения или пользователи, современные фреймворки используют TLS как место для централизованного хранения «контекста», то есть соединений, транзакций, информации о безопасности и т. Д.Пока вы работаете в одном потоке, TLS — отличный способ скрыть такой шаблонный код. Давайте представим, что TLS никогда не был изобретен. Как мы можем передать контекст, не заставляя его быть параметром в каждом методе? в Scala implicit Ключевое слово является одним из решений. Вы можете объявить, что параметр может быть неявно расположен и это создает проблему для компиляторов, чтобы добавить его к вызову метода. Поэтому, если бы в Java SE был внедрен такой механизм, Java EE не нужно было бы полагаться на TLS, и мы могли бы создавать действительно асинхронные приложения, в которых контейнер мог бы автоматически обрабатывать транзакции и безопасность, проверяя аннотации, как мы делаем сегодня! Сказав это, при использовании синхронного Java EE контейнер знает, когда совершать транзакцию — в конце вызова метода, который запустил транзакцию. Если вы выполняете асинхронно, вам нужно явно закрыть транзакцию, потому что контейнер больше не может знать, когда это сделать. 

Конечно, необходимость оставаться неблокируемой и, следовательно, необходимость не зависеть от TLS, сильно зависит от имеющегося сценария. Я не верю, что проблемы, которые я описал здесь, являются общей проблемой сегодня, скорее, это проблема, с которой сталкиваются приложения, работающие в нишевом секторе рынка. Достаточно взглянуть на количество работ, которые в настоящее время предлагаются для хороших инженеров Java EE, где синхронное программирование является нормой. Но я верю, что чем больше становится ИТ-систем программного обеспечения и чем больше данных они обрабатывают, тем больше проблем с блокировкой API. Я также считаю, что эта проблема усугубляется замедлением роста аппаратного обеспечения.Интересно будет посмотреть, будет ли Java а) идти в ногу с тенденциями асинхронной обработки и б) будет ли платформа Java предпринимать шаги, чтобы исправить свою зависимость от TLS.