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设计与实现》(一)