真正的异步--io_uring 闲谈
历史的接口
IO 一直是件麻烦事.对冯诺依曼模型的计算机来说,IO 可以说是计算的开始和结束.因此 IO 十分麻烦,但异常重要.
高效的 IO 方式,是构建高效的应用程序必不可少的,更是计算机科学家与工程师们一直探讨的话题.
在本文中,笔者将简要的回顾 Linux Kernel 已有的 IO 接口.
注:本文不涉及 io_uring
的用法.如需了解 io_uring
的用法请直接查看本文参考资料.
同步接口
常见的 read
、write
等 syscall
都是同步 IO 的接口.read
、write
类系统调用也衍生出了带有偏移量的接口 pread
、pwrite
向量读写的接口 readv
、writev
和具有两者特性的 preadv
、pwritev
,后来又出现为 preadv
、pwritev
加上 flag
字段的 preadv2
、pwritev2
接口.
说了这些,但究竟什么是同步?
同步接口最鲜明的特征就是应用程序:要么在执行应用程序中的用户代码;要么在因完成 IO.
听起来可能有人觉得模糊,请看下面这张图.同步 就是「IO 的完成」是在「请求执行 IO 的时候」(Y7n05h 嘴笨实在不知道该怎么组织语言解释了)
在图中:
- 紫色代表应用程序在执行用户代码
- 红色代表应用程序在完成 IO
我们可以看到,紫色块和红色块在时间上没有任何的重叠区域.完成 IO 不会和用户代码同时进行.
Y7n05h 猜测一定有读者想说,为什么都说了这么多了,没有提及 阻塞
和 非阻塞
哪怕一句.
这里请允许 Y7n05h 先说异步 IO.因为很多人把 非阻塞
和 异步
混为一谈.Y7n05h 认为先说明异步 IO 有助于理解这二者的概念.
异步接口
同步的反面是异步.
异步就是应用程序只需要提交一次 IO 的请求,由别的组件(通常是内核)来完成完成这次 IO,并在 IO 完成时告诉应用程序 IO 已经完成.
注:有经验的读者一定发现 Y7n05h 在此处刻意模糊了内核在 IO 中的作用,也未提及系统调用导致的陷入内核态等行为.这是为了使本文对异步的描述也适用于 ASIO 等用户态对异步 IO 的实现.
如下图:应用程序无需间断对代码的执行,只需要提交一次请求,即可静待别人(通常是内核)完成 IO.
对于异步 IO :这就好比一个聪明的老板(类比应用程序)请了一个高明的助理,收发文书(类比 IO 行为)之类的事情,只需要老板吩咐一声,助理就好办妥当.助理办妥当后,告诉老板这件事办好了即可.老板只需要接着做自己的事(类比执行代码).
对于同步 IO :这就好比一个没有助理的老板(类比应用程序),收发文书(类比 IO 行为)之类的事情也得自己干.忙着收发文书就不能做自己的事情(类比执行代码)了.
阻塞与非阻塞
谈阻塞和非阻塞就一定谈谈内核了.
在 Linux 系统中,无论采用阻塞 IO 还是非阻塞 IO,若 IO 已经准备好了,那么会立刻返回.
阻塞和非阻塞的区别仅限于 IO 尚未准备就绪的情况下(例如写管道缓冲区已满、读 socket 但尚无数据到达).这类场景,在在使用非阻塞 IO 的系统调用时,系统调用会立刻返回,并通过返回值和 errno
告诉调用者出现了错误.但若是使用阻塞 IO 的系统调用,则会继续等待制止 IO 完成.
Q:那么阻塞与否和同步、异步又有什么关系?
A:平日说的阻塞与非阻塞大多数情景是指同步阻塞和同步非阻塞.对于异步 IO 是否阻塞的问题,通常不做探讨.
为什么?那就要接着回顾 IO 接口的发展了.
众所周知,无论是网络 IO 还是硬盘 IO,其速度远低于 CPU 的运行速度.因此,等待 IO 浪费了应用程序原本可以执行很多事务的时间.追求高性能的应用程序自然不肯什么都不做静静的等待 IO 的发生.
在 Y7n05h 看来 同步非阻塞
的 IO 调用就是为了解决应用程序长时间等待 IO 浪费时间的问题.使用阻塞 IO 之后,应用程序自然可以过每过一小段时间尝试一次 IO 是否已经就绪,别的时间继续用来做别的事,这也就是是所谓的 轮询.
倘若一个 IO 密集型应用(例如一个服务器)那么可能需要同时处理大量的 IO 请求,当然遍历并轮询所有的 IO 是否就绪是一个做法.内核也提供了相关的设施用来完成遍历并轮询的操作(select
、poll
) ,但这在同步 IO 中也不是一个最好的做法.内核还提供了 epoll
这种机制,当内核通过中断机制得知有 IO 时间发生时通知应用程序.这样便避免了遍历之苦也提高了 IO 的效率.这也就是 IO 的多路复用了.
非阻塞 IO 的语义是:试一试,若能完成 IO 就完成;完不成就算了.
说了这么多,我想读者一定发现了:非阻塞 IO 无非是想提高 CPU 的利用率.
谈回异步,既然异步 IO 已经不可能卡住应用程序的代码了.那么阻塞与否就已经没了意义.
不但非阻塞在异步 IO 中没有意义,反而会制造麻烦.何处此言?因为非阻塞 IO 遇到 IO 未就绪时会直接返回.
回到之前老板请助理的例子.老板一定不会希望他请助理去送一份文件,仅仅是因为助理没找到收件人就回来向他报告失败,而是希望他去等收件人回来再把文件交给他.这才是一个聪明的助理.异步 IO 完美的符合了这一切的标准.
io_uring 一统天下
在 io_uring 出现之前,追求高性能 IO 的应用程序有这几种常见做法:
- 针对文件 IO 可采用 AIO 异步接口.
- epoll + 同步非阻塞.
- 使用类似 boost Asio 的方式,使用 IO 线程模拟异步接口.
但这几种方式都有自己的问题:
- AIO 仅支持文件 Direct IO.
- epoll + 同步非阻塞在大量连接的高并发场景中比 io_uring 有更高的开销和更高的延迟.
- boost Asio 与 io_uring 同为异步接口,但 io_uring 的在内核态的实现比在用户态基于多线程模拟异步 IO 更高效.
可以说,AIO 被 io_uring 最主要是因为 AIO 的应用面太窄.而「epoll 同步非阻塞」和「boost Asio」被 io_uring 打败是因为 io_uring 的性能更好.
但 io_uring 并非没有缺点.
可移植性差.
这是 io_uring
的一个硬伤.io_uring
是 Linux 5.1 中加入的新接口.且 io_uring 还有部分特性在 5.6 才最终加入.因此想体验 io_uring 的一个相对完整的特性可能需要 Linux Kernel 5.6+.(虽然 Linux Kernel 5.6 中的 io_uring 已经相对完整了,但 Linux Kernel 5.10-5.15 中也为 io_uring 添加了更多的新特性)
接口复杂.
注意到了吗?io_uring
代替的是 「epoll
+ 同步非阻塞」而不仅仅是 epoll
.为了支持各种 IO 调用,io_uring 通过庞大 struct io_uring_sqe
描述各种各样的 IO 请求.但 io_uring
接口的复杂性不仅仅体现在这里.io_uring_setup
、io_uring_enter
、io_uring_register
看似仅仅只有 3 个系统调用,但它们却都分别支持了十多个 flag
来改变系统调用的行为.
那么 io_uring
的性能为什么会好呢?
- 内核和用户态通过 mmap 共享 io_uring 相关的部分数据结构.
- 内核可以并行执行应用程序提交的 IO 请求.
- 节省系统调用次数.将 IO 请求放入提交队列(SQ)即可,无需通过中断陷入内核执行系统调用.
MoreInfo
本文到这里就结束了.读者可能会觉得有点突兀,但 Y7n05h 写本文的意愿本就不是去介绍 io_uring
的用法.本文仅仅是为了科普这几种不同的 IO 的方式的区别,区分 「阻塞」 与 「非阻塞」 这一对概念和 「同步」与「异步」这一堆概念.对于需要详细了解 io_uring
的读者,请看下面的 参考资料.
参考资料
1. Efficient IO with io_uring.[G/OL].https://kernel.dk/io_uring.pdf. ↩
2. What is io_uring?. [G/OL]. Lord of the io_uring, https://unixism.net/loti/what_is_io_uring.html. ↩
3. IO_URING(7). [G/OL]. Linux Programmer’s Manual, https://man.archlinux.org/man/io_uring.7. ↩
4. IO_URING_SETUP(2). [G/OL]. Linux Programmer’s Manual, https://man.archlinux.org/man/io_uring_setup.2. ↩
5. IO_URING_ENTER(2). [G/OL]. Linux Programmer’s Manual, https://man.archlinux.org/man/io_uring_enter.2. ↩
6. IO_URING_REGISTER(2). [G/OL]. Linux Programmer’s Manual, https://man.archlinux.org/man/io_uring_register.2. ↩