java虚拟线程网络I/O底层实现 翻译

1,848 阅读9分钟

虚拟线程网络I/O底层实现

Chris Hegarty on May 10, 2021 对inside.java/2021/05/10/…

Project Loom计划在java平台为java虚拟机带来新的特性和API以支持简单易用,高吞吐轻量级并发和新的编程模型。这带来了许多有趣的前途,其中之一就是简化网络交互的代码。现在的服务器可以处理远超其能够支持线程数量的打开的socket,这既带来了机遇又带来了挑战

Loom.png

不幸的是书写具有良好扩展性的网络交互代码是非常困难的。如果超过一个阈值,使用同步api就无法扩展性能规模,因为这样的api在执行I/O操作时可能会阻塞,进而阻塞一个线程直到这个操作准备好。比如:当尝试从socket读取一些数据,但是当前没有数据可用。在现在的java平台上线程是非常昂贵的资源,等待I/O操作完成的代价太大。为了解决这个限制,我们通常使用异步I/O或响应式框架,因为他们可以用来编写在I/O过程中不会绑定一个线程的代码,而是在I/O操作完成或者就绪时使用回调或者事件通知机制

使用异步和非阻塞api(比使用同步api)更具挑战性,部分原因是它们导致的代码编写对人来说不自然。同步api在大多数情况下更容易使用;代码更易于编写、阅读和调试(有意义的堆栈跟踪!)但是正如前面列出的,代码使用同步api不能想异步变体那样有很好的扩展性,这留下一个不好的选择——选择更简单的同步代码和接受它将不能具有扩展性,或选择更加可伸缩异步代码和处理所有它的复杂性。这不是一个好的选择!Project Loom的一个引人注目的价值主张就是避免不得不做出这样的选择——同步代码应该是可以扩展的。

在这篇文章里面,我们将了解在调用虚拟线程时,Java平台的网络api在底层是如何工作的。细节在很大程度上是实现的产物,我们并不需要知道什么时候在上面编写代码,但是了解在底层是如何工作的仍然是很有意思的事情,而且可能可以帮助回答一些问题,如果没有答案,可能会导致再次不得不做出艰难的选择。

虚拟线程

在继续深入之前,我们需要对Project Loom中的新型线程—Virtual threads有一点了解。

虚拟线程是由Java虚拟机而不是操作系统调度的用户模式线程。虚拟线程只需要很少的资源,而一个Java虚拟机可能支持数百万个虚拟线程。对于执行花费大量时间阻塞的任务(通常是等待I/O操作完成),虚拟线程是一个很好的选择。

平台线程(我们在当前版本Java平台都很熟悉的线程)通常是1:1映射到操作系统调度的内核线程。平台线程通常有一个大的堆栈和其他由操作系统维护的资源。

虚拟线程通常使用一组平台线程作为载体线程。在虚拟线程中执行的代码通常不会意识到底层的载体线程。锁和I/O操作是调度点,其中载体线程将从一个虚拟线程重新调度到另一个虚拟线程。虚拟线程可能被暂停,这将使其无法进行调度。一个暂停的虚拟线程可以被启动,这将重新启用它以进行调度。

网络API

在java平台上面有两大类网api

  1. 异步 - AsynchronousServerSocketChannel, AsynchronousSocketChannel
  2. 同步 -java.net Socket / ServerSocket / DatagramSocket, java.nio.channels.SocketChannel / ServerSocketChannel / DatagramChannel

第一类,异步,初始化I/O操作将在之后的某一时间完成,可能是在一个线程上而不是在初始化I/O操作的的线程上。根据定义,这些api并不会导致阻塞系统调用,因此在虚拟线程运行时并不需要特殊处理

第二类,同步,从它们在虚拟线程中运行时的行为角度来看更有趣。在这个类别中有可以以非阻塞模式配置的NIO通道。这类通道通常注册为I/O事件通知机制,如Selector,并且不执行阻塞系统调用。与异步网络api类似,这些api在虚拟线程中运行时不需要特殊处理,因为I/O操作不会调用阻塞系统调用本身,这通常是留给selector的。因此,这使得java.net.socket类型和NIO通道配置为阻塞模式。让我们看看它们在虚拟线程中是如何工作的。

同步api的语义要求I/O操作一旦开始初始化,在控制权返回给调用者之前这个操作完成或失败必须在调用线程中。但是,如果I/O操作“没有准备好”,例如,没有数据读取一个socket?

同步阻塞API

在虚拟线程中运行的java同步网络api会将底层socket设置为非阻塞模式,java代码调用的I/O操作没有立刻完成时(原生socket返回EAGAIN-“还没准备好”/“将阻塞”),原生socket会被注册到JVM范围的一个消息通知机制上面(一个轮询器),之后虚拟线程将被暂停,当底层的I/O操作完成时(一个事件当到达轮询器),虚拟线程会被启动之后将重试底层的socket操作

让我们更近距离看看这个例子,这个retrieveURLs方法将下载并且返回多个url对应的响应

// Tuple of URL and response bytes
record URLData (URL url, byte[] response) { }

List<URLData> retrieveURLs(URL... urls) throws Exception {
  try (var executor = Executors.newVirtualThreadExecutor()) {
    var tasks = Arrays.stream(urls)
            .map(url -> (Callable<URLData>)() -> getURL(url))
            .toList();
    return executor.submit(tasks)
            .filter(Future::isCompletedNormally)
            .map(Future::join)
            .toList();
  }
}

retrieveURLs方法创造了一个任务的列表(为每个URL)然后把他们投递到线程池中,之后等待结果。线程池为每个任务开启一个新的虚拟线程,他们会调用getURL.为简单起见,只返回成功完成的任务。

getURL方法编写成使用同步URLConnection API来获得响应。

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

readAllBytes方法是一个读取所有响应字节的批量同步读取操作。在外壳之下,readAllBytes 最终在java.net.socket输入流的'read 方法中达到最底层。

如果我们运行一个小程序,使用retrieveURLs下载一个HTTP URL,而HTTP服务器没有提供完整的响应,我们可以检查线程的状态如下:

$ java Main & echo $!
89215
$ jcmd 89215 JavaThread.dump threads.txt
Created /Users/chegar/threads.txt

threads.txt中,我们看到了通常的系统线程,以及我们的测试程序的主线程,以及在读取操作中阻塞的虚拟线程。注意:虚拟线程没有名称,除非显式地指定一个,因此是没有命名的

$ cat threads.txt
...
"<unnamed>" #15 virtual
  java.base/java.lang.Continuation.yield(Continuation.java:402)
  java.base/java.lang.VirtualThread.yieldContinuation(VirtualThread.java:367)
  java.base/java.lang.VirtualThread.park(VirtualThread.java:534)
  java.base/java.lang.System$2.parkVirtualThread(System.java:2370)
  java.base/jdk.internal.misc.VirtualThreads.park(VirtualThreads.java:60)
  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.implRead(NioSocketImpl.java:320)
  java.base/sun.nio.ch.NioSocketImpl.read(NioSocketImpl.java:356)
  java.base/sun.nio.ch.NioSocketImpl$1.read(NioSocketImpl.java:807)
  java.base/java.net.Socket$SocketInputStream.read(Socket.java:988)
  java.base/java.io.BufferedInputStream.fill(BufferedInputStream.java:255)
  java.base/java.io.BufferedInputStream.read1(BufferedInputStream.java:310)
  java.base/java.io.BufferedInputStream.lockedRead(BufferedInputStream.java:382)
  java.base/java.io.BufferedInputStream.read(BufferedInputStream.java:361)
  java.base/sun.net.www.MeteredStream.read(MeteredStream.java:141)
  java.base/java.io.FilterInputStream.read(FilterInputStream.java:132)
  java.base/sun.net.www.protocol.http.HttpURLConnection$HttpInputStream.read(HttpURLConnection.java:3648)
  java.base/java.io.InputStream.readNBytes(InputStream.java:409)
  java.base/java.io.InputStream.readAllBytes(InputStream.java:346)
  Main.getURL(Main.java:24)
  Main.lambda$retrieveURLs$0(Main.java:13)
  java.base/java.util.concurrent.FutureTask.run(FutureTask.java:268)
  java.base/java.util.concurrent.ThreadExecutor$TaskRunner.run(ThreadExecutor.java:385)
  java.base/java.lang.VirtualThread.run(VirtualThread.java:295)
  java.base/java.lang.VirtualThread$VThreadContinuation.lambda$new$0(VirtualThread.java:172)
  java.base/java.lang.Continuation.enter0(Continuation.java:372)
  java.base/java.lang.Continuation.enter(Continuation.java:365)

从下往上看堆栈帧;首先,我们看到许多与虚拟线程设置相关的帧(“continuation”是虚拟线程内部使用的虚拟机制),它们对应于executor服务创建的新线程。其次,我们看到一些帧对应于调用 retrieveURLs'和'getURL 的测试程序。第三,我们看到对应于HTTP协议处理程序的帧以及socket输入流实现的read方法。最后,在堆栈中跟踪这些帧,我们可以看到虚拟线程已经暂停,这是我们所期望的,因为服务器没有发送完整的响应,所以没有足够的数据来读取套接字。但是,如果当数据到达套接字上时,如何启动虚拟线程?

仔细看看threads.txt中的其他系统线程,我们可以看到:

"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)
  java.base@17-internal/sun.nio.ch.Poller.lambda$startPollerThread$0(Poller.java:65)
  java.base@17-internal/sun.nio.ch.Poller$$Lambda$14/0x00000008010579c0.run(Unknown Source)
  java.base@17-internal/java.lang.Thread.run(Thread.java:1522)
  java.base@17-internal/jdk.internal.misc.InnocuousThread.run(InnocuousThread.java:161)

这个线程是jvm范围的读轮询器。它的核心是执行一个基本的事件循环,监视所有在虚拟线程中调用时没有立即准备好的同步网络操作readconnectaccept。当I/O操作准备好时,将通知轮询器,并随后启动后适当的暂停的虚拟线程。对于write操作,有一个等效的写-轮询器

上面的堆栈跟踪是在macOS上运行测试程序时捕获的,这就是为什么我们会看到与macOS上的轮询器实现相关的堆栈帧,即kqueue。在Linux上轮询器使用epoll,在Windows上wepoll(它在Winsock的辅助功能驱动程序上提供了类似epoll的API)。

轮询器维护一个文件描述符到虚拟线程的映射。当向轮询器注册文件描述符时,将向该文件描述符的映射添加一个条目,并将注册线程作为其值。当被事件唤醒时,轮询器的事件循环将使用事件的文件描述符来查找相应的虚拟线程并将其解除暂停状态。

扩展

如果你仔细观察,你会发现上面的行为与当前使用NIO通道和选择器的可扩展代码并没有太大的不同——它们可以在许多服务器端框架和库中找到。虚拟线程的不同之处在于向开发人员公开的编程模型。前者暴露了一个更复杂的模型,用户代码必须实现事件循环和维护应用程序逻辑I / O之间,而后者暴露了一种更简单和更简单的编程模型的Java平台处理任务的调度和维护跨I / O边界的上下文。

用于调度虚拟线程的默认调度器是fork-join work-stealing调度器,它非常适合这项工作。用于监视就绪I/O操作的原生事件通知机制是操作系统提供的一种同样现代和高效的机制。虚拟线程构建在Java VM中的延续支持之上。因此,同步Java网络api的规模应该与更复杂的异步和非阻塞代码构造的规模相当。

结论

同步Java网络api已经由JEP 353JEP 373重新实现,为Project Loom做准备。在虚拟线程中运行时,如果I/O操作没有立即完成,将导致虚拟线程被暂停。当I/O就绪时,虚拟线程将被启动。该实现使用了来自Java VM和Core库的几个特性,提供了一个可扩展的、高效的替代方案,与当前的异步和非阻塞代码构造相比,它更有优势。

请尝试Early Accessloom的构建版本,我们很乐意听到你的体验,可以发送到loom-dev邮件列表。