io_uring | 用 Rust 实现基于 io_uring 的异步随机读文件

作者:迟先生(skyzh)/ 后期编辑:张汉东


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

TL;DR

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


#![allow(unused)]
fn main() {
ctx.read(fid, offset, &mut buf).await?;
}

io_uring 简介

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

  • RocksDB 的 MultiRead 目前就是通过 io_uring 做并发读文件。
  • Tokio 为 io_uring 包装了一层 API。在 Tokio 1.0 发布之际,开发者表示今后会通过 io_uring 提供真正的异步文件操作 (见 Announcing Tokio 1.0)。 目前 Tokio 的异步文件操作通过开另外的 I/O 线程调用同步 API 实现。
  • QEMU 5.0 已经使用 io_uring (见 ChangeLog)。

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

随机读文件的场景

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

  • 通过 mmap 直接把文件映射到内存中。读文件变成了直接读内存,可以在多个线程中并发读。
  • pread 可以从某一位置 offset 开始读取 count 个字节,同样支持多线程并发读。

不过,这两种方案都会把当前线程阻塞住。比如 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。


#![allow(unused)]
fn main() {
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) 可以将任务添加到队列中。


#![allow(unused)]
fn main() {
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)?; }
}

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


#![allow(unused)]
fn main() {
assert_eq!(ring.submit()?, 1);
}

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


#![allow(unused)]
fn main() {
loop {
    if let Some(entry) = ring.completion().pop() {
        // do something
    }
}
}

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

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

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

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


#![allow(unused)]
fn main() {
ctx.read(fid, offset, &mut buf).await?;
}

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

  • 开发者在使用 io_uring 之前,需要创建一个 UringContext
  • UringContext 被创建的同时,会在后台运行一个(或多个)用来提交任务和轮询完成任务的 UringPollFuture。 (对应上一章节中读文件的第二步、第三步操作)。
  • 开发者可以从 ctx 调用读文件的接口,用 ctx.read 创建一个 UringReadFuture。在调用 ctx.read.await 后:
    1. UringReadFuture 会创建一个固定在内存中的对象 UringTask,然后把读文件任务放进队列里,将 UringTask 的地址作为 读操作的用户数据。UringTask 里面有个 channel。
    2. UringPollFuture 在后台提交任务。
    3. UringPollFuture 在后台轮询已经完成的任务。
    4. UringPollFuture 取出其中的用户数据,还原成 UringTask 对象,通过 channel 通知 UringReadFuture I/O 操作已经完成。

整个流程如下图所示。

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:

  • 8 线程 mmap。 (mmap_8)
  • 32 线程 mmap。 (mmap_32)
  • 512 线程 mmap。 (mmap_512)
  • 8 线程 8 并发的 io_uring。(uring_8)
  • 8 线程 32 并发的 io_uring。即 8 个 worker thread, 32 个 future 同时 read。(uring_32)
  • 8 线程 512 并发的 io_uring。(uring_512)

测试了 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-2

p50 Latency (ns)

一些可能的改进

  • 看起来现在 io_uring 在我和 Tokio 的包装后性能不太行。之后可以通过对比 Rust / C 在 io_uring nop 指令上的表现来测试 Tokio 这层包装引入的开销。
  • 测试 Direct I/O 的性能。目前只测试了 Buffered I/O。
  • 和 Linux AIO 对比。(性能不会比 Linux AIO 还差吧(痛哭
  • 用 perf 看看现在的瓶颈在哪里。目前 cargo flamegraph 挂上去以后 io_uring 没法申请内存。(占个坑,说不定能出续集
  • 目前,用户必须保证 &mut buf 在整个 read 周期都有效。如果 Future 被 abort,会有内存泄漏的问题。 futures-rs 的类似问题见 https://github.com/rust-lang/futures-rs/issues/1278 。Tokio 目前的 I/O 通过两次拷贝(先到缓存,再给用户)解决了这个问题。
  • 或许可以把写文件和其他操作也顺便包装一下。

作者简介:

迟先生(skyzh),上海交通大学大三学生,SJTUG 镜像站维护者,沉迷写 Rust。