Linux I/O 模型介绍之 Select 和 Epoll

Select 和 Epoll 是如何工作的

Table of Contents

话说关于 epoll 这个话题,我曾经整理过不下四次了,每次整理之前都雄心勃勃,然而每次又都半途而废,我思考了一下主要是因为对 socket、fd 以及操作系统的一些概念了解不深造成的,而且已有的文章的质量都不高,没有一个全面的、高质量的总结,所以老天保佑这次能够整理完。

在读这篇文章之前我希望你了解 Linux 内核网络栈的相关内容,了解数据是如何从网卡中被 Linux 使用的,你可以先参考《Linux 内核网络栈》

Socket 与 fd

Socket 一般都是以一个 fd 为形式被用户使用。Linux 遵循一切皆文件的理念,将 I/O 操作都抽象出读和写两个关键操作(当然还有打开和关闭等,这里就不赘述了),文件读和写是与硬盘设备交互,socket(虚假的文件)读和写则是与网卡交互,本质上他们都是操作二进制字节流并与某种外设交互。每个进程打开的文件都各不相同,比如 A 进程可能打开 file1,B 进程打开了 file2。每个进程自己需要维护一个文件表,所谓的文件表就是一个数组,一个索引就称之为一个 fd,当用户提供一个 fd 时就进程通过查数组就可以找到对应的文件。综上,一个 fd 就对应了一个 socket 连接,当 socket 数据准备完毕,内核通过某种方式返回就绪的 fd 就可以告知用户进程哪些 socket 的数据已经准备完毕。

阻塞与非阻塞

阻塞 I/O 是指在等待 I/O 准备的过程中,当前进程被加入到阻塞队列,在 I/O 准备好之前将永远不会进入运行态,在等待 I/O 过程中永远不会消耗 CPU,同时进程也丧失了对 CPU 的使用权。

非阻塞 I/O 是指在等待 I/O 准备的过程中,会立即返回结果(有数据或无数据),进程可以通过自己的逻辑来自由决定下次什么时候再去查询,当然也有个比较笨的方案就是在一个 for 循环中无限轮询(弊端是 CPU 会被 for 循环打满),此时进程是有使用 CPU 的权利的。

Select 与 epoll

那么为什么要使用 select 和 epoll 呢?在实际场景中一个服务器可能要处理成百上千的 TCP 连接,每个 TCP 连接都对应一个 fd,如果进程自己一个个遍历全部 fd 看是否就绪这个事情可能就直接让服务器挂掉(虽然有那么多 TCP 连接,但是真正活跃的可能并不多),所以需要一种方式能更高效的让用户进程知道那些 fd 已经准备就绪了,这就是 select 和 epoll 存在的意义。

我们都知道 epoll 是 select 的升级版,我们就先从 select 开始讲起。

首先我们先来假设在没有 select 的方式中,等待每一个 socket 准备数据都需要阻塞当前进程,那么当我们在不使用并发的情况下,1000 个 socket 将会被一个个阻塞等待就绪(后续都用 1000 个 socket 作为假设),这个是灾难性的,当然我们可以启动 1000 个线程同时阻塞等待,这也同样是灾难性的。

Select 机制简单来说就是一个线程同时监听 1000 个 fd(以后我们用 fd 代替 socket,因为是一个更通用的概念)。那么我们考虑如果想实现这个机制,要明确几个问题:(1)谁来监听?(2)如何知道要监听哪些 fd?(3)在 select 返回之后,如何确定在 1000 个 fd 中定位哪个 fd 就绪?

问题(1):内核监控。因为涉及到 I/O 操作都必须经过内核态处理,所以需要用户以某种方式将需要监控的 fd 告知内核。

问题(2):是通过 bitset(在 Linux 实现中是 fd_set)的方式告知内核的,假设 sizeof(fd_size) = 512B,那么可以传递 4096 个 fd,比如我们要监听的 fd 包括 1、2 和 6,那么 fd_set = 0b00100011,select 支持的 fd_set 有两种,一种是 readset,一种是 writeset,分别表示要监听类型。

问题(3):当然是遍历 readset 和 writeset 了,每次 select 返回就需要遍历一次,需要注意的是每次返回内核都会将没有就绪的 fd 清零(如果清零咋知道哪个 fd 就绪了呢),所以用户自己必须使用数组保留一份需要监控的 fd 的副本,并且在下次 select 之前重新设置 readset 和 writeset。

通过上述问题,我们可以看出来 select 的问题:

  1. 一次监控的 fd 数量有限,由 sizeof(fd_set) 的大小决定,sizeof(fd_set) 效率越低(线性扫描)。
  2. 内核需要通过线性扫描 + 轮询的方式确定 socket 是否准备就绪,返回之后用户进程同样需要通过线性扫描的方式确定哪些 fd 可用。
  3. 内核会将 readset 和 writeset 的数据拷贝到 select 查询队列中,代价也同样是每次调用 select 都需要 copy 一次。

Epoll 机制则是对 select 机制的改进,本质来说就是一个更高效的一个线程同时监听 1000 个 fd 的方案,可以说 epoll 就是一条一条针对 select 的问题改进的。

Epoll 使用红黑树数据结构维护了一个需要被监控的全部 fd,所有需要被监控的 fd 只需要被添加一次(解决问题 3 不需要每次调用 select 时传递和拷贝),同时由于使用树结构,fd 的数量就没有任何限制了(解决问题 1)。

第二是为每个被监控的 fd 注册一个回调函数,在某个 fd 准备就绪后就会调用相应的回调函数。再说这个回调函数干什么之前,我们需要说明下 epoll 维护了一个就绪 fd 列表(rdllist),是说当 fd 准备就绪后会将 fd 的引用加入到该列表。

回调函数做的事情是:

  1. 将本次唤醒对应的 fd 的引用追加到 rdllist 中;
  2. 将等待本次唤醒对应的 fd 的进程由阻塞态修改为就绪态。

用户监听 fd 就绪只需要调用 epoll_wait() 函数,首先会检查 rdllist 是否有数据,如果有则直接返回,如果没有则将其变为阻塞态(由回调函数唤醒),这里就可以看出来 epoll 下只需要线性遍历就绪 fd 而不是全部 fd(通常活跃 fd 数量远小于监听 fd 数量,比如 1000 个 fd 可能活跃的就几个 fd)。

最后在来说下触发模式(以读为例):

  • 水平触发(LT):只要缓冲区有数据就返回。
  • 边缘触发(ET):只有新数据到达时才返回。

如果采用水平触发模式的话,系统中一旦有大量你不需要读写的就绪文件描述符,每次调用epoll_wait都会返回,这样会大大降低处理程序检索自己关心的就绪文件描述符的效率。而采用边缘触发模式的话,当被监控的文件描述符上有可读写事件发生时,epoll_wait() 会通知处理程序去读写。如果这次没有把数据全部读写完(如读写缓冲区太小),那么下次调用epoll_wait()时,它不会通知你,也就是它只会通知你一次,直到该文件描述符上出现第二次可读写事件才会通知你!这种模式比水平触发效率高,系统不会充斥大量你不关心的就绪文件描述符。

References

All rights reserved
Except where otherwise noted, content on this page is copyrighted.