Статьи

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

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

Service2.java

01
02
03
04
05
06
07
08
09
10
11
12
@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 выходные данные в AsyncResult . В тот момент, когда клиентский код вызывает метод EJB, контейнер перехватывает вызов и создает задачу, которую он будет выполнять в другом потоке, чтобы он мог немедленно вернуть Future . Когда контейнер затем запускает задачу, используя другой поток, он вызывает метод EJB и использует AsyncResult для завершения Future которое было дано вызывающей стороне. Есть несколько проблем с этим кодом, хотя он выглядит точно так же, как код во всех примерах, найденных в Интернете. Например, класс Future содержит только блокирующие методы для получения результата Future , а не любые методы для регистрации обратных вызовов, когда он завершается. В результате получается код, подобный следующему, что плохо при загрузке контейнера:

Client.java

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
//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...

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

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

AsyncServlet2.java

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@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 в качестве возвращаемого типа мы передаем ему CompletableFuture , который он использует для возврата нам результата. Как? Хорошо, строка 16 запускает асинхронный контекст сервлета, так что мы все еще можем писать в ответ после возврата метода doGet . Строки 17 и далее эффективно регистрируют обратный вызов в CompletableFuture который будет вызываться после завершения CompletableFuture с результатом. Здесь нет кода блокировки — ни один поток не заблокирован, ни один поток не опрошен, ожидая результата! Под нагрузкой количество потоков на сервере может быть сведено к минимуму, обеспечивая эффективную работу сервера, поскольку требуется меньше переключений контекста.

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

Service3.java

01
02
03
04
05
06
07
08
09
10
11
@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 что может быть вызван обратный вызов, зарегистрированный в предыдущем листинге.

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

AsyncServlet3.java

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
@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
...

Service4.java

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
@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 вместе с классом AsyncServlet2 , который использует CompletableFuture , лучше по следующим причинам:

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

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

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

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

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

Service5.java

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@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, может найти транзакцию, сомнительно, будет ли она по-прежнему активной или контейнер ее закрыл.

AsyncServlet5.java

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
@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 больше не работают при использовании CompletableFuture или пула Java SE-fork-join или даже других пулов потоков, независимо от того, управляются они контейнером или нет.

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

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

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

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