如何提升存储系统的性能是一个对存储工程师们来说是永恒的大命题,解决这个问题并没有一击即中的银弹,IO性能的优化都在细节里。今天我们来讲一讲性能和IO模型之间的关系。
我们先从本地磁盘的IO模型说起。一方面,对本地磁盘来说,传统机械磁盘HDD介质的IO性能比CPU指令和应用程序差了好几个数量级;另一方面,新型的SATA SSD和NVMe SSD硬盘的性能大幅提升,在硬盘内部,磁盘控制器芯片具有多个队列来处理并发的IO请求,磁盘本身具备了更高程度并发的能力。那么如何解决磁盘交互慢,以及利用新型磁盘内部特性,提升数据访问性能,降低系统开销呢?由此系统工程师们引入了多种IO模型来应对这些问题。
01 IO模型
简单来说,我们可以在下面这张二维的表中,分别从同步和异步、阻塞和非阻塞两个维度,归纳一下现在Linux操作系统中不同的IO模型。
同步阻塞 IO
这是应用程序编写时最常用的IO模型。在该模型中,应用程序执行系统调用时,会导致应用程序阻塞。例如,应用发出一个读的系统调用,程序后续的逻辑会被阻塞,直到系统调用完成(数据传输完成或失败)为止。当然,这个应用程序的阻塞,并不代表其它的应用不能继续执行,在这个应用被阻塞期间,会让出CPU,CPU可以执行其它的应用程序,只是这个程序本身被访问磁盘IO操作阻塞住了。从处理器角度来看,还是挺高效的,而且即使传统HDD响应较慢,这种读写模式所涉及的用户态、内核态上下文切换也不多,能满足大部分应用的性能需求。
同步非阻塞 IO
同步非阻塞模型和第一中模型的最大区别,是应用程序以非阻塞方式发送IO系统调用之后,系统会直接返回一个返回码(EAGAIN或者EWOULDBLOCK),这个返回码是提示应用程序等待或稍后再次主动询问IO是否完成。在IO完成后的那次系统调用,系统会返回数据,这意味着IO可能已经完成了,但仍需应用再次主动请求,才能获得数据,所以会带了一些额外的延时,存储整体的延时性能差,且发生了多次内核和用户态之间的上下文切换,对延时要求高的应用一般不会采用该模型。
异步阻塞IO
第三个IO模型,也称之为系统事件驱动模型或IO multiplexing,也是非常常用的IO模型。其机制可以简单理解为应用程序在发送系统调用时,利用操作系统的epoll机制,主动声明去监听某个IO描述符fd状态的变化(或事件的类型),epoll机制会保证这个fd在发生指定变化后通知应用,数据已经准备好,再由应用程序发起IO操作。在实际从磁盘进行IO过程中,由epoll机制本身去监听事件,应用程序并不关注epoll内部的执行,应用程序可以执行其它操作。
异步非阻塞IO
话题终于来到今天的重点,异步非阻塞IO,也称为AIO。这种模型的特点,是应用程序发出IO请求之后,系统会直接返回,告知这个请求已经成功发起并被系统接收了。系统后台在执行具体IO操作过程中,应用程序可以执行其它业务逻辑。当IO的响应到达时,会产生一个信号或由系统直接执行一个回调函数来完成这次的IO操作。通过描述和下图可以看到,这种模型带来几个好处,一是应用并不会被某次IO请求阻塞,后续应用逻辑可以继续进行,且不需要轮询或再次发起相关系统调用;二是这种模式的上下文切换很少,它可以在一个上下文完成多个IO的提交,因此系统开销也很小。
AIO是Linux2.6内核提出的一个标准特性,提出来的目的,就是支持异步非阻塞模型。目前,AIO有两种实现方式,分别是使用libaio和io_uring。2.6以上版本的内核已经实现了内核级别的AIO支持,配合用户态libaio库,即可支持异步非阻塞模式访问,到现在已经十分成熟和稳定。在5.x内核中引入的io_uring,则将作为统一框架,用于支持磁盘和网络等数据访问的异步非阻塞操作,虽然io_uring应用场景更广,但成熟稳定性还欠缺一些,目前还在不断迭代中。因此业界通常说AIO的时候,默认指的就是libaio这种实现。
libaio的出现,确实对SSD等新型介质是一个很好的支持和解放。如果不借助libaio,要充分发挥硬件性能的话,需要在应用程序级别引入多线程或多机多任务。这种方式存在两个不足,一是多线程之间需要上下文切换,而且也不能为了并发而无限量地引入大量的线程,这样对系统和CPU开销都很大;二是有的应用程序本身并没有实现多线程,也没有做多机并发,因此也不可能通过多线程方式来提升对底层的利用。而通过libaio,就可以在一个线程的情况下,充分利用SSD等新型硬件内部多队列来实现并发(即SSD的控制器维护了多个任务队列,应用程序通过使用libaio,就可以在单线程下,放心地往硬件下发大量IO请求,由硬件本身来处理多并发的问题),从而提升单线程应用程序的性能,也能够减少系统由于多线程切换带来的开销。AIO是当前高性能系统(不管是存储或是其他系统)提升处理能力的一个重要方式。
02 AIO(libaio)的限制
文件在打开时有两种方式,dio和buffer io。dio不写pagecache,直接和盘交互,buffer io会有内存pagecache介入,某些场景下会对性能有提升,但有些特定IO场景中性能反而可能会下降。例如顺序大IO,性能可能反而不如dio,这是因为buffer io要先写内存,再刷盘,而HDD或其它磁盘直接进行顺序IO性能可能更高;另外某些对数据可靠性要求比较高的场景中,写pagecache可能会有数据丢失的风险,例如MySQL等数据库,这些应用在写数据时通常都会使用dio,读的时候会引入应用程序自身的一些缓存机制来提升性能。
之所以介绍了一下dio和buffer io的背景,是因为libaio的一个限制是只支持dio。这是因为buffer io会遇到bounce buffer分配阻塞的问题,此外,在遇到非对齐的IO时,还会触发写惩罚,这些对效率影响都较大,与libaio希望提升性能背道而驰了,因此libaio在实现的时候默认就是dio了。
而新的io_uring则支持buffer io(关于io_uring,我们就在以后再介绍了)。
03 分布式文件系统对AIO的支持及意义
对网络存储或者外部存储来说,客户端主要功能就是IO转发,所以客户端不涉及直接访问磁盘(IO访问模型,尤其是AIO的初衷,就是解决本地访问的问题),所以通常来说(尤其是对网络文件系统),类似GlusterFS等开源的分布式文件存储一般不会支持AIO。然而,但对于一些应用,例如MySQL,它不知道自身的数据来源是本地文件系统还是网络文件系统,所以应用程序默认使用的是libaio,如果客户端不支持AIO,只是进行AIO转发的话,性能就会受到制约。在这种场景下,客户端就要模拟后端AIO的实现,进而充分发挥客户端的性能了。
04 YRCloudFile客户端对AIO的支持
YRCloudFile新版本的客户端对AIO的读写模式进行了支持。关于YRCloudFile客户端AIO的实现方面,需要理解接口io_setup、io_cancel、 io_destroy、io_getevents、 io_submit,内核中对应的接口为aio_read/write和aio_complete。在客户端中,首先要判定该请求是否是AIO请求,然后在执行aio_read/write的时候,决定是否异步,aio_read/write是实现的重点。
对于AIO读而言:首先要检查data buff和offset是否对齐,对于非PAGE_SIZE对应的请求,需要计算出其对应的物理pages,然后依次 pin user pages,延迟被换出,再封装请求并异步下发。映射page到内核线性地址空间后,从存储后端读取到数据进行填充,数据填充完后,回调aio_complete,并释放pages的引用计数。
期间要考虑pagecache的影响,需要将重叠区间的pagecache进行回刷和等待,可以参考filemap_write_and_wait_range的处理。
此外,还要考虑如下三类对齐的场景:
场景1:date_len <= PAGE_SIZE,写入数据在同一个page的场景。
场景2:date_len <= PAGE_SIZE,数据跨越两个page的场景。
场景3:date_len > PAGE_SIZE,数据在首个page内有偏移。
对于写而言:可以参考读的逻辑,大体上也是封装请求异步下发。并发处理后,回调aio_complete,在这个过程中,同样需要考虑pagecache的影响。
性能数据
在实现libaio的支持后,客户端在使用fio+libaio场景的测试中,性能随着iodepth基本呈现线性增长状态,直到达到客户端的性能上限,单客户端性能如下:
05 总结
在分布式文件系统中,客户不仅关注整个集群的性能,同时也会关注单个客户端的性能以及单线程下应用访问的性能。对于很多业务而言,并发度不高,单线程的延迟直接影响了系统的性能;而部分业务逻辑(如Nginx,MySQL,seastar)都使用到了AIO模型,如果客户端不支持AIO,那么后端数据访问的性能将会受到制约。
YRCloudFile在新版本中实现客户端的AIO支持后,进一步弥补了这一短板,将能够更好地适配这些应用场景。