异步 EJB 只是一个噱头吗?

一则或许对你有用的小广告

欢迎加入小哈的星球 ,你将获得:专属的项目实战 / Java 学习路线 / 一对一提问 / 学习打卡/ 赠书活动

目前,正在 星球 内带小伙伴们做第一个项目:全栈前后端分离博客项目,采用技术栈 Spring Boot + Mybatis Plus + Vue 3.x + Vite 4手把手,前端 + 后端全栈开发,从 0 到 1 讲解每个功能点开发步骤,1v1 答疑,陪伴式直到项目上线,目前已更新了 204 小节,累计 32w+ 字,讲解图:1416 张,还在持续爆肝中,后续还会上新更多项目,目标是将 Java 领域典型的项目都整上,如秒杀系统、在线商城、IM 即时通讯、权限管理等等,已有 870+ 小伙伴加入,欢迎点击围观

在之前的文章( 这里 这里 )中,我展示了创建非阻塞异步应用程序可以在服务器负载很重时提高性能。 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 行。该方法返回一个 String 类型的 Future ,并在第 10 行通过将输出包装在 AsyncResult 中来实现。在客户端代码调用 EJB 方法时,容器拦截调用并创建一个任务,它将在不同的线程上运行,以便它可以立即返回 Future 。当容器随后使用不同的线程运行任务时,它调用 EJB 的方法并使用 AsyncResult 来完成调用者提供的 Future 。这段代码有几个问题,尽管它看起来与在 Internet 上找到的所有示例中的代码一模一样。例如, Future 类仅包含用于获取 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);
}

}

这种代码很糟糕,因为它会导致线程阻塞,这意味着它们在此期间无法做任何有用的事情。虽然其他线程可以运行,但需要进行上下文切换,这会浪费时间和精力(有关成本的详细信息或我以前文章的结果,请参阅 这篇好文章 )。像这样的代码会导致已经负载的服务器承受更多负载,并逐渐停止。

那么有没有可能让容器异步执行方法,而是编写一个不需要阻塞线程的 客户端 呢?这是。下面的清单显示了这样做的 servlet。


 @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);
}

}


第 1 行声明 servlet 支持异步运行——不要忘记这一点!第 8-10 行开始将数据写入响应,但有趣的是第 13 行调用了异步服务方法。我们没有使用 Future 作为返回类型,而是传递给它一个 CompletableFuture ,它用它来返回结果。如何?第 16 行启动了异步 servlet 上下文,这样我们仍然可以在 doGet 方法返回后写入响应。从第 17 行开始,有效地在 CompletableFuture 上注册了一个回调,一旦 CompletableFuture 完成并返回结果,该回调将被调用。这里没有阻塞代码——没有线程被阻塞,也没有线程被轮询,等待结果!在负载下,服务器中的线程数可以保持在最低限度,确保服务器可以高效运行,因为需要更少的上下文切换。

服务实现如下所示:



 @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);
}

}

第 7 行真的很难看,因为它会阻塞,但假装这是调用远程部署在 Internet 或慢速数据库中的 Web 服务的代码,使用会阻塞的 API,就像大多数 Web 服务客户端和 JDBC 驱动程序所做的那样。或者,使用 异步驱动程序 ,当结果可用时,完成未来,如第 9 行所示。然后向 CompletableFuture 发出信号,表明可以调用在上一个清单中注册的回调。

这不就像使用一个简单的回调吗?它当然是相似的,下面的两个清单显示了使用自定义回调接口的解决方案。


 @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);
}

}


 @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);
    }
}

同样,在客户端中,绝对不会发生阻塞。但由于以下原因,AsyncServlet2 与 Service3 类一起使用 CompletableFuture 的早期示例更好:

  • CompletableFuture 的 API 允许异常/失败,
  • CompletableFuture 类提供异步执行回调和相关任务的方法,即在 fork-join 池中,以便整个系统使用尽可能少的线程运行,从而可以更有效地处理并发,
  • CompletableFuture 可以与其他组合,以便您可以注册一个仅在多个 CompletableFuture 完成时调用的回调,
  • 回调不会立即调用,而是池中有限数量的线程按照它们应运行的顺序为 CompletableFuture 的执行服务。

第一次上市后,我提到异步 EJB 方法的实现存在几个问题。除了阻塞客户端之外,另一个问题是根据 EJB 3.1 规范 的第 4.5.3 章,客户端事务上下文不会通过异步方法调用进行传播。如果您想使用 @Asynchronous 注释创建两个可以并行运行并在单个事务中更新数据库的方法,这是行不通的。这限制了使用


使用 CompletableFuture,您可能认为您可以在同一事务上下文中并行运行多个任务,方法是首先在 EJB 中启动一个事务,然后创建多个可运行对象并使用在执行中运行它们的 runAsync 方法运行它们池,然后使用 allOf 方法注册一个回调以在所有操作完成后触发。但是你可能会因为很多事情而失败:

  • 如果您使用容器管理的事务,那么一旦导致事务启动的 EJB 方法将控制权返回给容器,事务就会被提交——如果您的未来在那时还没有完成,您将不得不阻塞运行 EJB 方法的线程使其等待并行执行的结果,而阻塞正是我们要避免的,
  • 如果运行任务的单个执行池中的所有线程都被阻塞等待它们的数据库调用响应,那么您将面临创建性能不佳的解决方案的危险 - 在这种情况下,您可以尝试使用非阻塞 异步驱动程序 ,但不是每个数据库都有这样的驱动程序,
  • 一旦任务在不同的线程(例如执行池中的线程)上运行,线程本地存储 (TLS) 就不再可用,因为正在运行的线程与将工作提交到执行池并设置的线程不同在提交作品之前将值导入 TLS,
  • EntityManager 等资源 不是线程安全的 。这意味着您不能将 EntityManager 传递到提交到池的任务中,而是每个任务都需要掌握它自己的 EntityManager 实例,但是 EntityManager 的创建取决于 TLS(见下文)。

让我们用下面的代码更详细地考虑 TLS,它显示了一个异步服务方法试图做几件事,以测试允许的内容。



 @Stateless
public class Service2 {
@Asynchronous
public Future&lt;String&gt; foo(String s) {
    // simulate some long running process
    Thread.sleep(5000);

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

}

第12行没问题,可以回滚容器调用EJB方法时第9行自动启动的事务。但是该事务不会是调用第 9 行的代码启动的全局事务。第 16 行也没有问题,您可以使用 EntityManager 写入第 9 行启动的事务内的数据库。第 4 和 18 行显示另一种在不同线程上运行代码的方法,即使用 Java EE 7 中引入的 ManagedExecutorService 。但是只要依赖 TLS,这也会失败,例如第 22 行和第 31 行会导致异常,因为在第 9 行开始的事务无法定位,因为使用了 TLS,并且第 21-35 行的代码使用与第 19 行之前的代码不同的线程运行。

下一个清单显示,第 11-14 行在 CompletableFuture 上注册的完成回调也运行在与第 4-10 行不同的线程中,因为提交在第 6 行回调之外启动的事务的调用将在第 6 行失败13,再次因为第 13 行的调用在 TLS 中搜索当前事务,并且因为运行第 13 行的线程与运行第 6 行的线程不同,所以找不到事务。事实上,下面的清单实际上有一个不同的问题:处理对 Web 服务器的 GET 请求的线程运行第 6、8、9 和 11 行,然后它返回,此时 JBoss 记录 JBAS010152: APPLICATION ERROR: transaction still active in request with status 0 - 即使运行第 13 行的线程可以找到该事务,它是否仍处于活动状态或容器是否会关闭它都是值得怀疑的。



 @Stateless
public class Service2 {
@Asynchronous
public Future&lt;String&gt; foo(String s) {
    // simulate some long running process
    Thread.sleep(5000);

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

}


事务显然依赖于线程和 TLS。但依赖 TLS 的不仅仅是交易。以 JPA 为例,它要么被配置 为直接在 TLS 中 存储会话(即与数据库的连接),要么被配置为将会话范围限定 为当前依赖于 TLS 的 JTA 事务 。或者以使用从 EJBContextImpl.getCallerPrincipal 获取的 Principal 进行安全检查为例,它调用 AllowedMethodsInformation.checkAllowed 然后调用使用 TLS 的 CurrentInvocationContext ,如果在 TLS 中找不到上下文则简单地返回,而不是进行适当的权限检查正如第 112 行所做的那样。

这些对 TLS 的依赖意味着许多标准 Java EE 功能在使用 CompletableFuture 或 Java SE fork-join 池或其他线程池时不再起作用,无论它们是否由容器管理。

对 Java EE 公平地说,我在这里所做的一切都是按设计工作的!规范实际上禁止在 EJB 容器中启动新线程。我记得十多年前我曾经用旧版本的 Websphere 运行过一个测试——启动一个线程导致抛出异常,因为容器确实严格遵守规范。这是有道理的:不仅因为线程的数量应该由容器管理,而且因为 Java EE 对 TLS 的依赖意味着使用新线程会导致问题。在某种程度上,这意味着使用 CompletableFuture 是非法的,因为它使用了一个不受容器管理的线程池(该池由 JVM 管理)。使用 Java SE 的 ExecutorService 也是如此。 Java EE 7 的 ManagedExecutorService 是一个特例 - 它是规范的一部分,因此您可以使用它,但您必须了解这样做的意义。 EJB 上的 @Asynchronous 注释也是如此。

结果是,在 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 工程师提供的工作数量,其中同步编程是常态。但我确实相信,IT 软件系统变得越大,它们处理的数据越多,阻塞 API 就越会成为一个问题。我还认为,当前硬件增长速度放缓加剧了这个问题。有趣的是,Java 是否 a) 需要跟上异步处理的趋势,以及 b) Java 平台是否会采取行动来修复其对 TLS 的依赖。