Hermes : Java中超快速通信新方法

为 Java应用程序提供超快速网络的新方法,Hermes 项目是一个基于 OpenJDK JEP 424 的与网络无关的 Java 超快速通信解决方案。

Hermes 项目将为基于 OpenJDK JEP 424 的 Java 提供与网络无关的超快速通信解决方案:

  • Java 19版本以后的外部函数和内存 API :JEP 424 作为OpenJDK 19 中的预览功能提供 ,是用于将 Java 与外部代码和数据互连的新 API。它将取代笨重且容易出错的JNI。
  • 统一 通信 X (UCX) 

Hermes 项目通过两个开源库提供两个抽象级别:Infinileap 和hadroNIO 

  • Infinileap 为 UCX 提供了一个 Java 接口,UCX 是一个支持不同网络的本机库。
  • hadroNIO,它提供了基于JUCX/Infinileap的透明Java NIO套接字接口。

hadroNIO
Java NIO 多年来一直是 Java 平台上现代网络开发的标准。凭借其用于异步通信的优雅 API,它使应用程序开发人员能够仅使用单个线程处理多个连接,同时仍然可以灵活地扩展大线程数。此外,它还支持阻塞通信,类似于传统的 Java 套接字 API。
然而,由于 NIO 实现依赖于经典套接字,因此应用程序仅限于使用以太网进行通信。

统一通信X(UCX)是一个原生框架,旨在为多种传输类型提供统一的API。UCX API 提供多种形式的通信,例如标记消息传递、主动消息传递、流式传输或 RDMA。应用程序开发人员无需针对特定的网络互连,因为 UCX 会自动扫描系统中可用的传输方式并选择最快的传输方式(例如以太网或 InfiniBand)。其称为 JUCX(基于 JNI)的 Java 绑定使其也适用于 Java 应用程序。

通过 hadroNIO,目标是通过提供新的 NIO 实现来结合这两个框架,该实现利用 UCX 发送和接收网络流量。因此,hadroNIO 可以使用给定环境中可用的最快网络互连来透明地加速现有 Java NIO 应用程序。

这是杜塞尔多夫海因里希海涅大学操作系统小组的一个研究项目。

架构
为了透明地加速现有的NIO 应用程序,hadroNIO 需要完全替换涉及的类,包括SocketChannel, ServerSocketChannel, Selector 和SelectionKey:

Java 平台通过一个名为 "SelectorProvider "的类为交换默认 NIO 实现提供了一种舒适的方式。该类提供了创建不同 NIO 组件(如 SocketChannel 或 Selector)实例的方法。可以通过系统属性 java.nio.channels.spi.SelectorProvider 设置要使用的提供程序类(请参阅运行说明)。

写缓冲区管理
UCX 和 NIO 对缓冲区的管理方式不同:在默认的 NIO 实现中,调用 write() 会将源缓冲区的内容复制到底层套接字的缓冲区并返回。即使发送数据的实际过程是异步执行的,源缓冲区也可能被应用程序重复使用和更改。

UCX 的行为与之不同,它不允许在请求完成前修改源缓冲区。

为了解决这个问题,我们在 hadroNIO 的 SocketChannel 实现中引入了一个中间缓冲区。在它的 write() 方法中,源缓冲区的内容被复制到中间缓冲区,所有 UCX 发送请求将只对复制的数据进行操作。由于我们希望能够处理多个活动的发送请求,因此需要一个简单但线程安全的内存管理来管理中间缓冲区内的空间。为此,我们采用基于 Agrona OneToOneRingBuffer 的环形缓冲区来实现该缓冲区。

整个写入机制可分为以下几个步骤:

  • 在中间缓冲区内分配所需的空间。
  • 将源缓冲区的内容复制到新分配的空间。
  • 通过 UCX 发出发送请求。
  • 返回应用程序。源缓冲区现在可以重复使用,向远程接收器发送数据的实际过程是异步执行的。
  • 一旦 UCX 完成请求,就会调用回调。
  • 中间缓冲区内的空间不再需要,并由回调例程释放。

读取时的缓冲区管理
在传统的 NIO 实现中,所有接收到的数据都会先存储在底层套接字的内部缓冲区中,然后 read() 方法会将这些数据复制到应用程序的目标缓冲区中。

hadroNIO 的 read() 实现也采用了类似的技术:与写入()方法类似,中间缓冲区用于存储异步接收的数据,而读取()方法只需复制这些数据即可。为了向 UCX 发出接收请求,SocketChannel 类引入了 fillReceiveBuffer() 方法。该方法在中间缓冲区内分配多个长度相同的片段,并为每个片段创建一个接收请求。这意味着由 write() 发出的发送请求可能不会大于 fillReceiveBuffer() 创建的片段。为此,write() 会将较大的缓冲区划分为多个较小的发送请求,以适应远程接收缓冲区内的片段。为确保 hadroNIO 的有效接收请求永远不会用完,连接建立后,每次选择操作都会调用 fillReceiveBuffer()。

完整读取机制可分为以下几个步骤:

  • 通过 fillReceiveBuffer() 分配中间接收缓冲区内的片段。
  • 为每个新分配的片段发出接收请求。
  • UCX 完成请求后,会调用回调。
  • 回调例程会通知套接字通道,一个新的缓冲区片已被数据填满。通道会保留一个内部计数器,记录有多少个已分配的片段包含有效数据。
  • 应用程序调用 read() 时,缓冲区片的内容会被复制到目标缓冲区。如果一个片段已被完全读取,分配的空间将被释放,并在下一次调用 fillReceiveBuffer() 时重新使用。

阻塞与非阻塞套接字通道
要通过 UCX 实际发送或接收数据,需要推进相应的 Worker 实例。

在非阻塞模式下,这需要在相关选择器的 select() 方法中完成。但在阻塞模式下,不涉及选择器,这意味着必须在其他地方推进 Worker。

  • 至于 write(),它是在最后一个缓冲区片的发送请求发出后立即执行的,这意味着与非阻塞模式相反,一旦 write()返回,UCX 就已经处理了要发送的数据。自然,这种方法更倾向于延迟而非吞吐量。
  • 对于 read(),每次从中间接收缓冲区读取的数据片所剩无几时,worker进程都会继续,并调用 fillReceiveBuffer()。

测试

  • 使用阻塞套接字通道时,hadroNIO 的性能优于 IPoIB
  • 当使用非阻塞套接字通道时,由于选择器逻辑造成的开销,小消息的操作吞吐量会降低。然而,hadroNIO 仍然能够比 IPoIB 每秒处理更多的操作(大约 850 Kop/s 与使用 4 字节缓冲区的大约 620 Kop/s)。凭借更大的缓冲区,hadroNIO 的数据吞吐量快速增长,在 16 KiB 时达到 6 GB/s。与使用阻塞套接字通道相比,从 8 KiB 到 16 KiB 性能没有下降,吞吐量稳定在 6 GB/s,几乎与 JUCX 达到的最大吞吐量 6.2 GB/s 相当。

延迟:

  • 与直接使用 JUCX 编程相比,hadroNIO 仅引入很小的延迟开销。
  • hadroNIO 和 IPoIB 使用非阻塞套接字通道都会产生更高的延迟。尽管如此,hadroNIO 仍设法将往返时间低至 5 μs,并在高达 2 KiB 缓冲区大小的情况下将延迟保持在个位数微秒内。IPoIB 的延迟结果为 16 至 19 μs,该范围内的延迟结果是该范围的 3 倍多。