什么是异步IO
《UNIX网络编程卷1》中的IO多路复章节总结了几种典型IO模型,包括:
阻塞IO
非阻塞IO
IO复用
信号驱动式IO
异步IO
这些IO模型在本质上都是围绕着同步、异步、阻塞、非阻塞这几个特点在做一些不同的选择。IO的过程是应用程序从某个设备读取数据,或者往设备写入数据。操作系统把这些设备抽象为描述符fd,应用程序则在这些fd上面进行读写操作。由于fd的底层是设备,这里就会有个问题:设备还没有准备好数据的读写,比如网卡还没有收到数据,此时如果应用程序去读相应的fd,肯定是没有数据的。那么当遇到这种情况时,应用程序应该如何反应呢,有几个选择:
阻塞:应用程序一直等待数据ready,然后返回
非阻塞:应用程序立即返回,去跑跑其他逻辑,然后定期来看下数据是否ready
此外,即使设备中已经有数据,操作系统还需将数据从内核拷贝到用户的缓存,这也需要一些时间,具体长短和用户设定的读取数据量大小有关。换言之,一次IO操作可能是比较耗时的,那么是否有必要一直等待IO完成,或者,是否有必要定期去检查数据是否ready呢。显然不是必须的,因此这里又有了同步、异步的概念:
同步:同步和阻塞的意思是一样的, 每次发起IO请求后,等待完成才返回
异步:发起IO请求后立即返回,等到内核将IO完成后,才以某种形式通知应用
异步IO的核心在于:应用程序不需要花费时间在IO上,只需要提交一个IO操作,当内核执行这个IO操作时,应用可以去运行其他逻辑,也不需要定期去查看IO是否完成,当内核完成这个IO操作后会以某种方式通知应用。
内核通知应用的方式其实并不多,上面说的信号驱动IO,就是内核将数据准备好之后,用信号的方式通知应用。但是信号这种方式会打乱应用程序的执行流,让逻辑变得混乱,在实际中使用的很少。另外一种通知的方式是让应用主动来询问,例如现有的io_getevents系统调用,它可以让应用知道到现在是否已经完成了IO操作。
当使用特定的参数时,io_getevents会阻塞直到指定的IO操作全部完成。这看起来似乎又变成了阻塞IO的样子,但实际上有些区别,一个重要的不同在于:应用可以同时提交多个IO请求,然后在一个io_getevents中等待他们全部完成。这个和IO多路复用的机制很相似,从应用的角度看,就好像执行一个批量的操作,这显然是能够提升效率的。
总结一下,异步IO的基本逻辑是:应用提交一些IO操作到内核,然后不需要去关注这些IO,等到适当的时机,或者内核发信号给应用,或者应用主动询问内核,来获取到IO操作的执行是否完成。
为什么需要异步IO
在理想的情况下,运行中的程序会尽可能发挥硬件的能力,包括CPU的计算能力以及存储设备的IO能力,来获得最好的性能。在近几年,存储设备的IO性能提升很快,如果还是使用之前的阻塞IO模式,设备的能力会得不到发挥,这和我们程序运行的初衷是相悖的。也就是说,在硬件设备相同的条件下,我们需要尽力改善代码,来获取更好的性能。事实上,很多东西都在做这样的事情,比如协程、事件驱动这些机制,本质上都是在尽可能的提升程序的执行效率。
那么异步IO是怎么提高性能的呢?上面说到,异步IO的本质就是应用将一批IO提交给内核,然后就不用管了,可以去做其他事情。这个过程对性能的提升体现在两个地方:
应用不再阻塞在IO这里,由内核来操作IO,应用可以执行其他逻辑,此时应用的运行和IO执行变成了并行的关系
可以批量的进行IO操作,让设备的能力得到最大发挥
这里有也有值得商榷的地方:1,是不是真正的在并行执行,如果cpu资源有限,应用线程和内核线程不能同时在各自的cpu核心上运行,那么其实也不是并行(从设备拷贝数据到内核可能不需要cpu参与,只要硬件就够了,但是从内核往用户控件拷贝是肯定需要内核线程来操作的)。2,批量提交IO给内核,是不是这个量越大越好。这些都需要看具体的情况。
有哪些异步IO的实现
现有的异步IO实现主要包括两个:
以aio_为前缀的一系列函数,包括 aio_read,aio_write, aio_suspend等,这个异步IO的实现是在用户态使用线程池实现的,性能不怎么样,它只是暴露出异步IO风格的接口。
libaio包提供的系列函数,libaio是包装在io_setp,io_submit等系统调用上的lib,这个一套正儿八经在内核实现的异步IO机制。
第一个这里就不说了,第二个libaio也存在很多问题,导致没有没广泛的应用,主要的缺陷如下:
只能支持O_DIRECT模式,也就是没有缓冲的读写
只能支持ext2, ext3, jfs, xfs文件系统
不支持fsync
不支持socket
不支持管道pipes
api设计的不够好:一次IO需要至少两次系统调用;submit + completion一共需要拷贝104字节数据,本来应该是0拷贝的(这个量不大,可能也不是一个严重的问题);此外,api很难使用正确
由于这些原因,libaio只在一些底层软件如数据库中有被使用,大多数普通的应用都没有使用libaio。
io_uring
在linux5.1以后,内核引入了一种全新的异步IO机制,也就是io_uring。io_uring基本上克服了上述aio的各种缺陷,它的主要特性如下:
支持O_DIRECT以及非O_DIRECT模式的文件读写,并能够支持在各种类型的fd上操作,包括文件、网络
高性能,相比于旧的aio,省去了读书数据的拷贝,减少必须的系统调用次数
丰富的特性,包括fixed buffer,polled IO等
简单易用的api接口
ring buffer
io_uring的最大特色在于对性能的提升,它通过让用户态的应用和内核共享数据结构来实现这一点,这个数据结构就是ring buffer,这也是io_uring名字的由来。
上面讲过,异步IO的基本逻辑是应用提交IO请求到内核,内核执行这些IO请求,然后应用再以某种方式来获取到IO执行完成的情况。很明显这个过程需要应用和内核交换信息,应用需要告诉内核有一个新的IO请求到来,内核需要告诉应用某个IO已经完成。之前的异步IO做法是让应用通过系统调用来获取这些信息,但是系统调用是一个相对较重的操作,它需要中断当前的进程,保持上下文,陷入内核,执行相应逻辑后再返回。io_uring的做法是直接让应用和内核共享两个ring buffer,一个是submission ring,一个completion ring,这两个ring以queue的形式工作,应用和内核通过访问这两个ring来获取需要的信息。对于SQ(submission queue)来说,应用是生产者,内核是消费者;对于CQ(completion queue)则是相反的。
在多线程中共享数据结构时,必须要做好同步的工作,因为这里有竞争条件。类似的,当应用和内核共享数据结构时,也需要做同步。一般的做法是使用互斥锁,但是由于这里应用是和内核在共享数据,如果使用锁,则必须要使用某种形式的系统调用。一方面,互斥锁对性能是有损耗的,另外,系统调用也是要避免的。io_uring的做法是使用memory ordering来避免出现数据不一致(在多核心的cpu架构中,每个核都有自己的多级缓存,线程只会在一个核心上运行,当某个线程连续更改了内存中某个变量的值,运行在其他核心上的线程的缓存需要做相应更新,此时其他线程可能会看到这些变量的变更顺序和发起更改的顺序不一致,memory ordering主要是用于防止这种现象,也就是它可以保证所有线程看到相同的变更顺序)。在使用ring buffer当做queue的这种场景下,生产和消费都是通过修改相应的head、tail值来进行的,使用memory ordering能够保证两边看到的数据是一致的。 相比于使用互斥锁,memory ordering的效率更高。
高级特性
FIXED FILES:在每次提交一个IO请求后,内核会获取这个IO请求中fd的一个引用,在使用完成后释放这个引用。在高IOPS、同时操作的文件基本不变的情况下,这个过程会有显著的性能损耗。io_uring支持使用一个或一组固定的fd,这样避免了内核频繁的创建和销毁fd的应用
FIXED BUFFER:在使用O_DIRECT模式的IO时,内核会将用户给的缓冲区map到内核的内存地址,然后在上面做读写,完成后再unmap这些地址。这个是比较昂贵的操作,为了避免频繁额map和unmap,io_uring提供了FIXED BUFFER的能力,即可以让应用重复的使用同一块已经映射好的缓冲区。
POLLED IO:这种模式下,应用使用轮询的方式来查询IO完成情况,应用会不停的询问硬件驱动,相应的IO是否已经完成,从而避免了由硬件设备中断来告知IO已经完成。对于IOPS高的应用来说,频繁的硬件中断会带来很多效率上的损耗。polled io这种模式适合用在IOPS高,硬件性能高的场景,能够有效提升应用的性能。
KERNAL SIDE POLLING:内核侧的轮询模式,使用这种方式,在应用提交一个IO操作到submission queue后,不需要通过系统调用来告诉内核有一个新的IO请求到来。内核中会有一个专职的线程关注submission queue,一旦有新的entry,会立即处理它。
io_uring和io多路复用在用法上比较类似,都是先提交一些数据,然后等待相应的事件发生,由于io_uring能够支持各种设备的IO,包括文件、网络,现在似乎可以使用io_uring将网络、文件读写都统一起来。但实际上,io_uring和epoll还是有些差别的,io_uring最多能够提交4096个IO请求到submission queue中,epoll则可以同时管理数以百万的连接,仅这一点就使得io_uring不可能代替epoll。