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
implementsAutoCloseable
, with aclose
method that blocks until all submitted tasks have completed.
每个提交的任务都会阻塞对 getBody
、put
和 sleep
的调用。此外,执行器的 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_HOME
和 CATALINA_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。
大约有 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。然后寻找阻塞虚拟线程。
无论哪种方式,您现在都有一个堆栈跟踪,您可以在其中找到有问题的一方。下载 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中解脱出来。不再仔细构造 thenThis
和 thenThat
和 exceptionally
的管道,而只是循环、函数调用和异常。
这对于业务逻辑特别有吸引力,因为业务逻辑出了名的繁琐并且充满了难以用反应式框架开箱即用的方法来表达的边缘情况。而且它们是由可能没有这些框架通常需要的聪明才智和耐心的程序员实现的。
这些程序员将要求 servlet 运行器和应用程序框架的提供者提供虚拟线程。这并不是一件难事。对于框架提供者。他们知道那些线程池在哪里,以及在线程启动和服务方法之间可能需要调整哪些代码。您刚刚在一个简单的案例中看到了如何做到这一点。