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 - KaiwuDB中的应用
KaiwuDB使用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设计与实现》(一)
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。