导言:大部分程序使用的I/O模型(传统的阻塞式I/O模型)都是单个进程每次只在一个文件描述符上执行I/O操作,每次I/O系统调用都会阻塞直到完成数据传输。但是,有些场景需要:
- 以非阻塞的方式检查文件描述符上是否可进行I/O操作。
- 同时检查多个文件描述符,看它们中的任何一个是否可以执行I/O操作。
对应的解决方法分别是,使用
非阻塞式I/O
和多进程(多线程)
。
- 在打开文件时设定
O_NONBLOCK
标志,会以非阻塞方式打开文件,如果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,例如,当有写事件发生时,内核向进程发送一个信号。相比select()和poll()在检查大量的文件描述符时可以提升性能。
- Linux专有的epoll(2.6+内核)。当同时检查大量文件描述符时,epoll能提供更好的性能。
- POSIX异步I/O(AIO),允许进程将I/O操作排列到一个文件中,当操作完成后得到通知。目前,Linux在glibc中提供有基于线程的POSIX AIO实现。
选择哪种I/O多路复用技术和原因
- 系统调用select()和poll()在UNIX系统中已经存在了很长的时间,同其他技术相比,它们的优势在于可移植性,而缺点是当同时检查大量文件描述符时性能延展性不好。
- epoll的优点是可以高效地检查大量的文件描述符,但缺点是它是专属于Linux系统的API。
- 信号驱动I/O也可以支持检查大量的文件描述符,但是epoll有一些其没有的优点:
- 避免了信号处理的复杂性
- 可以指定想要检查的事件类型(读就绪或写就绪)
- 可以选择水平触发或边缘触发来通知进程
- Libevent库,提供了检查文件描述符I/O事件的抽象,底层支持select(), poll(), 信号驱动I/O, epoll, 以及Solaris专有的/dev/poll接口和BSD系统的kqueue接口,对应用程序透明。
水平触发和边缘触发
水平触发通知:如果文件描述符上可以非阻塞地执行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操作,直到相应的系统调用(比如,read()/write())以错误码EAGAIN或EWOULDBLOCK返回。
I/O模式 | 水平触发 | 边缘触发 |
---|---|---|
select()/poll() | Y | N |
信号驱动I/O | N | Y |
epoll | Y | Y |
文件描述符何时就绪
SUSv3:如果对I/O函数的调用不会阻塞,而不论该函数是否能够实际传输数据,此时文件描述符被认为是就绪的。
select()和poll()在套接字上通知的事件:
条件或事件 | select() | poll() |
---|---|---|
有输入 | r | POLLIN |
可输出 | w | POLLOUT |
在监听套接字上建立连接 | r | POLLIN |
流套接字的对端关闭连接,或执行了shutdown(SHUT_WR) | rw | POLLIN|POLLOUT|POLLRDHUP |
epoll编程接口
epoll是Linux系统专有的,在2.6版中新增,主要优点有:
- 当检查大量文件描述符时,epoll的性能延展性比select()和poll()高很多。
- epoll即支持水平触发,也支持边缘触发。而select()和poll()只支持水平触发,而信号驱动I/O只支持边缘触发。
- 可以避免复杂的信号处理流程(比如,信号队列溢出时的处理)。
- 灵活性高,可以指定希望检查的事件类型。
epoll的核心数据结构:epoll实例,它和一个打开的文件描述符相关联,这个文件描述符不是用来做I/O操作的,它是内核数据结构的句柄,这些内核数据结构实现了两个目的:
- 记录了在进程中声明过的感兴趣的文件描述符列表(
interest list
,兴趣列表) - 维护了处于I/O就绪状态的文件描述符列表(
ready list
,就绪列表)
其中,ready list中的成员是interest list的子集。
对于由epoll检查的每一个文件描述符,可以指定一个位掩码
来表示我们感兴趣的事件。
epoll由以下3个系统调用组成:
- epoll_create()创建一个epoll实例,返回代表该实例的文件描述符。
- epoll_ctl()操作同epoll实例相关联的兴趣列表,通过epoll_ctl()可以增加/移除/修改文件描述符。
- epoll_wait()返回与epoll实例相关联的就绪列表中的成员。
深入探究epoll的语义
当通过epoll_create()创建了一个epoll实例时,内核在内存中创建了一个新的i-node并打开文件描述(抽屉,表示的是一个打开文件的上下文信息),随后在调用进程中为打开的这个文件描述(抽屉)分配了一个新的文件描述符(抽屉的把手,即句柄)。同epoll实例的兴趣列表相关联的是打开的文件描述,而不是epoll文件描述符。
文件描述(抽屉)实际是是内核中的一个数据结构,而用户空间的文件描述符(抽屉的把手)只不过是一个整数,epoll的兴趣列表实际关注的是内核中的数据结构。
边缘触发通知
默认情况下,epoll提供的是水平触发通知。这同select()和poll()所提供的通知类型相同。epoll还能以边缘触发方式进行通知,在语义上类似信号驱动I/O(区别是,如果有多个I/O事件发生的话,epoll会将它们合并成一次单独的通知,通过epoll_wait()返回,而信号驱动I/O则可能会产生多个信号)。
要使用边缘触发通知,在调用epoll_ctl()时在ev.events字段中指定EPOLLET
标志。
struct epoll_event ev;
ev.data.fd = fd;
ev.events = EPOLLIN | EPOLLET;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, fd, ev) == -1) {
// err op
}
水平触发和边缘触发的区别
- 套接字上有输入到来。
- 调用一次epoll_wait(),无论采用的是水平触发还是边缘触发通知,该调用都会告诉我们套接字已经处于就绪状态。
- 再次调用epoll_wait()。
如果采用的是水平触发,那么第二个epoll_wait()调用将告诉我们套接字处于就绪状态;而如果采用边缘触发通知,那么第二个epoll_wait()调用将阻塞,因为自从上一次调用epoll_wait()以来并没有新的输入到来。
边缘触发通知机制的程序基本框架
- 让所有待监视的文件描述符都成为非阻塞的。
- 通过epoll_ctl()构建epoll的兴趣列表。
- 通过如下的循环处理I/O事件。
3.1 通过epoll_wait()取得处于就绪状态的描述符列表。
3.2 针对每一个处于就绪状态的文件描述符,不断进行I/O处理直到相关系统调用(如read()/write()/recv()/send()/accept()等)返回EAGAIN或EWOULDBLOCK错误。
边缘触发通知时的饥饿问题
假设采用边缘触发通知监视多个文件描述符,其中一个处于就绪的文件描述符上有着大量的输入存在。如果在检测到该文件描述符处于就绪状态后,我们将尝试通过非阻塞的读操作将所有的输入都读取,那么此时就会使其他的文件描述符处于饥饿状态(即,在我们再次检查这些文件描述符是否处于就绪并执行I/O操作前会有很长的一段处理时间)。
该问题的一种解决方案是让应用程序维护一个列表,列表中存放着已经被通知为就绪状态的文件描述符,通过一个循环按照如下方式不断处理:
- 调用epoll_wait()监视文件描述符,并将处于就绪状态的描述符添加到应用程序维护的列表中。如果这个文件描述符已经注册到应用程序维护的列表中了,那么这次监视操作的超时时间应该设为较小的值或者是0。这样如果没有新的文件描述符成为就绪状态,应用程序就可以迅速进行到下一步,去处理那些已经处于就绪状态的文件描述符。
- 在应用程序维护的列表中,只在那些已经注册为就绪状态的文件描述符上进行一定限度的I/O操作。当相关的非阻塞I/O系统调用出现EAGAIN或EWOULDBLOCK错误时,文件描述符就可以从应用程序维护的列表中移除了。