jasyncfio:Java中基于linux io_uring的高性能IO操作库


io_uring — 是 Linux 内核中相对较新的 API,在版本 5.1 中引入。io_uring 的构建理念是为文件和网络套接字提供高性能异步输入/输出 (IO)。

io_uring 基于内核和用户空间内存之间共享的两个队列,即提交队列 ( sq) 和完成队列 ( cq)。用户向提交队列写入操作请求,内核将操作结果写入完成队列。然后用户需要读取并处理该结果。
这种方式允许程序通过单个系统调用向内核发出多个IO操作的请求,相应地,内核可以通过适当的队列返回多个IO操作的结果。

可以在 API 作者 Jens Axboe - Efficient IO with io_uring撰写的优秀文章中找到有关 io_uring 的更多详细信息。

如何使用 io_uring 的 Java jasyncfio  API
首先,需要初始化EventExecutor,它封装了事件循环以及与 io_uring 相关的所有内容:

EventExecutor eventExecutor = EventExecutor.initDefault();

EventExecutor ​​​的initDefault()方法使用合理的默认值进行初始化,并在内部创建单个 io_uring 实例。

EventExecutor eventExecutor = EventExecutor.builder()
                .entries(128)
                .ioRingSetupIoPoll() // this parameter creates two io_urings, which I wrote about earlier
                .ioRingSetupSqPoll(1000)
                .build();

创建后EventExecutor,一切都已设置完毕并准备好处理文件:

CompletableFuture<AsyncFile> asyncFile = AsyncFile.open(filePath, eventExecutor, OpenOption.READ_WRITE);
AsyncFile file = asyncFile.get();
ByteBuffer buffer = ByteBuffer.allocateDirect(4096);
CompletableFuture<Integer> readCompletableFuture = file.read(buffer);

请注意,只允许使用DirectByteBuffer。这是一个特意的决定,这也是它与标准Java库不同的地方,后者接受任何类型的缓冲区。

在对标准Java库的调用中,如果没有传递DirectByteBuffer,无论如何都会分配一个DirectByteBuffer。读取将被执行到这个缓冲区,然后数据将被复制到用户提供的缓冲区。这造成了隐藏的开销成本,这对于一个高性能的IO库来说是不可接受的。


基准测试
使用fio基准测试的结果作为参考值。在fio中,有一个名为one-core-peak.sh的方便脚本,用于io_uring,它可以优化配置io_uring,以实现当前环境下的最大IOPS数。

一台装有AMD Ryzen 7 4800H CPU、32GB内存和三星SSD 970 EVO Plus 500GB的笔记本电脑。它正在运行Ubuntu 22.04,Linux内核版本为6.2.14-060214-generic。

结果(启用轮询队列):

io_uring: Running taskset -c 0,1 t/io_uring -b512 -d128 -c32 -s32 -p1 -F1 -B1 -n2  /dev/nvme0n1

IOPS=329.81K, BW=161MiB/s, IOS/call=31/31
IOPS=329.51K, BW=160MiB/s, IOS/call=31/32
IOPS=330.12K, BW=161MiB/s, IOS/call=31/31

现在让我们来看看jasyncfio基准测试的结果。

用Java写了一个程序,请注意,目前不支持文件描述符的注册和io_uring文件描述符。

让我们用最接近fio的配置来运行jasyncfio基准测试:

java -jar benchmark/build/libs/benchmark-1.0-SNAPSHOT.jar -b512 -d128 -c32 -s32 -p=true -O=true -w2 /dev/nvme0n1

IOPS=288736, BW=140MiB/s, IOS/call=32/31
IOPS=286624, BW=139MiB/s, IOS/call=32/31
IOPS=285728, BW=139MiB/s, IOS/call=32/31

差异约为-15%。

问题是,我还没有找到一个最佳的方法来实现在使用IORING_SETUP_IOPOLL标志时与io_uring一起工作。

如果我们从基准配置中去掉poll(-p)标志,那么结果将如下:

java -jar benchmark/build/libs/benchmark-1.0-SNAPSHOT.jar -b512 -d128 -c32 -s32 -p=false -O=true -w2 /dev/nvme0n1

IOPS=320672, BW=156MiB/s, IOS/call=32/31
IOPS=316672, BW=154MiB/s, IOS/call=32/32
IOPS=315232, BW=153MiB/s, IOS/call=32/32


操作SSD的IO操作每秒31.5万次!

对fio运行同样的配置:

taskset -c 0,1 t/io_uring -b512 -d128 -c32 -s32 -p0 -F0 -B0 -n2  /dev/nvme0n1

IOPS=328.00K, BW=160MiB/s, IOS/call=32/32
IOPS=328.80K, BW=160MiB/s, IOS/call=32/31
IOPS=329.47K, BW=160MiB/s, IOS/call=32/32

现在,Java和C之间的差异不到5%!

总结
io_uring是一个惊人的IO API,它允许从硬件的IO子系统中提取几乎最大的性能,而CPU和内存的使用量却最小。