vlambda博客
学习文章列表

Java 19即将支持虚拟线程,我们探索如何在Tomcat中使用虚拟线程

Virtual Threads and Tomcat

虚拟线程是运行大部分阻塞任务的理想机制,提供高水平的并发性,而不需要业务逻辑程序员的异步杂技。我展示了为虚拟线程配置 Tomcat 是很容易的,只要对 Tomcat 源代码进行一些小改动。

虚拟线程

Java 19 具有 虚拟线程 作为预览功能,在 JEP 425 中进行了描述。虚拟线程被安排在平台线程中运行。当一个虚拟线程阻塞时,它停放,另一个虚拟线程可以在它的位置运行。大量虚拟线程可以并发运行,前提是它们大多是阻塞的。这种工作负载在 Web 应用程序中很典型,其中请求花费大量时间等待来自数据库查询或其他外部服务的响应。

虚拟线程的任务可以用同步风格编写,用标准 Java 语言结构表达控制流:方法调用(可能阻塞)、循环和异常。这与带有回调或链式CompletableFuture 异步风格 形成鲜明对比。

例如,在一个收集图像的应用程序中,我有以下异步样式代码:

CompletableFuture.supplyAsync(info::getUrl, pool)
   .thenCompose(url -> getBodyAsync(url, HttpResponse.BodyHandlers.ofString()))
   .thenApply(info::findImage)
   .thenCompose(url -> getBodyAsync(url, HttpResponse.BodyHandlers.ofByteArray()))
   .thenApply(info::setImageData)
   .thenAccept(this::process)
   .exceptionally(t -> { t.printStackTrace(); return null; });

使用虚拟线程,我可以像这样重写它:

try {
   String page = getBody(info.getUrl(), HttpResponse.BodyHandlers.ofString());
      // Blocks, but with virtual threads, blocking is cheap
   String imageUrl = info.findImage(page);
   byte[] data = getBody(imageUrl, HttpResponse.BodyHandlers.ofByteArray()); // Blocks
   info.setImageData(data);
   process(info);
} catch (Throwable t) {
   t.printStackTrace();
}

你更喜欢写哪个版本?易读?易调试?

我猜大多数 Java 程序员更喜欢使用 Java 语法来编写业务逻辑。这是使用虚拟线程的主要好处。

具有虚拟线程的客户端

我需要测试我对 Tomcat 的修改。我本可以使用 shell 脚本或 JMeter,但为了练习使用虚拟线程,这里有一个 简单客户端。客户端只需放置请求并将结果放入阻塞队列:

var q = new LinkedBlockingQueue<String>();
try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) {
   for (int i = 0; i < nthreads; i++) {
      exec.submit(() -> {
         try {
            String response = getBody(url + "?sent=" + Instant.now(), HttpResponse.BodyHandlers.ofString());
            q.put(response);
            Thread.sleep(delay);
         }
         catch (Exception ex) {
            ex.printStackTrace();
         }
      });
   }
}

这是标准的并发 Java 代码。与虚拟线程只有两个区别:

  • You use Executors.newVirtualThreadPerTaskExecutor() instead of a thread pool.
  • Since Java 19, the ExecutorService implements AutoCloseable, with a close method that blocks until all submitted tasks have completed.

每个提交的任务都会阻塞对 getBodyputsleep 的调用。此外,执行器的 close 方法中的执行块。我不在乎。使用虚拟线程,阻塞很便宜。

要试用客户端,请下载 code.zip,解压缩并运行(使用 Java 19):

cd code
mkdir bin
javac -d bin --enable-preview --release 19 src/webclient/*.java
java -cp bin --enable-preview webclient.Main 100 10 https://horstmann.com/random/word

小服务程序

安装Tomcat。将 CATALINA_HOME 环境变量设置为安装目录。检查 JAVA_HOME 是否指向 Java 19 安装。

我们将使用一个休眠一小会儿并报告一些数据的 servlet:

  • The value of the sent request parameter (which the client sets to the time the request was sent)
  • The time the doGet method was called
  • The time the doGet method finished sleeping
  • The thread's toString

这是 ThreadInfo.java。您还需要这个 web.xml 文件。

编译和部署:

javac -d web/WEB-INF/classes/ -cp $CATALINA_HOME/lib/\* src/servlet/*.java
jar cvf ThreadDemo.war -C web .
cp ThreadDemo.war $CATALINA_HOME/webapps/

要进行测试,请启动 Tomcat:

$CATALINA_HOME/bin/startup.sh

然后使用 curl

curl -s "http://localhost:8080/ThreadDemo/threadinfo?sent=$(date -Iseconds)"

或者运行客户端:

java -cp bin --enable-preview webclient.Main 1

您应该得到如下所示的响应:

Sent: 2022-05-17T18:27:10.244228524Z Started: 2022-05-17T18:27:10.287297797Z Completed: 2022-05-17T18:27:11.304507264Z Thread[http-nio-8080-exec-2,5,main]

现在运行 200 个请求并注意完成时间:

time java -cp bin --enable-preview webclient.Main 200

对我来说,这花了 3.6 秒。

替换Tomcat中的Executor

在 Tomcat 中,您可以插入您自己的线程池实现。关于谁使用线程池的文档很模糊。如果您使用默认的 Http11NioProtocol,那么该线程池将用于为每个请求提供服务的线程。这是虚拟线程的最佳选择。

有一个更现代的 Http11Nio2Protocol,它使用可插入的执行器作为其内部线程,但对服务请求的线程使用单独的、显然不可配置的线程池。我没有进一步追究。一般来说,用虚拟线程替换所有线程是没有意义的。您希望专注于那些运行阻塞工作负载的部分,这些部分具有可以从同步编程风格中受益的任意业务。

执行器需要实现这个Executor接口。我在这个 < code>VirtualThreadExecutor.java 类。这是关键点:

private ExecutorService exec = Executors.newThreadPerTaskExecutor(
   Thread.ofVirtual()
      .name("myfactory-", 1)
      .factory());      

public void execute(Runnable command) {
   exec.submit(command);
}

这个 server.xml 文件定义执行器并将其用于每个连接器。以下是从标准配置修改的条目:

    <Executor name="virtualThreadExecutor" className="executor.VirtualThreadExecutor"/>
    <Connector  port="8080" protocol="HTTP/1.1"
       connectionTimeout="20000"
       maxConnections="-1"
       maxThreads="10000"
       redirectPort="8443" />

以下是安装执行器的命令:

javac -d bin -cp $CATALINA_HOME/lib/\* --enable-preview --release 19 \
  src/executor/*.java
jar cvf vte.jar -C bin executor
cp vte.jar $CATALINA_HOME/lib/
cp conf/server.virtualthreads.xml $CATALINA_HOME/conf/server.xml

重启Tomcat:

$CATALINA_HOME/bin/shutdown.sh
JAVA_OPTS="--enable-preview -Djdk.tracePinnedThreads=full" \
   $CATALINA_HOME/bin/startup.sh

将浏览器指向 http://localhost:8080。如果您没有看到 Tomcat 欢迎窗口,请检查 JAVA_HOMECATALINA_HOME 是否设置正确,并查看 $CATALINA_HOME/logs/catalina.out代码>寻找线索。

现在再次运行 Web 客户端:

java -cp bin --enable-preview webclient.Main 1

请注意,线程是由 myfactory 创建的。

运行 200 个并发请求:

java -cp bin --enable-preview webclient.Main 200

哇! 27 秒。这比以前更糟糕了!发生了什么?

查找阻塞线程

查看时序数据,我们看到基本上同时提交了 200 个请求。当 Tomcat 收到它们时会发生什么?让我们重新提交并在之后立即进行线程转储:

jcmd $(jcmd | grep catalina | cut -d " " -f 1) Thread.dump_to_file -overwrite -format=json /tmp/catalina.json

现在查看 /tmp/catalina.json。 Firefox 很好地显示了 JSON。

Java 19即将支持虚拟线程,我们探索如何在Tomcat中使用虚拟线程

大约有 200 个虚拟线程,其中许多没有堆栈。他们不可能完成,否则我们会在创建时间后不久看到他们的输出。在它们启动和 doGet 方法开始之间似乎有什么东西阻止了它们。

这可能是 Tomcat 故意限制的,但是当使用默认执行程序运行 200 个请求时,为什么不会发生相同的限制呢?更有可能的原因是阻塞

在某些情况下,阻塞虚拟线程并不便宜

  • When invoking a native method that does file I/O, invokes a process, etc.
  • When blocking on an intrinsic lock (i.e. one declared with synchronized).

在这些情况下,载体线程阻塞,它不能运行其他虚拟线程。它被阻塞。如果所有carrier线程都被阻塞,则没有虚拟线程可以进行。

第二个限制最终应该会消失,因为 JVM 被重新设计以更好地支持虚拟线程。但这是目前的一个问题。在您自己的代码中,您可以通过使用 ReentrantLock 或其他机制来避免它。但是如果你使用第三方代码,这就比较麻烦了。例如,Tomcat 源代码中出现了大约 1,000 次 synchronized 关键字,您不想查看所有这些关键字。

VM 标志 -Djdk.tracePinnedThreads 将在 pinning 发生时打印堆栈跟踪。 (如果多个线程出于相同的原因 pin ,将只有一个报告。)查看 $CATALINA_HOME/logs/catalina.out 内部,您可能会看到这样的堆栈跟踪。仔细寻找像这样的 <== 箭头:

...
org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:49) <== monitors:1
...

或者,您可以使用 Java Flight Recorder。开始录制:

jcmd $(jcmd | grep catalina | cut -d " " -f 1) JFR.start name=catalina filename=/tmp/catalina.jfr

启动 200 个请求,并停止记录:

jcmd $(jcmd | grep catalina | cut -d " " -f 1) JFR.stop name=catalina

在您最喜欢的 Mission Control 查看器中加载 /tmp/catalina.jfr。我使用了 ZMC。在左侧窗格中,查找 Event Browser。在 Search the tree 字段中输入 Virtual。然后寻找阻塞虚拟线程。

Java 19即将支持虚拟线程,我们探索如何在Tomcat中使用虚拟线程

无论哪种方式,您现在都有一个堆栈跟踪,您可以在其中找到有问题的一方。下载 Tomcat 源代码并查看堆栈跟踪。在我们的例子中,定位到是 java/org/apache/tomcat/util/net/SocketProcessorBase.java 中的以下代码:

    public final void run() {
        synchronized (socketWrapper) {
            // It is possible that processing may be triggered for read and
            // write at the same time. The sync above makes sure that processing
            // does not occur in parallel. The test below ensures that if the
            // first event to be processed results in the socket being closed,
            // the subsequent events are not processed.
            if (socketWrapper.isClosed()) {
                return;
            }
            doRun();
        }
    }

我用

   private Lock socketWrapperLock = new ReentrantLock();
   public final void run() {
      socketWrapperLock.lock();       
      try {
         if (socketWrapper.isClosed()) {
            return;
         }
         doRun();
      }
      finally {
         socketWrapperLock.unlock();
      }
   }

要对此进行测试,请更改源代码。然后:

cd /path/to/apache-tomcat-10.0.20-src
ant
cd - # back to the code directory
$CATALINA_HOME/bin/shutdown.sh
CATALINA_HOME=/path/to/apache-tomcat-10.0.20-src/output/build
cp ThreadDemo.war $CATALINA_HOME/webapps/
cp vte.jar $CATALINA_HOME/lib/
cp conf/server.virtualthreads.xml $CATALINA_HOME/conf/server.xml
JAVA_OPTS="--enable-preview -Djdk.tracePinnedThreads=full" \
   $CATALINA_HOME/bin/startup.sh
java -cp bin --enable-preview webclient.Main 200

有了这个改变,200 个请求需要 3 秒,而 Tomcat 可以轻松处理 10,000 个请求。

这一切意味着什么

当早期版本的虚拟线程出现时,每个人都兴奋地同时运行了一百万个。但这并不是他们令人兴奋的地方。您可以拥有一百万个休眠线程,但如果它们执行任何实际工作,它们将消耗堆栈和堆资源。 Loom 不会动态存储,并且您会受到同时运行的任务资源的限制。

假设任务大部分是阻塞的,那么你有足够的资源来处理比操作系统线程更多的任务。然后虚拟线程的真正优势开始发挥作用。您从异步风格的hell中解脱出来。不再仔细构造 thenThisthenThatexceptionally 的管道,而只是循环、函数调用和异常。

这对于业务逻辑特别有吸引力,因为业务逻辑出了名的繁琐并且充满了难以用反应式框架开箱即用的方法来表达的边缘情况。而且它们是由可能没有这些框架通常需要的聪明才智和耐心的程序员实现的。

这些程序员将要求 servlet 运行器和应用程序框架的提供者提供虚拟线程。这并不是一件难事。对于框架提供者。他们知道那些线程池在哪里,以及在线程启动和服务方法之间可能需要调整哪些代码。您刚刚在一个简单的案例中看到了如何做到这一点。