Alex Chi Z.

在 Rust 中实现基于 io_uring 的异步随机读文件


一句话总结:在 skyzh/uring-positioned-io 中,我包装了 Tokio 提供的底层 io_uring 接口,在 Rust 中实现了基于 io_uring 的异步随机读文件。你可以这么用它:

ctx.read(fid, offset, &mut buf).await?;

本文介绍了 io_uring 的基本使用方法,然后介绍了本人写的异步读文件库的实现方法,最后做了一个 benchmark,和 mmap 对比性能。

点击这里可以访问 GitHub 项目

io_uring 简介

io_uring 是一个由 Linux 内核的提供的异步 I/O 接口。它于 2019 年 5 月在 Linux 5.1 中面世,现在已经在各种项目中被使用。 比如:

目前关于 io_uring 的测试,大多是和 Linux AIO 对比 Direct I/O 的性能 (1) (2) (3)io_uring 通常能达到两倍于 AIO 的性能。

随机读文件的场景

在数据库系统中,我们常常需要多线程读取文件任意位置的内容 (<fid>, <offset>, <size>)。 经常使用的 read / write API 无法完成这种功能(因为要先 seek,需要独占文件句柄)。 下面的方法可以实现文件随机读。

不过,这两种方案都会把当前线程阻塞住。比如 mmap 后读某块内存产生 page fault,当前线程就会阻塞;pread 本身就是一个阻塞的 API。 异步 API (比如 Linux AIO / io_uring) 可以减少上下文切换,从而在某些场景下提升吞吐量。

io_uring 的基本用法

io_uring 相关的 syscall 可以在 这里 找到。liburing 提供了更易用的 API。 Tokio 的 io_uring crate 在此基础之上,提供了 Rust 语言的 io_uring API。下面以它为例, 介绍 io_uring 的使用方法。

要使用 io_uring,需要先创建一个 ring。在这里我们使用了 tokio-rs/io-uring 提供的 concurrent API, 支持多线程使用同一个 ring。

use io_uring::IoUring;
let ring = IoUring::new(256)?;
let ring = ring.concurrent();

每一个 ring 都对应一个提交队列和一个完成队列,这里设置队列最多容纳 256 个元素。

通过 io_uring 进行 I/O 操作的过程分为三步:往提交队列添加任务,向内核提交任务 [注1], 从完成队列中取回任务。这里以读文件为例介绍整个过程。

通过 opcode::Read 可以构造一个读文件任务,通过 ring.submission().push(entry) 可以将任务添加到队列中。

use io_uring::{opcode, types::Fixed};
let read_op = opcode::Read::new(Fixed(fid), ptr, len).offset(offset);
let entry = read_op
            .build()
            .user_data(user_data);
unsafe { ring.submission().push(entry)?; }

任务添加完成后,将它提交到内核。

assert_eq!(ring.submit()?, 1);

最后轮询已经完成的任务。

loop {
    if let Some(entry) = ring.completion().pop() {
        // do something
    }
}

这样一来,我们就实现了基于 io_uring 的随机读文件。

注 1: io_uring 目前有三种执行模式:默认模式、poll 模式和内核 poll 模式。如果使用内核 poll 模式,则不一定需要调用提交任务的函数。

利用 io_uring 实现异步读文件接口

我们的目标是实现类似这样的接口,把 io_uring 包装起来,仅暴露给开发者一个简单的 read 函数。

ctx.read(fid, offset, &mut buf).await?;

参考了 tokio-linux-aio 对 Linux AIO 的异步包装后,我采用下面方法来实现基于 io_uring 的异步读。

整个流程如下图所示。

uring

这样,我们就可以方便地调用 io_uring 实现文件的异步读取。这么做还顺便带来了一个好处:任务提交可以自动 batching。 通常来说,一次 I/O 操作会产生一次 syscall。但由于我们使用一个单独的 Future 来提交、轮询任务,在提交的时候, 队列里可能存在多个未提交的任务,可以一次全部提交。这样可以减小 syscall 切上下文的开销 (当然也增大了 latency)。 从 benchmark 的结果观察来看,每次提交都可以打包 20 个左右的读取任务。

Benchmark

将包装后的 io_uringmmap 的性能作对比。测试的负载是 128 个 1G 文件,随机读对齐的 4K block。 我的电脑内存是 32G,有一块 1T 的 NVMe SSD。测试了下面 6 个 case:

测试了 Throughput (op/s) 和 Latency (ns)。

casethroughputp50p90p999p9999max
uring_8104085.7771077705383166109183246416310588314973666
uring_32227097.613569183571428692127301111491332188914336132
uring_512212076.516050544719734213521119194783482555170035433481
mmap_8109697.8702574455878971107021204211178782318522047
mmap_32312829.53428971884100336178914419955440821455129932
mmap_512235368.9890904751255642932652661594674450029659156095218

发现 mmap 吊打 io_uring。嗯,果然这个包装做的不太行,但是勉强能用。下面是一分钟 latency 的 heatmap。每一组数据的展示顺序是先 mmap 后 io_uring

mmap_8 / uring_8 waterfall_mmap_8 waterfall_uring_8

mmap_32 / uring_32 waterfall_mmap_32 waterfall_uring_32

mmap_512 / uring_512 waterfall_mmap_512 waterfall_uring_512

Throughput

p50 Latency (ns)

一些可能的改进