乐趣区

关于mysql:iouring技术在分布式云原生数据库中的应用

Part 1 – 背景

1.1 异步 I /O

异步 I / O 是计算机操作系统对输入输出的一种解决形式:发动 I / O 申请的线程不期待 I / O 操作实现,就继续执行随后的代码,I/ O 后果用其余形式告诉发动 I / O 申请的程序。

与异步 I / O 绝对的是更为常见的“同步(阻塞)I/O”:发动 I / O 申请的线程不从正在调用的 I / O 操作函数返回(即被阻塞),直至 I / O 操作实现。

同步 IO 机制存在着肯定的弊病,例如:IO 的实现都是在以后过程上下文的零碎调用中实现的,会阻塞以后过程,升高零碎的实时性,同时导致性能较低。

对于 I / O 密集型利用,同步 I / O 的弊病就会被放大,而异步 I / O 的劣势便体现进去:(1)由内核来操作 I /O,利用不再阻塞在 I /O,能够执行其余逻辑,此时利用运行和 I / O 执行变成了并行的关系;(2)能够批量地进行 I / O 操作,让设施的能力失去最大施展。

Linux 内核提供的 I / O 机制大都是同步实现的,如惯例的 read/write/send/recv 等零碎调用。在引入 io_uring 之前,Linux 零碎下的异步 I / O 机制的实现次要为两种:

(1)POSIX AIO。这种计划是用户态实现的异步 I / O 机制,其核心思想为:创立一个专门用来解决 IO 的线程,用户程序将 I / O 操作交给该线程来进行。这种形式实现的异步 I / O 效率和扩展性都比拟差。

(2)LINUX AIO。Linux 内核里也实现了一套异步 I / O 机制,被称为 AIO。该机制的应用限度比拟大,比方只反对 direct IO 而无奈应用 cache,且扩展性比拟差。

1.2 io_uring 劣势

随着 Linux 5.1 的公布,Linux 终于有了本人好用的异步 I / O 实现,并且反对大多数文件类型(磁盘文件、socket,管道等),彻底解决了长期以来 Linux AIO 的各种有余,这就是 io_uring。

相比于 Linux 传统的异步 I / O 机制,io_uring 的劣势次要体现在以下几个方面:

(1)高效。一方面,io_uring 采纳了共享内存的形式来传递参数,缩小了数据拷贝;另一方面,采纳 ring buffer 的形式来实现批量的 I / O 申请,缩小了零碎调用的次数。

(2)可扩展性强。io_uring 具备超强的可扩展性,具体表现在:

•反对的 I / O 设施类型多样化,不仅反对块设施的 IO,还反对任何基于文件的 IO,例如套接口、字符设施等;

•反对的 I / O 操作多样化,不仅反对惯例的 read/write,还反对 send/recv/sendmsg/recvmsg/close/sync 等大量的操作,而且可能很灵便地进行裁减。

(3)易用。io_uring 提供了配套的 liburing 库,其对 io_uring 的零碎调用进行了大量的封装,使得接口变得简略易用。

(4)可伸缩。io_uring 提供了 poll 模式,对于 I / O 性能要求较高的场景,容许用户就义肯定的 CPU 来取得更高的 IO 性能:低提早、高 IOPS。

经测试,相比于 libaio,在 poll 模式下 io_uring 性能晋升将近 150%,堪比 SPDK。在高 QD 的状况下,更是有赶超 SPDK 的趋势。

io_uring 就是:一套全新的 syscall,一套全新的 async API,更高的性能,更好的兼容性,来迎接高 IOPS,高吞吐量的将来。

Part 2 – io_uring 根本实现

2.1 基本原理

io_uring 实现异步 I / O 的形式其实是一个生产者 - 消费者模型:

(1)用户过程生产 I / O 申请,放入提交队列(Submission Queue,简称 SQ)。

(2)内核生产 SQ 中的 I / O 申请,实现后将后果放入实现队列(Completion Queue,简称 CQ)。

(3)用户过程从 CQ 中收割 I / O 后果。

SQ 和 CQ 是内核初始化 io_uring 实例的时候创立的。为了缩小零碎调用和缩小用户过程与内核之间的数据拷贝,io_uring 应用 mmap 的形式让用户过程和内核共享 SQ 和 CQ 的内存空间。

另外,因为先提交的 I / O 申请不肯定先实现,SQ 保留的其实是一个数组索引(数据类型 uint32),真正的 SQE(Submission Queue Entry)保留在一个独立的数组(SQ Array)。所以要提交一个 I / O 申请,得先在 SQ Array 中找到一个闲暇的 SQE,设置好之后,将其数组索引放到 SQ 中。

用户过程、内核、SQ、CQ 和 SQ Array 之间的根本关系如下:

图 1 io_uring 机制的基本原理

2.2 io_uring 的用户态

io_uring 的实现仅仅应用了三个 syscall:io_uring_setup, io_uring_enter 和 io_uring_register。它们别离用于设置 io_uring 上下文,提交并获取实现工作,以及注册内核用户共享的缓冲区。应用前两个 syscall 曾经足够应用 io_uring 接口了。

2.2.1 初始化

int io_uring_setup(int entries, struct io_uring_params *params);

内核提供了 io_uring_setup 零碎调用来初始化一个 io_uring 实例,创立 SQ、CQ 和 SQ Array,entries 参数示意的是 SQ 和 SQArray 的大小,CQ 的大小默认是 2 * entries。params 参数既是输出参数,也是输入参数。

该函数返回一个 file descriptor,并将 io_uring 反对的性能、以及各个数据结构在 fd 中的偏移量存入 params。用户依据偏移量将 fd 通过 mmap 内存映射失去一块内核用户共享的内存区域。这块内存区域中,有 io_uring 的上下文信息:SQ 信息、CQ 信息和 SQ Array 信息。

图 2 fd 映射到用户态

io_uring_setup 设计的奇妙之处在于,内核通过一块和用户共享的内存区域进行音讯的传递。在创立上下文后,工作提交、工作收割等操作都通过这块共享的内存区域进行,能够齐全绕过 Linux 的 syscall 机制去实现须要内核染指的操作,大大减少了 syscall 切换上下文、刷 TLB 的开销。

2.2.2 I/ O 提交与收割

初始化实现之后,咱们须要向 io_uring 提交 I / O 申请。io_uring 能够解决多种 I / O 相干的申请。比方:

文件相干:read, write, open, fsync,fallocate, fadvise, close

网络相干:connect, accept, send, recv,epoll_ctl

默认状况下,应用 io_uring 提交 I/O 申请须要:

(1)从 SQ Arrary 中找到一个闲暇的 SQE;

(2)依据具体的 I / O 申请设置该 SQE;

(3)将 SQE 的数组索引放到 SQ 中;

(4)调用零碎调用 io_uring_enter 提交 SQ 中的 I / O 申请。

图 3 io_uring 申请的提交与收割

在咱们提交 I / O 申请的同时,应用同一个 io_uring_enter 零碎调用就能够回收实现状态,这样的益处就是一次零碎调用接口就实现了本来须要两次零碎调用的工作,大大的缩小了零碎调用的次数,也就是缩小了内核核外的切换,这是一个很显著的优化,内核与核外的切换极其耗时。

当 I / O 实现时,内核负责将实现 I / O 在 SQ Array 中的 index 放到 CQ 中。因为 I / O 在提交的时候能够顺便返回实现的 I /O,所以收割 I / O 不须要额定零碎调用。

如果应用了 IORING_SETUP_SQPOLL 参数,I/ O 收割也不须要零碎调用的参加。因为内核和用户态共享内存,所以收割的时候,用户态遍历曾经实现的 I / O 队列,而后找到相应的 CQE 并进行解决,最初挪动 head 指针到 tail,IO 收割至此而终。

所以,在最现实的状况下,IO 提交和收割都不须要应用零碎调用。

2.3 io_uring 的内核态

io_uring 在创立时有两个选项,对应着 io_uring 解决工作的不同形式:

(1)开启 IORING_SETUP_IOPOLL,io_uring 会应用轮询的形式执行所有的操作;

(2)开启 IORING_SETUP_SQPOLL,io_uring 会创立一个内核线程专门用来收割用户提交的工作。

这些选项的设定会影响用户与 io_uring 交互的形式:

(1)都不开启,通过 io_uring_enter 提交工作,收割工作无需 syscall;

(2)开启 IORING_SETUP_IOPOLL,通过 io_uring_enter 提交和收割工作;

(3)开启 IORING_SETUP_SQPOLL,无需任何 syscall 即可提交、收割工作。内核线程在一段时间无操作后会休眠,能够通过 io_uring_enter 唤醒。

2.3.1 基于轮询的工作执行

创立 io_uring 时指定 IORING_SETUP_SQPOLL 选项即可开启 I / O 轮询模式。在轮询模式下,io_uring_enter 只负责把操作提交到内核的文件读写队列中。之后,用户须要调用 io_uring_enter 获取实现事件,内核会应用轮询形式一直查看 I / O 设施是否曾经实现申请,而非期待设施告诉。通过这种形式,可能尽可能快地获取设施 I / O 实现状况,开始后续的 I / O 操作。

2.3.2 基于内核线程的工作执行

同时,在内核中还反对了一个内核 I / O 模式,通过 IORING_SETUP_SQPOLL 标记设置。在这个模式下,io_uring 会启动一个内核线程,循环拜访和解决申请队列。内核线程与用户态线程不同,不能在没有工作时无条件的有限循环期待,因而当内核线程继续运行一段时间没有发现 I / O 申请时,就会进入睡眠。如果内核线程进入睡眠,会通过 I / O 申请队列的 flag 字段 IORING_SQ_NEED_WAKEUP 告诉用户态程序,用户态程序须要在有新的 I / O 申请时通过带 IORING_ENTER_SQ_WAKEUP 标识的 io_uring_enter 调用来唤醒内核线程持续工作。

如果 IORING_SETUP_IOPOLL 和 IORING_SETUP_SQPOLL 同时设置,内核线程会同时对 io_uring 的队列和设施驱动队列做轮询。在这种状况下,用户态程序又不须要调用 io_uring_enter 来触发内核的设施轮询了,只须要在用户态轮询实现事件队列即可,这样就能够做到对申请队列、实现事件队列、设施驱动队列全副应用轮询模式,达到最优的 I / O 性能。当然,这种模式会产生更多的 CPU 开销。

通过对上述对 io_uring 实现原理的介绍,能够总结出,io_uring 之所以领有如此出众的性能,次要来源于以下几个方面:

(1)用户态和内核态共享提交队列和实现队列;

(2)I/ O 提交和收割能够 offload 给 Kernel,且提交和实现不须要通过零碎调用;

(3)反对 Block 层的 Polling 模式

(4)通过提前注册用户态内存地址,缩小地址映射的开销

(5)相比 libaio,完满反对 buffered I/O

Part 3 – 云溪数据库中的利用

云溪数据库应用 Pebble 作为默认存储引擎,Pebble 底层应用同步 I / O 机制。为了晋升 Pebble 引擎的性能,能够思考从扭转 I / O 模型的角度动手,应用反对异步 I / O 的 io_uring 机制。

Pebble 波及磁盘 I / O 的次要文件类型和操作包含:WAL 文件的读写、SST 文件在 flush 和 compaction 时的读写以及 MANIFEST 文件的读写。在 Pebble 原有的虚构文件系统中,文件系统类通过调用 os 接口打开文件,文件操作类通过调用传统 io 接口实现文件的读、写、同步、敞开等操作。要想实现将 io_uring 机制集成到 Pebble 中,须要新增一个虚构文件系统类,对其中的文件系统类和文件操作类进行重写,同时须要对 io_uring 进行封装,负责解决各个文件生成的 io_uring 读写工作。因而,实现计划次要分为三个局部:1、应用 io_uring 机制的文件系统实现类;2、应用 io_uring 机制的文件操作实现类;3、解决 io_uring 工作的实现类。

图 4 Pebble 集成 io_uring 的实现计划

3.1 文件系统类

基于 Pebble 的 vfs.defaultFS 实现,须要改写 Create、Open 以及 ReuseForWrite 三个函数,即对应创立文件、关上文件以及再利用重写文件三种操作。革新点能够概括为两处:1、调用 os.OpenFile 关上文件时应用 direct_io 模式;2、函数返回的 File 对象具体为应用 io_uring 机制实现的文件操作类。

3.2 文件操作类

基于 Pebble 的 vfs.File 类进行实现,因为文件是以 direct_io 模式关上的,只反对大小为 512 字节整数倍的读写操作。因而,须要对文件操作类的构造进行革新,以及对文件操作类中波及读写操作的函数进行革新,包含:Read、ReadAt、Write 以及 Sync。

3.2.1 文件操作类构造革新

次要革新点有:1、定义两个数组对象,一个用于保留文件的写入内容,另一个用于读写操作时不满 512 字节的缓存解决,大小为 512 字节;2、定义三个整型对象,别离用于记录读、写以及落盘的偏移量;3、定义 waitgroup 对象,用于确保每次文件 Sync 时,所有的 io_uring 写工作曾经解决实现。

3.2.2 文件操作类函数革新

(1)Read。Read 函数逻辑为:记录文件读取的偏移量,每次调用 Read 函数时,以保留的偏移量为起始地位对文件进行读取,直至文件完结或者传入的保留读取内容的数组曾经读满。因为 direct_io 只反对 512 字节大小的读取,因而须要对读文件的起始地位和完结地位做一些非凡解决。首先文件读取的起始偏移量必须为 512 字节的整数倍,当记录的文件读取偏移量不满足此条件时,须要将起始偏移量前移至最大的满足 512 字节整数倍的地位。其次,因为读取文件长度必须为 512 字节的整数倍,须要依据传入数组的长度和文件大小,将读文件的完结地位后移至最小的满足 512 字节整数倍的地位。确定文件读取地位后,提交 io_uring 读工作,并创立对应的一个 waitgroup,期待读操作实现。当读操作实现后,须要对读取内容进行截取解决,能够了解为“掐头去尾”,最初将真正须要的读取内容返回。

(2)ReadAt。改造思想与 Read 函数同理。

(3)Write。因为文件是以 direct_io 模式关上的,即文件读写不会通过操作系统缓存,写操作先将内容写到内存,即文件操作类中定义的数组对象中,当写入内容达到肯定数量后,会提交一次 io_uring 写工作,将文件内容写入磁盘,这样做能够达到缩小 I / O 次数的目标。提交 io_uring 写工作时,会传入文件操作类中的 waitgroup 对象,但与读操作不同的是,写操作不须要期待工作解决实现,提交工作后能够间接返回。

(4)Sync。依据记录的落盘偏移量,将以后内存中未落盘的全副文件内容提交 io_uring 写工作,并传入文件操作类中的 waitgroup 对象,期待 I / O 工作实现。针对不满 512 字节的数据,须要进行非凡解决:将这部分文件内容写入文件操作类中大小为 512 字节的数组对象,有余 512 字节的局部补 0,并创立对应的一个 waitgroup,将这部分文件内容再提交一次 io_uring 写工作,并期待工作实现。有余 512 字节的提交,不会更新以后的落盘偏移量,这部分文件内容会追随后续写入的内容再次落盘。因而落盘的起始地位永远为 512 字节的整数倍,应用时不须要进行非凡解决。

3.2.3 解决 io_uring 工作的实现类

根本思维是实现一个 io_uring 封装类,应用单例模式创立惟一对象,所有文件的 io_uring 读写申请都通过该对象提交到内核中。实现形式是定义一个队列,该队列负责接管从多个线程发送过去的 io_uring 工作。该对象在初始化时,须要开启一个协程,在 Pebble 运行的生命周期中,一直地循环从队列头部获取 io_uring 工作提交到内核,同时一直地循环从 io_uring 的 CQ 队列中获取曾经写入磁盘完结的 io_uring 工作,调用 waitgroup.Done 来唤醒期待的线程持续解决,告诉发动申请的函数 I / O 操作曾经实现。

3.2.4 WAL 场景优化

Pebble 中对文件系统 Create 的文件进一步封装成 LogWriter 类,导致在写 WAL 时两次将数据写入内存,影响性能。因而,须要针对 WAL 场景定制化一个文件操作类,用于晋升 WAL 文件的写入性能,仅须要重写 Write 逻辑。

参考:

1. Getting Hands on with io_uring using Go

2. io_uring 技术的剖析与思考

3. 高性能异步 IO 机制:IO_URING

4. 一篇文章带你读懂 io_uring 的接口与实现

5. Linux 文件 I/O 进化史(四):io_uring—— 全新的异步 I/O

6.《操作系统与存储:解析 Linux 内核全新异步 IO 引擎——io_uring 设计与实现》(一)

退出移动版