io_uring(1) – 我们为什么会需要 io_uring

2019年6月12日 16.49k 次阅读 0 条评论 3 人点赞

目录

IO 到底怎么啦

当前 Linux 对文件的操作有很多种方式,最古老的最基本就是 readwrite 这样的原始接口,这样的接口简洁直观,但是真的是足够原始,效率什么自然不是第一要素,当然为了符合 POSIX 标准,我们需要它。一段时间之后,程序员们发现,人们需要更为简单的 API,于是出现了 preadpwrite 它允许我们在读写时直接传递 offset,显而易见它表现的更为优秀,在减少编码的同时,提高了代码的健壮性1。后来又出现了 preadvpwritev 这种可以一次性发送多个 IO 的高效接口;接着又出现了变种函数 preadv2pwritev2 他们不仅仅可以发送向量型的 IO,offset,还能设置本次 IO 的标志,比如 RWF_DSYNC、RWF_HIPRI、RWF_SYNC 等等(暂时没有其他)。

上面介绍的一系列的接口全部都是同步接口,意思就是在读写 IO 时,caller 一定会阻塞起来等待结果返回,对于普通的传统编程模型,这其实没有什么大不了的,编程简单且结果可以预测;但是在高效情况下呢?同步导致的后果就是 caller 不再能够继续执行其他的操作,只能静静的等待 IO 结果返回,其实他明明可以利用这段时间继续处理下一个操作,好比是一个 ftp 服务器,当接收到客户机上传的文件,然后将文件写入到本机的过程中时,假设 ftp 服务程序忙于等待文件读写结果的返回,那么就会拒绝到其他正在需要连接的客户机请求2。有没有更好的方式?当然有,那就是采用异步 IO 模型,当一个客户机上传文件时,直接将 IO 的 buffer 提交给内核即可,然后 caller 继续接受下一个客户请求,在内核处理完毕 IO 之后,主动调用各种通知机制,告诉 caller 上一个 IO 已经完成,完成状态保存在某某位置,请查看。

有一个更为形象的例子,就好比你去 JD 买东西,在网站上下单之后,你就下楼去等快递,就一直等啊一直等,直到等到为止,这就是同步 IO;假设换个方式,你在网站上下单之后就不管了,等着快递到了楼下之后打个电话给你,说放到快递柜了,让你下楼去取,这就是 poll/epoll 模型,也就是非阻塞轮询模式,你下楼取快递的过程还是同步的方式;好吧,还有一种更妙的方式就是,你在网站下单之后,就不管了,快递直接送到你手里,这就是异步模式3

尴尬的 AIO

Great,原来我们是如此迫切的需要异步 IO,他能帮助我们做更多的事情而无需增加 caller 更多的复杂度,AIO 应运而生,POSIX 也适时的添加了 aio_readaio_write 这样的标准接口,好像一切都那么顺利,世界来到了一个完美的位面。Unfortunately,aio 满足了我们的要求,但是他也存在很多的缺陷。

第一,最大的缺陷就是不支持 buffer-io,也就是说,在采用 aio 的时候,你只能使用 O_DIRECT 来发送这一个 IO,带来的影响就是你不再能够借助文件系统缓存来缓存当前的 IO 请求,于是你在得到的同时失去了一些东西。

其次,尽管你强迫所有的 IO 都采用异步 IO,但是有时候确做不到,你的 caller 尽管将任务发送给了内核,但是内核还是通过工作队列或者线程完成的提交工作,假设在写元数据区域的时候,submission 会被挂起等待,假设存储设备的所有通道都很忙的时候,submission 需要挂起等待。于是,这些不确定性的存在,导致你的 caller 在处理完成状态的时候也不得不妥协。

最后,API 函数并不是很友好,基本上每一个 IO 的提交都需要要拷贝 64 + 8 个字节,而完成状态需要拷贝 32 个字节,这里就是 104 个字节的拷贝,当然,这个消耗是否可以承受是和你的 IO 大小有关的,如果你发送的大的 IO 的话,这点消耗可以忽略。同时,每一个 IO 至少需要两次系统调用才能完成(submit 和 wait-for-completion),这在有 spectre/meltdown 的机器上是一个严重的灾难。

导致最终的问题就是,在各种测试场景上,aio 欣欣向荣,然后在实际生产环境中,被采纳的很少,这就成为尴尬的 AIO4

那么应该是什么样子的呢?

有了问题就需要有解决方案,设计解决方案就需要确立目标,于是新接口需要什么目标?

足够简单且难以滥用:所有的用户可见接口都需要满足这个要求,接口一定需要让使用者容易理解且不容易被错误使用。

可扩展:尽管这个接口是为了存储设备而建立的,但是他需要有足够的扩展性让将来支持非阻塞设备以及网络数据传输。

功能丰富:这个没什么好说的,什么接口都需要支持足够丰富的功能,而且需要减少和核外应用程序的耦合度,尽可能减少交互复杂度。

高效:高效率本来就是目标。

可伸缩性:高效和低延迟很重要,但是峰值速率对于存储设备来讲也很重要,为了适应新的硬件要求,接口还需要考虑到伸缩性。

于是,我们需要重新设计接口,新的异步接口 io_uring 就在这样的环境下诞生了,可以预见的就是,在不久的将来,他会成为一个 POSIX 标准存在,也会被更多的企业级软件所采用5。在 Jens Axboe 自己的测试环境中,io_uring 在特定情况下,会比 SPDK 拥有更好的表现。

io_uring 实现了什么

实际上,io_uring 有大量的基于原有 aio 的逻辑代码,这是为了获得更为全面的支持,比如可以通过 eventfd 通知核外收割 IO 完成事件,或者通过 SIGIO 信号通知核外收割 IO 完成事件,以及可以通过 io_uring 的 poll 模式实现一个统一的编程模型,这些都是一些外围的、扩展的支持,没有他们也并不影响整个 io_uring 优秀的设计。

一般情况下,核外通过 setup 系统调用创建 fd,然后 mmap 内存实现内核与核外的交互,当需要提交数据时使用 submit 系统调用发送 IO 数据。看起来好像没有什么了不起,除了比 aio 每一个 IO 少提交了几个字节之外,没有什么更惊艳的实现,是的,如果仅仅只是这样,io_uring 确实并不出色,但是他还有:

io_uring_enter 收割完成状态:在我们发送 IO 的同时,使用同一个系统调用就可以回收完成状态,这样的好处就是一次系统调用接口就完成了原本需要两次系统调用的工作,大大的减少了系统调用的次数,也就是减少了内核核外的切换,这是一个很明显的优化,内核与核外的切换极其耗时。

sqo_thread 内核发送线程:也许你还是不满足自己来发送数据,那么内核给你准备了一个 sqo_thread,他的作用就是你只需要在准备好数据之后,通过一次 io_uring_enter 设置的 IORING_ENTER_SQ_WAKEUP 参数即可唤醒线程启动发送,而不需要你每次都自己去发送数据,在合适的情况下,sqo_thread 就不停歇的永久为你发送数据,除非你不再往 sqes 环中填写数据,sqo_thread 会在无数据之后的 1s 之后休眠,等待再次唤醒。这样的好处就是,也许你的整个 IO 过程中仅仅只需要发起 1 次系统调用,这是多么神奇的优化。

io_poll 模式:很多时候,完成状态的探测都是使用的中断模型,也就是等待后端数据处理完毕之后,内核会发起一个 SIGIO 或者 eventfd 的 EPOLLIN 状态提醒核外有数据已经完成,可以开始处理。但是内核还是给你提供了一个更为激进的模式,那就是 io_poll 模式,核外程序可以不停的轮询你需要的完成事件数量(通过 IORING_ENTER_GETEVENTS),不断的轮询探测完成状态,直到足够数量的 IO 完成,然后返回,这期间节省了中断返回的时间,又从一定的层次上加速了 IO 的推进(在 NVMe 上有较高的性能提升)。

fixed_file 模式:如果你的文件相对固定,那么可以通过 IORING_REGISTER_FILES 参数将他们注册到内核,这样内核在注册阶段就批量完成文件的一些基本操作,之后的 io 再次发送时不再需要不停的对文件描述符进行基本信息设置,节省了一定量的 IO 时间。

fixed_buffer 模式:如果你下发的 buffer 相对固定,也可以通过 IORING_REGISTER_BUFFERS 将他们注册到内核。通过固定的 buffer 传递 IOV 的地址和长度,减少了内存拷贝和基础信息检测等等。

对于核外而言,内核提供了足够多的选择,每一种方案都有着不同角度的优化提升,这些优化方案可以自行组合(当然需要正确使用),当 io_uring 全速运转时,才是他真正魅力所在。


  1. 我在某处的文章上看到过,pread 所做的偏移只针对本次操作而不影响文件整体的文件偏移指针,就是用 lseek 偏移的那个。 ↩︎
  2. 这只是一个假设,一般情况下服务程序会为每一个客户机 fork 一个进程进行服务的,当然这也不是什么最佳的处理方式,在更多的客户机同时链接时,会导致主机资源消耗殆尽的情况。 ↩︎
  3. 这个例子我从别处看过来的,别人举例就是比我高明。 ↩︎
  4. 我个人这么认为的,实际上,aio 确实使用场景不多。 ↩︎
  5. 目前已知的 qemu 已经开始采用 io_uring 进行适配,很快将可以通过这个接口来加速虚拟机 IO 的流程。 ↩︎