IO多路复用
1. 基本概念
IO多路复用是指一旦发现进程指定的一个或者多个描述符可进行无阻塞IO访问时,它就通知该进程。IO多路复用适用以下场合:(1) 当客户处理多个描述字时(一般是交互式输入和网络套接口),必须使用I/O复用。
(2) 当一个客户同时处理多个套接口时,而这种情况是可能的,但很少出现。
(3) 如果一个TCP服务器既要处理监听套接口,又要处理已连接套接口,一般也要用到I/O复用。
(4) 如果一个服务器既要处理TCP,又要处理UDP,一般要使用I/O复用。
(5) 如果一个服务器要处理多个服务或多个协议,一般要使用I/O复用。
与多进程和 多线程技术 相比,I/O 多路复用技术 的最大优势是 系统开销 小,系统不必创建进程/线程,也不必维护这些进程/线程,从而大大减少了系统的开销。 对于应用层来说,使用非阻塞I/O的应用程序通常会使用select()和poll()系统调用查询是否可对设备进行无阻塞的访问。
主要应用:
(1)客户程序需要同时处理交互式的输入和服务器之间的网络连接
(2)客户端需要对多个网络连接作出反应
(3)TCP服务器需要同时处理多个处于监听状态和多个连接状态的套接字
(4)服务器需要处理多个网络协议的套接字
(5)服务器需要同时处理不同的网络服务和协议
2. IO多路复用模式
3. select()函数
#include <sys/time.h>
int select(int nfds, fd_set *readfds, fd_set *wtitefds, fd_set *errnofds,
struct timeval *timeout)
注意:描述符不受限与套接字,任何描述符都行
nfds:select()函数监视的描述符数的最大值,一般取监视的描述符数的最大值+1,
其上限设置在sys/types.h中有定义
#define FD_SETSIZE 256
readfds:select()函数监视的可读描述符集合
wtitefds:select()函数监视的可写描述符集合
errnofds:select()函数监视的异常描述符集合
timeout:select()函数监视超时结束时间,取NULL表示永久等待
返回值:返回总的位数这些位对应已准备好的描述符,否则返回-1
相关宏操作:
4. select()函数实现IO多路复用的步骤
(1)清空描述符集合
(2)建立需要监视的描述符与描述符集合的关系
(3)调用select函数
(4)检查监视的描述符判断是否已经准备好
(5)对已经准备好的描述符进程IO操作
5. poll函数
poll()函数是System V中引入的系统调用,其原型为:int poll(struct pollfd *fds, unsigned int nfds, int timeout);
pollfd结构体定义如下:
sruct pollfd {
int fd;// 文件描述符
short events;//等待的事件
short revents;//实际发生了的事件
};
每一个pollfd结构体指定了一个被监视的 文件描述符 ,可以传递多个结构体,指示poll()监视多个文件描述符。每个结构体的events域是监视该文件描述符的事件掩码,由用户来设置这个域。revents域是文件描述符的操作结果事件掩码,内核在调用返回时设置这个域。
events域中请求的任何事件都可能在revents域中返回。合法的事件如下:
POLLIN 有数据可读。
POLLRDNORM 有普通数据可读。
POLLRDBAND 有优先数据可读。
POLLPRI 有紧迫数据可读。
POLLOUT 写数据不会导致阻塞。
POLLWRNORM 写普通数据不会导致阻塞。
POLLWRBAND 写优先数据不会导致阻塞。
POLLMSGSIGPOLL 消息可用。
此外,revents域中还可能返回下列事件:
POLLER 指定的 文件描述符 发生错误。
POLLHUP 指定的 文件描述符 挂起事件。
POLLNVAL 指定的 文件描述符 非法。
这些事件在events域中无意义,因为它们在合适的时候总是会从revents中返回。 使用poll()和select()不一样,你不需要显式地请求异常情况报告。
POLLIN | POLLPRI等价于select()的读事件,
POLLIN等价于POLLRDNORM |POLLRDBAND,
timeout参数指定等待的毫秒数,无论I/O是否准备好,poll都会返回。timeout指定为负数值表示无限超时,使poll()一直挂起直到一个指定事件发生;timeout为0指示poll调用立即返回并列出准备好I/O的 文件描述符 ,但并不等待其它的事件。这种情况下,poll()就像它的名字那样,一旦选举出来,立即返回。
返回值和错误代码
成功时,poll()返回结构体中revents域不为0的 文件描述符 个数;如果在超时前没有任何事件发生,poll()返回0;失败时,poll()返回-1,并设置errno为下列值之一:
EBADF 一个或多个结构体中指定的 文件描述符 无效。
EFAULTfds 指针指向的地址超出进程的地址空间。
EINTR 请求的事件之前产生一个信号,调用可以重新发起。
EINVALnfds 参数超出PLIMIT_NOFILE值。
ENOMEM 可用内存不足,无法完成请求。
select()和poll()函数本质上没有多大差别,管理多个描述符也是进行轮询,根据描述符的状态进行处理,但是poll没有最大 文件描述符 数量的限制。并且select()返回后,之前没有准备好的文件描述符会从集合当中删除,这样如果下次需要再次添加所有文件描述符或者使用两个相同的文件描述符集合,一个用于备份,一个用于监听,比较复杂。poll不需要这个复杂的操作。poll和select同样存在一个缺点就是包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,而无论这些文件描述符是否就绪。它的开销随着文件描述符数量的增加而线性增加。
所以之后又出现了一个select和poll的增强版本epoll,此处就不做过多的介绍。
同步阻塞IO在等待数据就绪上花去太多时间,而传统的同步非阻塞IO虽然不会阻塞进程,但是结合轮询来判断数据是否就绪仍然会耗费大量的CPU时间。
多路IO复用提供了对大量文件描述符进行就绪检查的高性能方案。
select
select诞生于4.2BSD,在几乎所有平台上都支持,其良好的跨平台支持是它的主要的也是为数不多的优点之一。
select的缺点:
(1)单个进程能够监视的文件描述符的数量存在最大限制
(2)select需要复制大量的句柄数据结构,产生巨大的开销
(3)select返回的是含有整个句柄的列表,应用程序需要遍历整个列表才能发现哪些句柄发生了事件
(4)select的触发方式是水平触发,应用程序如果没有完成对一个已经就绪的文件描述符进行IO操作,那么之后每次select调用还是会将这些文件描述符通知进程。相对应方式的是边缘触发。
poll
poll 诞生于UNIX System V Release 3,那时AT&T已经停止了UNIX的源代码授权,所以显然也不会直接使用BSD的select,所以AT&T自己实现了一个和select没有多大差别的poll。
poll和select是名字不同的孪生兄弟,除了没有监视文件数量的限制,select后面3条缺点同样适用于poll。
面对select和poll的缺陷,不同的OS做出了不同的解决方案,可谓百花齐放。不过他们至少完成了下面两点超越,一是内核长期维护一个事件关注列表,我们只需要修改这个列表,而不需要将句柄数据结构复制到内核中;二是直接返回事件列表,而不是所有句柄列表。
/dev/poll
Sun在Solaris中提出了新的实现方案,它使用了虚拟的/dev/poll设备,开发者可以将要监视的文件描述符加入这个设备,然后通过ioctl()来等待事件通知。
/dev/epoll
名为/dev/epoll的设备以补丁的方式出现在Linux2.4中,它提供了类似/dev/poll的功能,并且在一定程度上使用mmap提高了性能。
kqueue
FreeBSD实现了kqueue,可以支持水平触发和边缘触发,性能和下面要提到的epoll非常接近。
epoll
epoll诞生于Linux 2.6内核,被公认为是Linux2.6下性能最好的多路IO复用方法。
int epoll_create( int size)
int epoll_ctl( int epfd, int op, int fd, struct epoll_event *event)
int epoll_wait( int epfd, struct epoll_event *events, int maxevents, int timeout)
|
- epoll_create 创建 kernel 中的关注事件表,相当于创建 fd_set
- epoll_ctl 修改这个表,相当于 FD_SET 等操作
- epoll_wait等待 I/O事件发生,相当于 select/poll 函数
epoll支持水平触发和边缘触发,理论上来说边缘触发性能更高,但是使用更加复杂,因为任何意外的丢失事件都会造成请求处理错误。Nginx就使用了epoll的边缘触发模型。
这里提一下水平触发和边缘触发就绪通知的区别,这两个词来源于计算机硬件设计。它们的区别是只要句柄满足某种状态,水平触发就会发出通知;而只有当句柄状态改变时,边缘触发才会发出通知。例如一个socket经过长时间等待后接收到一段100k的数据,两种触发方式都会向程序发出就绪通知。假设程序从这个socket中读取了50k数据,并再次调用监听函数,水平触发依然会发出就绪通知,而边缘触发会因为socket“有数据可读”这个状态没有发生变化而不发出通知且陷入长时间的等待。
因此在使用边缘触发的 api 时,要注意每次都要读到 socket返回 EWOULDBLOCK为止。