项目织布机,当虚拟线程进行阻塞系统调用时会发生什么?

16

我正在调查Project Loom的工作原理以及它能为我的公司带来什么样的好处。

因此,我了解到对于标准的基于servlet的后端,总是有一个线程池执行业务逻辑,一旦线程由于IO而被阻塞,它就无法执行任何操作,只能等待。假设我有一个后端应用程序,其中只有一个端点,这个端点背后的业务逻辑是使用JDBC读取一些数据,它内部使用InputStream,而InputStream又会使用阻塞系统调用(在Linux中为read())。因此,如果有200个用户到达此端点,则需要创建200个线程,每个线程都在等待IO。

现在假设我将线程池切换为使用虚拟线程。根据Ben Evans在文章Going inside Java’s Project Loom and virtual threads中的说法:

相反,虚拟线程在进行阻塞调用(如I/O)时会自动放弃(或屈服)其载体线程。

据我所知,如果我拥有与CPU核心数量相同的OS线程数量和不受限制的虚拟线程数量,则所有OS线程仍将等待IO,Executor服务将无法为虚拟线程分配新的工作,因为没有可用的线程来执行它。这与常规线程有何不同,至少对于OS线程,我可以将其扩展到数千个以增加吞吐量。或者我误解了Loom的用例吗?谢谢。

附加信息

我刚刚阅读了这封mailing list

虚拟线程喜欢阻塞I/O。如果线程需要在套接字读取中阻塞,则会释放底层内核线程以进行其他工作

我不确定我是否理解了它,如果线程进行像Socket read这样的阻塞调用,操作系统没有办法释放该线程,因为为此内核具有非阻塞syscall,例如epoll,它不会阻塞线程并立即返回一些可用数据的文件描述符列表。上面的引文是否意味着,在幕后,JVM将替换由虚拟线程调用的阻塞read为非阻塞epoll


我已经修复了你的格式,使其成为引用而不是代码。点击编辑链接以了解如何操作。 - Basil Bourque
3个回答

17

你的第一段摘录缺少了一个重要点:

相反,虚拟线程在发生阻塞调用(例如 I/O)时会自动放弃(或者让出)其载体线程。这由库和运行时处理 [...]

这意味着:如果你的代码通过库进行阻塞调用(例如 NIO),库将检测到你从虚拟线程中调用它,并将把阻塞调用转换为非阻塞调用,使虚拟线程进入停滞状态,继续处理其他虚拟线程的代码。

只有当没有虚拟线程准备好执行时,才会暂停本地线程。

请注意,你的代码从不调用阻塞系统调用,而是调用 Java 库(目前执行阻塞系统调用)。Project Loom 替换了你的代码和阻塞系统调用之间的层,并因此可以任意操作 - 只要对于你的调用代码来说结果看起来相同即可。


2
如果我想使用虚拟线程从套接字中读取并使用InputStream,JVM运行时会注意到它,并且不会使用在Linux中阻塞OS线程的syscall read(),而是将其替换为类似epoll()的东西,添加回调并稍后唤醒虚拟线程以执行回调。我是对的吗? - Almas Abdrazak
我发了自己的答案,还是感谢你的解释。 - Almas Abdrazak
这是Java的一个重大进步。 - gotch4
因此,该库有效地使用默认设置实现了事件循环,类似于selector.select()。一旦任何虚拟线程准备就绪,它将被排队回ForkJoinPool的Scheduler队列,以便进行下一次执行(如果有的话)。是否有任何JVM设置可以帮助配置库中的selector.select()任务运行的线程池,或者在需要此类自定义的情况下,是否应该放弃使用虚拟线程? - undefined
@AbhinavAtul 我建议您针对此问题发布一个新的提问 - undefined

9

我终于找到了一个答案。正如我所说,默认情况下 InputStream.read 方法会进行 read() 系统调用,根据Linux man页面的说明,这将阻塞底层操作系统线程。那么Loom怎么可能不阻塞呢?我找到了一篇文章展示了堆栈跟踪。因此,如果这段代码块将由虚拟线程执行。

URLData getURL(URL url) throws IOException {
  try (InputStream in = url.openStream()) {//blocking call
    return new URLData(url, in.readAllBytes());
  }
}

JVM运行时会将其转换为以下堆栈跟踪。
java.base/jdk.internal.misc.VirtualThreads.park(VirtualThreads.java:60)//this line parks the virtual thread
java.base/sun.nio.ch.NioSocketImpl.park(NioSocketImpl.java:184)
java.base/sun.nio.ch.NioSocketImpl.park(NioSocketImpl.java:212)
java.base/sun.nio.ch.NioSocketImpl.read(NioSocketImpl.java:356)//JVM runtime will replace an actual read() into read from java nio package 
java.base/java.io.InputStream.readAllBytes(InputStream.java:346)

JVM如何知道何时解除虚拟线程的阻塞状态?以下是在readAllBytes完成后将运行的堆栈跟踪。
"Read-Poller" #16
  java.base@17-internal/sun.nio.ch.KQueue.poll(Native Method)
  java.base@17-internal/sun.nio.ch.KQueuePoller.poll(KQueuePoller.java:65)
  java.base@17-internal/sun.nio.ch.Poller.poll(Poller.java:195)

这篇文章的作者使用的是MacOs系统,Mac系统使用的是非阻塞的kqueue系统调用。如果我在Linux上运行它,我会看到epoll系统调用。

所以基本上,Loom没有引入任何新东西,在底层是一个普通的epoll系统调用,其中包含可以使用诸如Vert.x之类的框架实现的回调,而这些框架都是在底层使用Netty。但是,在Loom中,回调逻辑被JVM运行时封装了起来,我觉得这很反直觉。当我调用InputStream.read()时,我期望有相应的read()系统调用,但是JVM将其替换为非阻塞系统调用。


9
当我调用InputStream.read()时,我期望有相应的read()系统调用。你为什么期望这样?因为当前的实现是这样吗?对于你的代码来说,重要的不是执行相应的read()系统调用,而是在从InputStream.read()返回后,已经读取了数据。其他所有细节都应该是实现细节。 - Thomas Kläger
3
我个人认为了解正在执行的系统调用非常重要,这使我能够理解Java中IO包的工作原理,在性能下降的情况下,我可以查看生产机器上的系统调用,并检查哪些进程表现不佳以及原因。 - Almas Abdrazak
@ThomasKläger 只是为了给你举个例子。考虑这种情况 https://gist.github.com/strogiyotec/b156e24137f5a1fcdf0260fb45a21b8f Java io包是不可中断的,为什么?因为它在read()系统调用上阻塞了OS线程,我在工作中遇到了这个问题,为了理解为什么我必须理解在OS调用方面发生了什么,我不相信人们可以在不理解底层原理的情况下修复这些类型的问题,并盲目地相信“从InputStream.read()返回后,数据已被读取” 干杯 - Almas Abdrazak
4
一个 InputStream 并不总是一个 FileInputStream。有 ByteArrayInputStream,即使实际上从文件中读取,FileInputStream 也可能被包装在 BufferedInputStream 中,但它也可能是一个 ZipInputStream 包装了一个 FileInputStream,而且并不是每个 read 调用都会最终变成系统调用。即使它最终变成了系统调用,也从来没有规定这个系统调用必须与此方法具有相同的名称。如果你认为你必须知道发生了什么,那没关系,但实现没有义务遵循你的期望。 - Holger
@Holger 你说得对,jdk并没有明确说明使用了哪个系统调用,因为这也取决于操作系统,并且可以很容易地被Oracle更改。 - Almas Abdrazak

2

Thomas Kläger的回答是正确的。我会补充一些想法。

据我所知,如果我的操作系统线程数量等于CPU核心数量,并且有无限量的虚拟线程,所有操作系统线程仍将等待IO

不,这是错误的,你误解了。

您描述的是Java中当前线程技术发生的情况。使用Java线程与主机操作系统线程的一对一映射,在Java中进行的任何阻塞调用(等待相对较长时间以获取响应)都会使该主机线程闲置,没有工作可做。如果主机有无数个线程,那么其他线程可以被安排在CPU核心上工作,这将不是一个问题。但是,主机操作系统线程非常昂贵,因此我们没有无数个线程,只有很少的线程。

使用Project Loom技术,JVM检测到阻塞调用,例如等待I/O。一旦检测到,JVM会将虚拟线程暂停(“park”),并等待I/O响应。JVM将不同的虚拟线程分配给主机操作系统载体线程,以便“真实”线程可以继续执行工作,而不是闲着等待。由于生活在JVM中的虚拟线程非常便宜(内存和CPU效率都很高),我们可以有数千甚至数百万个虚拟线程供JVM管理。

以您的示例为例,200个线程每个线程都在等待来自对数据库的JDBC调用的I/O响应,如果这些线程是虚拟线程,那么所有线程都将被停放在JVM中。您的ExecutorService使用的少量主机操作系统线程将处理其他当前未被阻塞的虚拟线程。这种阻塞-解除阻塞虚拟线程的停放和重新调度由JVM内的Project Loom技术自动处理,Java应用程序开发人员无需进行干预。

假设我将线程池切换为使用虚拟线程

实际上,并没有虚拟线程池。每个虚拟线程都是全新的,没有回收利用。这消除了对线程本地污染的担忧。

ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor() ;
…
executorService.submit( someTask ) ;  // Every task submitted gets assigned to a fresh new virtual thread.

如果想要了解更多,我强烈推荐观看 Ron Pressler 或 Alan Bateman 的演示和采访视频,他们都是 Project Loom 团队的成员。请查找最新版本,因为 Loom 正在不断发展。

同时阅读新的 Java JEP,JEP 草案:虚拟线程(预览版)


感谢回复。当JVM检测到阻塞IO并将虚拟线程挂起而不是OS线程时,这一部分是如何实现的?Linux中的read()系统调用会阻塞OS线程,如果虚拟线程执行阻塞的系统调用,则会阻塞OS线程,我的假设是正确的吗?JVM将替换所有read()调用为epoll(),以便OS线程不会被阻塞吗? - Almas Abdrazak
@AlmasAbdrazak 我不知道那些技术细节。你学习过JEP吗?此外,Loom团队可能会欢迎查询这些细节,因为他们现在正在寻求公众的输入和反馈。他们的源代码是开源的,有实现现在可用于各种平台,包括x64上的Windows,以及x64和AArch64上的Linux和macOS。 - Basil Bourque
我读了JEP,对于虚拟线程在阻塞系统调用期间如何不阻塞OS线程仍然不清楚。据我所知,输入/输出流类已更改以删除同步块。此外,从JEP中可以看出:“当虚拟线程尝试停放时,例如通过执行阻塞I/O操作,而被固定而不是释放时,其底层的OS线程将在操作期间被阻塞。”这里的“固定”意味着VM无法暂停它。因此,我认为使用InputStream从套接字读取或使用JDBC与数据库交互仍会阻塞os线程。 - Almas Abdrazak
我会阅读源代码以更好地理解底层发生的事情,谢谢。 - Almas Abdrazak

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接