TLPI读书笔记第63章-备选IO模型2
同 I/O 多路复用和信号驱动 I/O 一样, Linux 的 epoll( event poll) API 可以检查多个文件描述符上的 I/O 就绪状态。 epoll API 的主要优点如下。
1.当检查大量的文件描述符时, epoll 的性能延展性比 select()和 poll()高很多。
2.epoll API 既支持水平触发也支持边缘触发。与之相反, select()和 poll()只支持水平触发,而信号驱动 I/O 只支持边缘触发。
性能表现上, epoll 同信号驱动 I/O 相似。但是, epoll 有一些胜过信号驱动 I/O 的优点。
1.可以避免复杂的信号处理流程(比如信号队列溢出时的处理)。
2.灵活性高,可以指定我们希望检查的事件类型(例如,检查套接字文件描述符的读就绪、写就绪或者两者同时指定)。
epoll API 是 Linux 系统专有的,在 2.6 版中新增。
epoll API 的核心数据结构称作 epoll 实例,它和一个打开的文件描述符相关联。这个文件描述符不是用来做 I/O 操作的,相反,它是内核数据结构的句柄,这些内核数据结构实现了两个目的。
1.记录了在进程中声明过的感兴趣的文件描述符列表—interest list(兴趣列表)。
2.维护了处于 I/O 就绪态的文件描述符列表—ready list(就绪列表)。
ready list 中的成员是 interest list 的子集。
对于由 epoll 检查的每一个文件描述符,我们可以指定一个位掩码来表示我们感兴趣的事件。这些位掩码同 poll()所使用的位掩码有着紧密的关联。
epoll API 由以下 3 个系统调用组成。
1.系统调用 epoll_create()创建一个 epoll 实例,返回代表该实例的文件描述符。
2.系统调用 epoll_ctl()操作同 epoll 实例相关联的兴趣列表。通过 epoll_ctl(),我们可以增加新的描述符到列表中,将已有的文件描述符从该列表中移除,以及修改代表文件描述符上事件类型的位掩码。
3.系统调用 epoll_wait()返回与 epoll 实例相关联的就绪列表中的成员
63.4.1 创建 epoll 实例: epoll_create()
系统调用 epoll_create()创建了一个新的 epoll 实例,其对应的兴趣列表初始化为空。
参数 size 指定了我们想要通过 epoll 实例来检查的文件描述符个数。 该参数并不是一个上限,而是告诉内核应该如何为内部数据结构划分初始大小。 作为函数返回值, epoll_create()返回了代表新创建的 epoll 实例的文件描述符。这个文件描述符在其他几个 epoll 系统调用中用来表示 epoll 实例。当这个文件描述符不再需要时,应该通过 close()来关闭。当所有与 epoll 实例相关的文件描述符都被关闭时,实例被销毁,相关的资源都返还给系统。 (多个文件描述符可能引用到相同的 epoll 实例,这是由于调用了 fork()或者 dup()这样类似的函数所致。 )
63.4.2 修改 epoll 的兴趣列表: epoll_ctl()
系统调用 epoll_ctl()能够修改由文件描述符 epfd 所代表的 epoll 实例中的兴趣列表。
参数 fd 指明了要修改兴趣列表中的哪一个文件描述符的设定。该参数可以是代表管道、FIFO、套接字、 POSIX 消息队列、 inotify实例、终端、设备,甚至是另一个 epoll 实例的文件描述符。但是,这里 fd 不能作为普通文件或目录的文件描述符(会出现 EPERM 错误,普通文件根本不会阻塞)。 参数 op 用来指定需要执行的操作,它可以是如下几种值。 EPOLL_CTL_ADD 将描述符 fd 添加到 epoll 实例 epfd 中的兴趣列表中去。对于 fd 上我们感兴趣的事件,都指定在 ev 所指向的结构体中,下面会详细介绍。如果我们试图向兴趣列表中添加一个已存在的文件描述符, epoll_ctl()将出现 EEXIST 错误。 EPOLL_CTL_MOD 修改描述符 fd 上设定的事件,需要用到由 ev 所指向的结构体中的信息。如果我们试图修改不在兴趣列表中的文件描述符, epoll_ctl()将出现 ENOENT 错误。 EPOLL_CTL_DEL 将文件描述符 fd 从 epfd 的兴趣列表中移除。该操作忽略参数 ev。如果我们试图移除一个不在 epfd 的兴趣列表中的文件描述符, epoll_ctl()将出现 ENOENT 错误。关闭一个文件描述符会自动将其从所有的 epoll 实例的兴趣列表中移除。
/*参数 ev 是指向结构体 epoll_event 的指针,结构体的定义如下*/
struct epoll_event{
uint32_t events;/*所感兴趣的事件集合*/
epoll_data_t data;
}
/*结构体 epoll_event 中的 data 字段的类型为: */
typedef union epoll_data{
void *ptr; /*用户自定义指针*/
int fd; /*文件描述符*/
uint32_t u32; /*32位整数*/
uint64_t u64; /*64位整数*/
}epoll_data_t;
参数 ev 为文件描述符 fd 所做的设置如下。
1.结构体 epoll_event 中的 events 字段是一个位掩码,它指定了我们为待检查的描述符 fd 上所感兴趣的事件集合。我们将在下一节中说明该字段可使用的掩码值。
2.data 字段是一个联合体,当描述符 fd 稍后成为就绪态时,联合体的成员可用来指定传回给调用进程的信息
max_user_watches 上限
因为每个注册到 epoll 实例上的文件描述符需要占用一小段不能被交换的内核内存空间,因此内核提供了一个接口用来定义每个用户可以注册到 epoll 实例上的文件描述符总数。这个上限值可以通过 max_user_watches 来查看和修改。 max_user_watches 是专属于 Linux 系统的/proc/sys/fd/epoll 目录下的一个文件。默认的上限值根据可用的系统内存来计算得出
63.4.3 事件等待: epoll_wait()
系统调用 epoll_wait()返回 epoll 实例中处于就绪态的文件描述符信息。单个 epoll_wait()调用能返回多个就绪态文件描述符的信息。
参数 evlist 所指向的结构体数组中返回的是有关就绪态文件描述符的信息。 (结构体epoll_event 已经在上一节中描述。 )数组 evlist 的空间由调用者负责申请,所包含的元素个数在参数 maxevents 中指定。
在数组 evlist 中,每个元素返回的都是单个就绪态文件描述符的信息。 events 字段返回了在该描述符上已经发生的事件掩码。 Data 字段返回的是我们在描述符上使用 cpoll_ctl()注册感兴趣的事件时在 ev.data 中所指定的值。注意, data 字段是唯一可获知同这个事件相关的文件描述符号的途径。因此,当我们调用 epoll_ctl()将文件描述符添加到兴趣列表中时,应该要么将 ev.data.fd 设为文件描述符号(如程序清单 63-4 中所示),要么将 ev.data.ptr 设为指向包含文件描述符号的结构体。
参数 timeout 用来确定 epoll_wait()的阻塞行为,有如下几种。
1.如果 timeout 等于-1,调用将一直阻塞,直到兴趣列表中的文件描述符上有事件产生,或者直到捕获到一个信号为止。
2.如果 timeout 等于 0,执行一次非阻塞式的检查,看兴趣列表中的文件描述符上产生了哪个事件。
3.如果 timeout 大于 0,调用将阻塞至多 timeout 毫秒,直到文件描述符上有事件发生,或者直到捕获到一个信号为止。 调用成功后, epoll_wait()返回数组 evlist 中的元素个数。如果在 timeout 超时间隔内没有任何文件描述符处于就绪态的话,返回 0。出错时返回-1,并在 errno 中设定错误码以表示错误原因。 在多线程程序中,可以在一个线程中使用 epoll_ctl()将文件描述符添加到另一个线程中由epoll_wait()所监视的 epoll 实例的兴趣列表中去。这些对兴趣列表的修改将立刻得到处理,而epoll_wait()调用将返回有关新添加的文件描述符的就绪信息。
epoll 事件
当我们调用 epoll_ctl()时可以在 ev.events 中指定的位掩码以及由 epoll_wait()返回的evlist[].events 中的值在表 63-8 中给出。除了有一个额外的前缀 E 外,大多数这些位掩码的名称同 poll()中对应的事件掩码名称相同。(例外情况是 EPOLLET 和 EPOLLONESHOT,下面我们会给出更详细的说明。 )这种名称上有着对应关系的原因是当我们在 epoll_ctl()中指定输入,或通过 epoll_wait()得到输出时,这些比特位表达的意思同对应的 poll()的事件掩码所表达的意思一样。
EPOLLONESHOT 标志
默认情况下,一旦通过 epoll_ctl()的 EPOLL_CTL_ADD 操作将文件描述符添加到epoll 实例的兴趣列表中后,它会保持激活状态(即之后对 epoll_wait()的调用会在描述符处于就绪态时通知我们)直到我们显式地通过 epoll_ctl()的 EPOLL_CTL_DEL 操作将其从列表中移除。
如果希望在某个特定文件描述符上只得到一次通知,可以在传给 epoll_ctl()的ev.events指定EPOLLONESHOT标志。如果指定了这个标志,那么在下一个 epoll_wait()调用通知我们对应的文件描述符处于就绪态之后,这个描述符就会在兴趣列表中被标记为非激活态,之后的 epoll_wait()调用都不会再通知我们有关这个描述符的状态了。
如果需要,我们可以稍后通过 epoll_ctl()的 EPOLL_CTL_ MOD 操作重新激活对这个文件描述符的检查。 (这种情况下不能用 EPOLL_CTL_ADD 操作,因为非激活态的文件描述符仍然还在 epoll 实例的兴趣列表中。 )
63.4.4 深入探究 epoll 的语义
现在我们来看看打开的文件同文件描述符以及 epoll 之间交互的一些细微之处。 基于本次讨论的目的,回顾一下图 5-2 中展示的文件描述符,打开的文件描述( file description),以及整个系统的文件 i-node 表之间的关系。 当我们通过 epoll_create()创建一个 epoll 实例时,内核在内存中创建了一个新的 i-node 并打开文件描述, 随后在调用进程中为打开的这个文件描述分配一个新的文件描述符。 同 epoll 实例的兴趣列表相关联的是打开的文件描述,而不是 epoll 文件描述符。这将产生下列结果。
1.如果我们使用 dup()(或类似的函数)复制一个 epoll 文件描述符,那么被复制的描述符所指代的 epoll 兴趣列表和就绪列表同原始的 epoll 文件描述符相同。若要修改兴趣列表,在 epoll_ctl()的参数 epfd 上设定文件描述符可以是原始的也可以是复制的。
2.上一条观点同样也适用于 fork()调用之后的情况。此时子进程通过继承复制了父进程的 epoll 文件描述符, 而这个复制的文件描述符所指向的 epoll 数据结构同原始的描述符相同。
当我们执行 epoll_ctl()的 EPOLL_CTL_ADD 操作时,内核在 epoll 兴趣列表中添加了一个元素,这个元素同时记录了需要检查的文件描述符数量以及对应的打开文件描述的引用。 epoll_wait()调用的目的就是让内核负责监视打开的文件描述。 这表示我们必须对之前的观点做改进:如果一个文件描述符是 epoll 兴趣列表中的成员,当关闭它后会自动从列表中移除。改进版应该是这样的:一旦所有指向打开的文件描述的文件描述符都被关闭后,这个打开的文件描述将从 epoll 的兴趣列表中移除。这表示如果我们通过 dup()(或类似的函数)或者 fork()为打开的文件创建了描述符副本,那么这个打开的文件只会在原始的描述符以及所有其他的副本都被关闭时才会移除。
63.4.5 epoll 同 I/O 多路复用的性能对比
表 63-9 展示了当我们使用 poll()、 select()以及 epoll 监视 0 到 N-1 的 N 个连续文件描述符时的结果(在 2.6.25 版内核上)。(该测试设定为在每次监视中,只有一个随机选择的文件描述符处于就绪态。 )从这个表格中,我们发现随着被监视的文件描述符数量的上升,poll()和 select()的性能表现越来越差。与之相反,当 N 增长到很大的值时, epoll 的性能表现几乎不会降低。 (当 N 值上升时,微小的性能下降可能是由于测试系统上的 CPUcache达到了上限
在 63.2.5 节中我们知道了为什么 select()和 poll()在监视大量的文件描述符时性能表现很差。现在我们看看为什么 epoll 的性能表现会更好。
1.每次调用 select()和 poll()时,内核必须检查所有在调用中指定的文件描述符。与之相反,当通过 epoll_ctl()指定了需要监视的文件描述符时,内核会在与打开的文件描述上下文相关联的列表中记录该描述符。之后每当执行 I/O 操作使得文件描述符成为就绪态时,内核就在 epoll 描述符的就绪列表中添加一个元素。(单个打开的文件描述上 下文中的一次 I/O 事件可能导致与之相关的多个文件描述符成为就绪态。 )之后的epoll_wait()调用从就绪列表中简单地取出这些元素。
2.每次调用 select()或 poll()时,我们传递一个标记了所有待监视的文件描述符的数据结构给内核,调用返回时,内核将所有标记为就绪态的文件描述符的数据结构再传回给我们。与之相反,在 epoll 中我们使用 epoll_ctl()在内核空间中建立一个数据结构,该数据结构会将待监视的文件描述符都记录下来。一旦这个数据结构建立完成,稍后每次调用 epoll_wait()时就不需要再传递任何与文件描述符有关的信息给内核了,而调用返回的信息中只包含那些已经处于就绪态的描述符
粗略来看,我们可以认为当 N(被监视的文件描述符数量)取值很大时, select()和 poll()的性能会随着 N 的增大而线性下降。这可以从表 63-9 中 N=100 和 N=1000 时的情况得到。而当N=10000 时,性能伸缩性实际上比线性还要差。 与之相反的是, epoll 的性能会根据发生 I/O 事件的数量而扩展(呈线性)。因此常见的能够高效使用 epoll API 的应用场景就是需要同时处理许多客户端的服务器:需要监视大量的文件描述符,但大部分处于空闲状态,只有少数文件描述符处于就绪态。
63.4.6 边缘触发通知
默认情况下 epoll 提供的是水平触发通知。这表示 epoll 会告诉我们何时能在文件描述符上以非阻塞的方式执行 I/O 操作。这同 poll()和 select()所提供的通知类型相同。
epoll API 还能以边缘触发方式进行通知—也就是说,会告诉我们自从上一次调用epoll_wait()以来文件描述符上是否已经有 I/O 活动了(或者由于描述符被打开了,如果之前没有调用的话)。使用 epoll 的边缘触发通知在语义上类似于信号驱动 I/O,只是如果有多个 I/O事件发生的话, epoll将它们合并成一次单独的通知,通过epoll_wait()返回,而在信号驱动I/O 中则可能会产生多个信号。
要使用边缘触发通知,我们在调用 epoll_ctl()时在 ev.events 字段中指定 EPOLLET标志
当采用边缘触发通知时避免出现文件描述符饥饿现象
假设我们采用边缘触发通知监视多个文件描述符,其中一个处于就绪态的文件描述符上有着大量的输入存在(可能是一个不间断的输入流)。如果在检测到该文件描述符处于就绪态后,我们将尝试通过非阻塞式的读操作将所有的输入都读取,那么此时就会有使其他的文件描述符处于饥饿状态的风险存在(即,在我们再次检查这些文件描述符是否处于就绪态并执行 I/O 操作前会有很长的一段处理时间)。 该问题的一种解决方案是让应用程序维护一个列表,列表中存放着已经被通知为就绪态的文件描述符。通过一个循环按照如下方式不断处理。
1.调用 epoll_wait()监视文件描述符,并将处于就绪态的描述符添加到应用程序维护的列表中。如果这个文件描述符已经注册到应用程序维护的列表中了,那么这次监视操作的超时时间应该设为较小的值或者是 0。这样如果没有新的文件描述符成为就绪态,应用程序就可以迅速进行到下一步,去处理那些已经处于就绪态的文件描述符了。
2.在应用程序维护的列表中,只在那些已经注册为就绪态的文件描述符上进行一定限度的 I/O 操作(可能是以轮转调度( round-robin)方式循环处理,而不是每次 epoll_wait()调 用 后 都 从列 表 头 开 始处 理 ) 。 当 相 关 的 非 阻塞 I/O 系 统调 用 出 现 EAGAIN 或EWOULDBLOCK 错误时,文件描述符就可以从应用程序维护的列表中移除了。
尽管采用这种方法需要做些额外的编程工作,但是除了能避免出现文件描述符饥饿现象外,我们还能获得其他益处。比如,我们可以在上述循环中加入其他的步骤,比如处理定时器以及用 sigwaitinfo()(或其他类似的机制)来接收信号。 因为信号驱动 I/O 也是采用的边缘触发通知机制,因此也需要考虑文件描述符饥饿的情况。与之相反,在采用水平触发通知机制的应用程序中,考虑文件描述符饥饿的情况并不是必须的。这是因为我们可以采用水平触发通知在非阻塞式的文件描述符上通过循环连续地检查描述符的就绪状态,然后在下一次检查文件描述符的状态前在处于就绪态的描述符上做一些 I/O 处理就可以了。