真正的异步--io_uring 闲谈

历史的接口

IO 一直是件麻烦事.对冯诺依曼模型的计算机来说,IO 可以说是计算的开始和结束.因此 IO 十分麻烦,但异常重要.
高效的 IO 方式,是构建高效的应用程序必不可少的,更是计算机科学家与工程师们一直探讨的话题.

在本文中,笔者将简要的回顾 Linux Kernel 已有的 IO 接口.

注:本文不涉及 io_uring 的用法.如需了解 io_uring 的用法请直接查看本文参考资料.

同步接口

常见的 readwritesyscall 都是同步 IO 的接口.
readwrite 类系统调用也衍生出了带有偏移量的接口 preadpwrite 向量读写的接口 readvwritev 和具有两者特性的 preadvpwritev,后来又出现为 preadvpwritev 加上 flag 字段的 preadv2pwritev2 接口.

说了这些,但究竟什么是同步?

同步接口最鲜明的特征就是应用程序:要么在执行应用程序中的用户代码;要么在因完成 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 是否就绪是一个做法.内核也提供了相关的设施用来完成遍历并轮询的操作(selectpoll) ,但这在同步 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_setupio_uring_enterio_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.