Linux下I/O复用模型

本文详细介绍了Linux系统下的I/O复用模型,包括非阻塞I/O、I/O多路复用(select()、poll())、信号驱动I/O和epoll。重点对比了select()、poll()和epoll在性能和使用场景上的差异,并探讨了边缘触发和水平触发通知模式。epoll作为Linux特有的I/O复用机制,展示了更好的性能和灵活性。

以下内容引述至《Linux/Unix系统编程手册》

整体概览

常见的程序使用的I/O模型都是单个进程每次只在一个文件描述符上执行I/O操作,每次I/O系统调用会阻塞直到完成数据传输。
对于许多应用来说,传统的阻塞式I/O模型已经足够了,但这不代表所有的应用都能得到满足。

  • 如果可能的话,以非阻塞的方式检查文件描述符上是否可进行I/O操作
  • 同时检查多个文件描述符,看它们中的任何一个是否可以执行I/O操作
    满足这些技术需求的方案是:
    非阻塞式I/O 和 多进程或多线程技术

非阻塞式I/O可以让我们周期性地检查(“轮询”)某个文件描述符上是否可执行I/O操作。
由于非阻塞式I/O和多进(线)程都有各自的局限性,下列备选方案往往更可取:

  • I/O多路复用允许进程同时检查多个文件描述符以找出它们中的任何一个是否可执行I/O操作。系统调用select()和poll()用来执行I/O多路复用
  • 信号驱动I/O是指当时有输入或者数据可以写到指定的文件描述符上时,内核向请求数据的进程发送一个信号。进程可以处理其他的任务,当I/O操作可执行时通过接受信号来获取通知。当同时检查大量的文件描述符是,信号驱动I/O相比select()和poll()有显著的性能提升
  • epoll API是Linux 专有的特性,首次出现是在Linux 2.6版本中。同I/O多路复用API一样,epoll API允许进程同时检查多个文件描述符时,看其中任意一个是否能执行I/O操作。同信号驱动I/O一样,当同时检查大量文件描述符时,epoll能提供更好的性能。

实际上I/O多路复用、信号驱动I/O以及epoll都是用来实现同一目标的技术——同时检查多个文件描述符,看它们是否准备好了执行I/O操作。
文件描述符就绪状态的转化是通过一些I/O事件来触发的,比如输入数据到达,套接字连接建立完成,或者是之前满载的套接字发送缓冲区在TCP将队列中的数据传送到对端之后有了剩余空间。

技术选型

  • 系统调用select() 和 poll() 在Unix系统中已经存在很长的时间了。同其他技术相比,他们主要的优势在于可移植性,主要缺点在于当同时检查大量的(数百或数千个)文件描述符时性能延展性不佳
  • epoll API的关键技术优势在于它能让应用程序高效地检查大量的文件描述符
  • 同epoll一样,信号驱动I/O可以让应用程序高效地检查大量的文件描述符。但是epoll有一些信号驱动I/O所没有的优点
    1. 避免了处理信号的复杂性
    2. 我们可以指定想要检查的事件类型(即,读就绪或者写就绪)
    3. 我们可以选择以水平触发或边缘触发的形式来通知进程

水平出发和边缘触发

我们先区分两种文件描述符准备就绪的通知模式:

  • 水平触发通知:如果文件描述符上可以非阻塞式地执行I/O系统调用,此时认为它已经就绪
  • 边缘触发通知:如果文件描述符自上次状态检查以来有了新的I/O活动,此时需要触发通知

使用水平触发和边缘触发通知模型

I/O模式水平触发边缘触发
select(), poll()*
信号驱动*
epoll**

当采用水平触发通知时,我们可以在任意时刻检查文件描述符的就绪状态。这表示当我们确定了文件描述符处于就绪状态时(比如存在有输入数据),就可以对其执行一些I/O操作,然后重复检查we年描述符,看看是否仍然处于就绪态(比如还有更多的输入数据),此时我们就能执行更多的I/O,以此类推。

于此相反的是,当我们采用边缘触发时,只有当I/O事件发生时我们才会收到通知,在另一个I/O事件到来前我不会收到任何新的通知。另外当文件描述符收到I/O事件通知时,通常我们并不知道要处理多少I/O。因此,采用边缘触发通知的程序通常要按照如下规则来设计

  • 在接收到一个I/O事件通知后,程序在某个时刻应该在响应的文件描述符上尽可能多地执行I/O操作,如果程序没那么做,那么就可能失去执行I/O的机会。因为直到产生另一个I/O事件为止,在此之前程序都不会再接收到通知了,因此也就不知道此时应该执行I/O操作。这将导致数据丢失或者程序中出现阻塞。当我们确定了文件描述符是就绪态时,此事可能并不适合马上执行所有的I/O操作。问题的原因在于如果我们仅对一个文件描述符执行大量的I/O操作,可能会让其他文件描述符处于饥饿状态
  • 如果程序采用循环来对文件描述符执行尽可能多的I/O,而文件描述符又被置为可阻塞的,那么最终当没有更多的I/O可执行时,I/O系统调用就会阻塞。基于这个原因,每个被检查的文件描述符通常都应该被置为非阻塞模式,在得到I/O时间通知后重复执行I/O操作,直到响应的系统调用以错误码EAGAIN或EWOULDBLOCK的形式失败

采用非阻塞I/O

  • 非阻塞I/O通常和提供有边缘触发通知机制的I/O模型一起使用
  • 如果多个进程(或线程)在同一个打开的文件描述符上执行I/O操作,那么从某个特定进程的角度来看,文件描述符的就绪状态可能会在通知就绪和执行后续I/O调用之间发生改变。结果就是一个阻塞式的I/O调用将阻塞,从而防止进程检查其他的文件描述符
  • 尽管水平触发模式的API比如select() 或 poll() 通知我们流式套接字的文件描述符已经写就绪了,如果我们在单个write()或send()调用中写入足够大块的数据,那么该调用将阻塞
  • 在非常罕见的情况下,水平触发型的API比如select()和poll(),会返回虚假的就绪通知——他们会错误通知我们文件描述符已经就绪了。

I/O多路复用

I/O多路复用允许我们同时检查多个文件描述符,看其中任意一个是否可执行I/O操作。我们可以采用两个功能几乎相同的系统调用来执行I/O多路复用操作。

select()系统调用

系统调用select()会一直阻塞,直到一个或多个文件描述符集合称为就绪态

#include <sys/time.h>
#include <sys/select.h>
int select(int ndfd, fd_set *readfds, fd_set *writefds, fd_set *exceptfds,
			struct timeval * timeout);

fd_set 文件描述符集合
参数readfsd, writefds 以及 exceptfds 都是指向文件描述符集合的指针,所指向的数据类型是fd_set

  • readfds 是用来检测输入是否就绪的文件描述符集合
  • writefds 是用来检测输出是否就绪的文件描述符集合
  • exceptfds 是用来检测异常情况是否发生的文件描述符集合

在Linux上,一个异常情况只会在下面两种情况下发生

  • 连接到处于信号包模式下的伪终端主设备上的从设备状态发生了改变;
  • 流式套接字上接收到了带外数据

通常,数据类型fd_set以位掩码的形式来实现

#include <sys/select.h>
// 将fdset所指向的集合初始化为空
void FD_ZERO(fd_set *fdset); 
// 将文件描述符fd添加到由fdset所指向的集合中
void FD_SET(int fd, fd_set *fdset);
// 将文件描述符fd从fdset所指向的结婚中移除
void FD_CLR(int fd, fd_set *fdset);
// 如果文件描述符fd是fdset所指向的集合中的成员,FD_ISSET()返回true
int FD_ISSET(int fd, fd_set *fdset);

文件描述符集合由一个最大容量限制,由常量FS_SETSIZE来决定。在Linux上,该常量的值为1024

select()的返回值
-1 表示错误发生
0 表示超时
正整数表示有1个或多个文件描述符已达到就绪态,返回值表示处于就绪态的文件描述符个数。

poll() 系统调用

系统调用poll()执行的任务同select()很相似。两者间主要的区别是我们如何指定待检查的文件描述符。

#include <poll.h>
int poll(strcut pollfd[], nfds_t nfds, int timeout);

参数fds列出了我们需要poll()来检查的文件描述符。定义如下

strcut pollfd {
	int fd;
	short evnets;
	short revents;
};

参数nfds指定了数组fds中元素的个数。数据类型nfds_t 实际为无符号整型
pollfd结构体中的events和revents字段都是位掩码。调用者初始化events来指定需要描述符fd做检查的事件。当poll()返回时,revents被设定以此来表示该文件描述符上实际发生的事件。
返回值同select()函数,但是一个文件描述符只统计一次,select同一个文件描述符统计多次。

文件描述符就绪

如果对I/O函数的调用不会阻塞,而不论该函数是否能够实际传输数据,此时文件描述符(未指定O_NONBLOCK标志)被认为是就绪的。
select()和poll()只会告诉我们I/O操作是否阻塞,而不是高数我们到底能够成功传输数据。

  • select()这一列表示文件描述符是否被标记为可读®,可写(w)还是有异常情况(x)
  • poll()这一列表示在revents字段中返回的位掩码。这这些表格中,我们忽略POLLRDNORM、POLLWRNORM、POLLRDBAND以及POLLWRBAND

普通文件
代表普通文件的文件描述符总是被select()标记为可读和可写。对于poll()来说,则会在revents字段中返回POLLIN和POLLOUT标志

select() 和 poll() 比较

实现细节

在linux内核层面,select() 和 poll() 都使用了相同的内核poll例程集合。这些poll例程有别于系统调用poll()本身。每个例程都返回有关单个文件描述符就绪的信息。就绪信息以位掩码表示,其值同poll()系统调用中返回的revents字段中的比特值相关
为了实现select(),我们使用一组宏将内核poll例程返回的信息转化为由select()返回的与之对应的事件类型

#define POLLIN_SET (POLLRDNORM | POLLRDBAND | POLLIN | POLLHUP | POLLERR)
#define POLLOUT_SET (POLLWRBAND | POLLWRNORM | POLLOUT | POLLERR)
#define POLLEX_SET (POLLPRI)
区别
  • select()所使用的数据类型fd_set对于被检查的文件描述符数量有一个上限限制,poll()没有
  • select()参数fd_set同时也是保存调用结果的地方,如果要在循环中重复调用select()的话,我们必须每次都要重新初始化fd_set,而poll()通过独立的两个字段events(针对输入)和revents(针对输出)来处理,从而避免每次都要重新初始化参数
  • select()提供的超时精度比poll()提供的超时精度比poll()提供的超时精度高
  • 如果其中一个被检查的文件描述符关闭了,通过在对应的revents字段中设定POLLNVAL标记,poll()会准确告诉我们哪一个文件描述符关闭了。而select()只会返回-1,并设置错误码EBADF
性能

当满足如下两条任意一条时,性能接近

  • 待检查的文件描述符范围较小
  • 有大量的文件描述符待检查,但是它们分布得很密集(0到某个上限之间)
    然而,当文件描述符集合很稀疏的时候,poll()表现得比select()要好很多。当使用poll()时,只需要指定我们感兴趣的文件描述符即可,内核只会去检查这些指定的文件描述符
存在的问题
  • 每次调用select()或poll(),内核都必须检查所有指定的文件描述符,看他们是否处于就绪态。当检查大量处于密集范围内的文件描述符时,该操作耗费的时间将大大超过接下来的操作
  • 每次调用select()或poll()时,程序都必须传递一个表示所有需要被检查的文件描述符的数据结构到内核,内核检查过描述符后,修改这个数据结构并返回给程序。对于poll()而言,随着待检查的文件描述符数量的增加,传递给内核的数据结构大小也会随之增加。当检查大量文件描述符时,从用户空间到内核空间来回拷贝这个数结构将占大量的CPU时间。对于select()来说,这个数据结构的大小固定为FD_SETSIZE,与待检查的文件描述符数量无关
  • select()或poll()调用完成后,程序必须检查返回的数据结构中的每个元素,一次查明哪个文件描述符处于就绪态了

信号驱动I/O

在I/O多路复用中,进程时通过系统调用(select()或poll())来检查文件描述符上是否可以执行I/O操作。而在信号驱动I/O中,当文件描述符上可执行I/O操作时,进程请求内核为自己发送一个信号。之后进程就可以执行其他的任务直到I/O就绪为止。
过程如下:

  1. 为内核发送的通知信号安装一个信号处理例程,默认情况下,这个通知信号为SIGIO
  2. 设定文件描述符的属主,也就是当文件描述符上可执行I/O时会收到通知信号的进程或进程组。通常我们让调用进程称为属主。设定属主可通过fcntl()的F_SETOWN操作来完成
  3. 通过设定O_NONBLOCK标志使能非阻塞I/O
  4. 通过打开O_ASYNC标志使能信号驱动I/O
  5. 调用进程现在可以执行其他的任务了,当I/O操作就绪时,内核为进程发送一个信号,然后调用信号处理例程
  6. 信号驱动I/O提供的是边缘触发通知,这表示一旦进程被通知I/O就绪,就应该进口嫩更多地执行I/O

优化信号驱动I/O的使用

想要全部利用信号驱动I/O的优点,可以通过下面两个步骤

  • 通过专属于Linux的fcntl() F_SETSIG操作来指定一个实时信号,当文件描述符上的I/O就绪时,这个实时信号应该取代SIGIO被发送;
  • 使用sigaction()安装信号处理例程时,为前一步中使用的实时信号指定SA_SIGINFO标记

epoll编程接口

同I/O多路复用和信号驱动I/O一样,Linux的epoll API可以检查多个文件描述符上的I/O就绪状态。epoll API的主要优点如下:

  • 当检查大量的文件描述符时,epoll的性能延展性比select()和poll()高很多
  • epoll API既支持水平触发也支持边缘触发,与之相反,select()和poll()只支持水平触发,而信号驱动I/O只支持边缘触发

性能表现上,epoll同信号驱动I/O相似,但是epoll有一些胜过信号驱动I/O的优点

  • 可以避免复杂的信号处理流程
  • 灵活性高,可以指定我们希望检查的事件类型

epoll API的核心数据结构称为epoll实例,它和一个打开的文件描述符相关联。这个文件描述符不是用来做I/O操作的,相反,它是内核数据结构的句柄,这些内核数据结构实现了两个目的

  • 记录了在进程中声明过的感兴趣的文件描述符列表—— interest list
  • 维护了处于I/O就绪态的文件描述符列表—— ready list

epoll API由以下3个系统调用组成

  • 系统调用epoll_create()创建一个epoll 实例,返回代表该实例的文件描述符
  • 系统调用epoll_ctl() 操作同epoll 实例相关联的兴趣列表
  • 系统调用epoll_wait() 返回与 epoll 实例相关联的就绪列表中的成员

当我们通过epoll_create() 创建一个epoll实例时,内核在内存中创建一个新的i-node并打开文件描述,随后在调用进程中为打开的这个文件描述分配一个新的文件描述符。同epoll实例的兴趣列表相关联的是打开的文件描述,而不是epoll 文件描述符

边缘触发

epoll默认是水平触发,表示epoll会告诉我们何时能在文件描述符上以非阻塞的方式执行I/O操作。这同poll() 和 select() 所提供的通知类型相同
epoll API还可以通过边缘触发方式进行通知——也就是说,会告诉我们自从上一次epoll_wait()以来文件描述符上是否已有I/O活动了。使用epoll的边缘触发通知在语义上类似于信号驱动I/O,只是如果有多个I/O事件发生的话,epoll会将他们合并成一次单独的通知,通过epoll_wait()返回,而在信号驱动I/O中可能会产生多个信号

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值