I/O 模型 04 - 多路 I/O 就绪通知

多路 I/O 就绪通知

在实际应用中,特别是 Web 服务器,同时处理大量的文件描述符是必不可少的,但是使用同步非阻塞 I/O 显然不是最佳的选择, 在这种模型下,我们知道如果服务器想要同时接收多个 TCP 连接的数据,就必须轮流对每个 socket 调用接收数据的方法,比如 recv()。 不管这些 socket 有没有可以接收的数据,都要询问一遍,假如大部分 socket 并没有数据可以接收, 那么进程便会浪费很多 CPU 时间用于检查这些 socket,这显然不是我们所希望看到的。

多路 I/O 就绪通知的出现,提供了对大量文件描述符就绪检查的高性能方案, 它允许进程通过一种方法来同时监视所有文件描述符,并可以快速获得所有就绪的文件描述符,然后只针对这些文件描述符进行数据访问。

回到买面条的故事中,假如你不止买了一份面条,还在其他几个小吃店买了饺子、粥、馅饼等,因为一起逛街的朋友看到你的面条后也饿了。 这些东西都需要时间来制作。在同步 I/O 非阻塞模型中,你要轮流不停地去各个小吃店询问进度,痛苦不堪。
现在引入多路 I/O 就绪通知后,小吃城管理处给大厅安装了一块电子屏幕,以后所有小吃店的食物做好后,都会显示在屏幕上, 这可真是一个好消息,你只需要间隔性地看看大屏幕就可以了,也许你还可以同时逛逛附近的商店,在不远处也可以看到大屏幕。

需要注意的是,I/O 就绪通知只是帮助我们快速获得就绪的文件描述符, 当得知数据就绪后,就访问数据本身而言,仍然需要选择阻塞或非阻塞的访问方式, 一般我们选择非阻塞方式,以防止任何意外的等待阻塞整个进程, 比如有时就绪通知只代表一个内核的提示,也许此时文件描述符尚未真正准备好或者已经被客户端关闭连接。

UNIX 家族历史

由于平台和历史等原因,多路 I/O 就绪通知有很多不同的实现,在检查大量文件描述符时的性能也存在一定的差异。 说到这里,我们有必要简单介绍一下 UNIX 家族的历史,这有助于我们更加深刻地了解这些依赖于平台的技术。

记住,最早的 UNIX 诞生于贝尔实验室,在随后的十年,UNIX 在学术机构和大型企业中被广泛应用。 贝尔实验室是 AT&T 收购了西方电子公司的研究部门后设立的一个独立实体, AT&T 这时以廉价的许可将 UNIX 的源代码授权给一些研究机构和大学,供它们研究和教学使用, 这使得很多机构在此基础上对 UNIX 进行了完善和扩展,促进了 UNIX 的发展,同时产生了一些新的变种版本, 其中最著名的变种之一便是由加州大学 Berkeley 分校开发的 BSD,这一变种对 UNIX 有着重大贡献和深远影响。

由于 BSD 开始被很多企业广泛采用,不久之后,AT&T 意识到 UNIX 的商业价值,于是开始停止源代码授权, 同时为了统一混乱的 UNIX 版本,AT&T 综合了其他大学和企业开发的各种 UNIX,开发了 UNIX System V Release 1。

这时候 BSD 已经非常成熟,比如 select 就诞生于这时候的 4.2 BSD, 而 TCP/IP 则诞生于 4.1 BSD,它的代码也成为以后几乎所有操作系统 TCP/IP 实现代码的前辈,包括 Windows。 BSD 不断增大的影响力终于引起了 AT&T 的关注,AT&T 将 BSD 告上了法庭,一场源代码版本的官司开始了, 一直持续到 AT&T 将 UNIX 实验室项目卖给 Novell。

Novell 采取了开明的态度,它允许 BSD 自由开发,但前提是必须删除来自 AT&T 的代码, 就这样,BSD 诞生了全新的版本 4.4 BSD Lite,这个版本不存在法律问题。 所以它成为很多现代自由版 UNIX 的基础,它们和 UNIX 以及 Linux 一起,共同成为 UNIX 大家族的成员。

BSD 在发展中逐渐衍生出 3 个主要的分支:FreeBSD、OpenBSD 和 NetBSD。

在此后的几十年中,UNIX 仍在不断变化中,其版权所有者不断变更,授权者的数量也在增加。 有很多大公司在取得了 UNIX 的授权之后,开发了自己的 UNIX 产品, 比如 IBM 的 AIX、HP 的 HPUX、Sun 的 Solaris、SGI 的 IRIX,以及 Apple 的 MacOS。

正是在 UNIX 之间的利益纷争及其商业化运作的背景下,乱世出英雄,Linux 诞生了, 它天生便吸取了 UNIX 的教训,在版权问题上直接采用自由开发的 GPL 开源许可, 这种开发精神使得 Linux 就像当年的 BSD 一样迅速发展,同时又由于 GPL 的使用,很好地规避了变种并存的情况。

这真是一部充满斗争的历史,它深深影响到几十年后的我们,本章介绍的技术都来自于那个时代, 所以当你被很多相似的实现方法搞得眼花缭乱时,想想那也许只是多年前的一个历史错误。

以下的实现方法,我们只侧重介绍其优势和局限性,目的在于真正了解它们在何种场景下表现出现, 至于如何使用它们来编写代码,超出了本书的讨论范围,你可以查看手册或者《UNIX 网络编程》。

select

select 最早于 1983 年出现在 4.2 BSD 中,它通过一个 select() 系统调用来监视包含多个文件描述符的数组, 当 select 返回后,该数组中就绪的文件描述符便会被内核修改标志位,使得进程可以获得这些文件描述符从而进行后续的读写操作。

select 目前几乎在所有的平台上都支持,其良好跨平台支持也是它的一个优点,事实上从现在看来,这也是所剩不多的优点之一。

select 的一个缺点在于单个进程能够监视的文件描述符数量存在最大限制,在 Linux 上一般为 1024, 不过可以通过修改宏定义甚至重新编译内核的方式提升这一限制。 所以,假如你使用了 select 的服务器已经维持了 1024 个连接,那么你的请求可能会被拒绝。

另外,select() 所维护的存储大量文件描述符的数据结构,随着文件描述符数量的增大,其复制的开销也线性增长。 同时,由于网络响应时间的延迟使得大量 TCP 连接处于非活跃状态, 但调用 select() 会对所有 socket 进行一次线性扫描,所以这也浪费了一定的开销。

poll

poll 在 1986 年诞生于 System V Release 3,显然 UNIX 不愿意直接沿用 BSD 的 select,而是自己重新实现了一遍, 它和 select 在本质上没有多大差别,但是 poll 没有最大文件描述符数量的限制。

poll 和 select 同样存在的一个缺点就是,包含大量文件描述符的数组被整体复制于用户态和内核的地址之间, 而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增长。

另外,select() 和 poll() 将就绪的文件描述符告诉进程后,如果进程没有对其进行 I/O 操作, 那么下次调用 select() 或 poll() 的时候将再次报告这些文件描述符, 所以它们一般不会丢失就绪的消息,这种方式称为水平触发(Level Triggered)。

SIGIO

Linux 2.4 提供了 SIGIO,它通过实时信息号(Real Time Signal)来实现 select/poll 的通知方法, 但是它们的不同在于,select/poll 告诉我们哪些文件描述符是就绪的,一直到我们读写它之前,每次 select/poll 都会告诉我们; 而 SIGIO 则是告诉我们哪些文件描述符刚刚变为就绪状态,它只说一遍,如果我们没有采取行动,那么它将不会再次告知, 这种方式称为边缘触发(Edge Triggered)。SIGIO 几乎是 Linux 2.4 下性能最好的多路 I/O 就绪通知方法。

但是,SIGIO 也存在一些缺点,在 SIGIO 机制中,代表事件的信号由内核中的事件队列来维护,信号按照顺序进行通知, 这可能导致一个信号到达的时候,该事件已经过期,它所描述的文件描述符已经被关闭。 另一方面事件队列长度是有长度限制的,无论你设置多大的上限,总有可能被事件装满, 这就很很容易发生事件丢失,所以这时候需要采用其他方法来弥补损失。

/dev/poll

Sun 在 Solaris 中提供了新实现方法,它使用了虚拟的 /dev/poll 设备,你可以将要监视的文件描述符数组写入这个设备, 然后通过 ioctl() 来等待事件通知。当 ioctl() 返回就绪的文件描述符后,你可以从 /dev/poll 中读取所有就绪的文件描述符数组, 这点类似于 SIGIO,节省了扫描所有文件描述符的开销。

在 Linux 下有很多方法可以实现类似 /dev/poll 的设备,但是都没有提供直接的内核支持,这些方法在服务器负载较大时性能不够稳定。

/dev/epoll

随后,名为 /dev/epoll 的设备以补丁的形式出现在 Linux 2.4 上,它提供了类似 /dev/poll 的功能, 而且增加了内存映射(mmap)技术,在一定程度上提高了性能,后面会详细介绍内存映射的内容。

但是,/dev/epoll 仍然只是一个补丁,Linux 2.4 并没有将它的实现加入内核。

epoll

直到 Linux 2.6 才出现了由内核直接支持的实现方法,那就是 epoll,它几乎具备了之前所说的一切优点, 被公认为 Linux 2.6 下性能最好的多路 I/O 就绪通知方法。

epoll 可以同时支持水平触发和边缘触发,理论上边缘触发的性能要更高一些,但是代码实现相当复杂,因为任何意外的丢失事件都会造成请求处理错误。 在默认情况下 epoll 采用水平触发,如果要使用边缘触发,则需要在事件注册时增加 EPOLLET 选项。

在 Lighttpd 的 epoll 模型代码(src/fdevent_linux_sysepoll.c)中,可以看到它注释掉了 EPOLLET,并没有使用边缘触发方式,如下所示: ep.event |= EPOLLERR | EPOLLHUP /* | EPOLLET */;

而在 Nginx 的 epoll 模型代码(src/event/moudules/ngx_epoll_module.c)中,可以看到它使用了边缘触发,如下所示: ee.event = EPOLLIN|EPOLLOUT|EPOLLET

另外,epoll 同样只告知那些就绪的文件描述符,而且当我们调用 epoll_wait() 获得就绪文件描述符时,返回的并不是实际的描述符, 而是一个代表就绪描述符数量的值,你只需要去 epoll 指定的一个数组中依次取得相应数量的文件描述符即可, 这里也使用了内存映射(mmap)技术,这样便彻底省掉了这些文件描述符在系统调用时复制的开销。

另一个本质的改进在于 epoll 采用基于事件的就绪通知方式。 在 select/poll 中,进程只有在调用一定的方法后,内核才对所有监视的文件描述符进行扫描, 而 epoll 事先通过 epoll_ctl() 来注册每一个文件描述符,一旦某个文件描述符就绪时,内核会采用类似 callback 的回调机制, 迅速激活这个文件描述符,当进程调用 epoll_wait() 时便得到通知。

回到买面条的故事中,虽然有了电子屏幕,但是显示的内容是所有餐品的状态,包括正在制作的和已经做好的,这显然给你造成阅读上的麻烦, 就好像 select/poll 每次返回所有监视的文件描述符一样。 如果能够只显示做好的餐品,那该多好,随后小吃城管理处改进了大屏幕,实现了这一点,这就像 /dev/poll 一样只告知就绪的文件描述符。 在显示做好的餐点时,如果只显示一次,而不管你有没有看到,这就相当于边缘触发,而如果在你领取餐点之前,每次都显示,就相当于水平触发。

但尽管这样,一旦你走得比较远,就还得花时间走到小吃城去看电子屏幕,能不能让你更轻松地获得通知呢? 管理处这次采用了手机短信通知的方法,你只需要到管理处注册后,便可以在餐点就绪时及时收到短信通知,这类似于 epoll 的事件机制。

kqueue

FreeBSD 中实现了 kqueue,它像 epoll 一样可以设置水平触发或边缘触发,同时 kqueue 还可以用来监视磁盘文件和目录, 但是它的 API 在很多平台都不支持,而且文档相当匮乏。 kqueue 和 epoll 的性能非常接近。

对于以上介绍的多种实现方法中,在 Linux 平台的 Web 服务器中,我们常用的主要有 select、poll、SIGIO、epoll, 尤其是在 Linux 2.6 中,epoll 成为首要选择。

也许你没有特别留意,在安装一些 Web 服务器软件的时候,configure 过程中会检查当前系统支持的多路 I/O 就绪通知方法, 比如 Nginx 在 configure 时,其中一部分内容如下所示:


 + rt signal found
checking for epoll ... found
checking for poll() ... found
checking for /dev/poll ... not found
checking for kqueue ... not found

Ref

摘自《构建高性能 Web 站点》第 3 章 服务器并发处理能力 3.6 I/O 模型
聊聊 IO 多路复用之 select、poll、epoll 详解