IO多路复用的Select,Poll,Epoll
一、基本概念
I/O多路复用(详细可见《五大I/O模型》)的本质是通过系统内核缓冲I/O数据,让单个进程可以监视多个文件描述符(Socket),一旦某个描述符就绪(读就绪或写就绪),就能够通知用户进程进行相应的读写操作。
- 文件描述符
UNIX、Linux中的一个概念。本身是一个非负整数,本质上是一个文件指针数组的索引,指向内核中一个结构体(进程所维护的该进程打开文件的记录表)。应用进程对文件的读写就通过对描述符的读写完成,当它打开一个文件或创建一个文件时,内核向进程返回一个文件描述符。
二、select()
2.1 函数简介
int select(int maxfdp1,fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval *timeout);
select函数参数说明:
- int maxfdp1:待测试的文件描述符个数,它的值是待测试的最大值加1。
- fd_set *readset 、fd_set *writeset 、fd_set *exceptset:每个参数都是一个数组,存放文件描述符(即文件句柄)。分别指定了要让内核测试读、写和异常条件的文件描述符数组。
- timeval *timeout:内核等待指定文件描述符集合中的任何一个就绪的超时时间。
fd_set:
select()提供了一种 fd_set 的核心数据结构,实际上是一个long类型的数组,每一个数组元素都能与一打开的文件句柄(不管是Socket句柄,还是其它文件/命名管道/设备句柄)建立联系。当调用select()时,由内核根据IO状态修改 fd_set 的内容,由此来通知执行了select()的进程哪一个Socket或文件可读。
系统提供了4个宏对描述符集进行操作:
#include <sys/select.h>
#include <sys/time.h>
// 将fd加入set集合
void FD_SET(int fd, fd_set *fdset);
// 将fd从set集合中清除
void FD_CLR(int fd, fd_set *fdset);
// 检测fd是否在set集合中,不在则返回0
void FD_ISSET(int fd, fd_set *fdset);
// 将set清零使集合中不含任何fd
void FD_ZERO(fd_set *fdset);
2.2 select()总结
从执行过程来看,使用基于select的IO多路复用和同步阻塞IO没有太大的区别,而且多了添加监视socket以及调用select函数的额外操作,按理说效率更低。但是,select()可以让用户可以在一个线程内同时处理多个socket的IO请求,用户可以注册多个感兴趣的socket,然后不断地调用select轮询被激活的socket,即可达到单个线程处理多个IO请求的目的。而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。
但是,select()本身也存在不少问题:
- 每次调用select,都要把fd_set数组从用户态拷贝到内核态,开销较大。
- 每次在内核都要遍历传递进来的所有fd_set,当fd_set很大时,会导致效率变低。
- 系统内核对被监控的fd_set数组大小做了限制,并且是通过宏FD_SETSIZE控制的,大小通常限制为1024:
// 头文件中的定义
#ifndef FD_SETSIZE
#define FD_SETSIZE 1024
#endif
- 可以在头文件中修改 FD_SETSIZE 来改变select()使用的文件描述符数组的大小,但是必须重新编译内核才能使修改生效。
- select的触发方式是水平触发,应用程序如果没有完成对一个已经就绪的文件描述符的IO操作,那么之后每次select()时还是会将这些文件描述符通知进程。
三、poll()
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
typedef struct pollfd {
int fd; // 需要被检测或选择的文件描述符
short events; // 对文件描述符fd上感兴趣的事件
short revents; // 文件描述符fd上当前实际发生的事件
} pollfd_t;
poll函数参数说明:
-
pollfd *fds:pollfd类型的数组,指向一个结构体数组的第0个元素,用于存放需要检测状态的socket描述符,并且调用poll函数之后fds数组不会被清空。
-
nfds_t nfds:数组 fds 中描述符的总数量。
-
timeout:超时时间。
-
pollfd:表示一个被监视的文件描述符,通过传递fds指示 poll() 监视多个文件描述符。
-
events:指定监视fd的事件(输入、输出、错误),是监视该文件描述符的事件掩码,由用户来设置。
-
revents:文件描述符的操作结果事件掩码,内核在调用返回时设置。
总结: -
poll()的机制与select()类似,都是管理多个描述符进行轮询,根据描述符的状态进行处理,但是poll()没有最大文件描述符数量的限制,解决了第三个问题。而且它提供了更多的事件类型,对描述符的复用比select()高。
-
但是在执行过程中,包含大量文件描述符的数组依然会被整体从用户态复制到内核空间,而且内核也要遍历数组,对效率改善不大。
四、epoll()
在高并发场景下,对于select()和poll(),进程间上下文切换的时间消耗,以及内核/用户空间大量的内存拷贝、数组轮询等操作消耗,是系统难以承受的。因此,需要一种新的IO多路复用的方法来实现并发服务程序。
epoll()是基于事件驱动的I/O方式,是Linux内核为处理大批量文件描述符而作了改进的poll,其实现机制与select/poll机制完全不同。epoll()没有描述符个数限制,它使用一个文件描述符管理多个描述符,将用户关心的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的拷贝操作只需一次。
epoll()通过在内核中申请一个简易的文件系统,把原先的select/poll调用分成了3个操作部分。在Linux中,这三个部分对应的函数如下所示:
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:负责建立一个epoll对象,在epoll文件系统中为这个句柄对象分配资源。参数size表明内核要监听的描述符数量。
- epoll_ctl:负责向内核的epoll对象中添加要监听的事件类型(文件描述符),已添加的描述符被维护在一颗红黑树上。
- epoll_wait:负责收集已就绪事件的连接。
eventpoll:
当进程调用epoll_create()方法时,Linux内核会创建一个eventpoll结构体:
struct eventpoll{
...
// 红黑树的根节点,树中存储着所有添加到 epoll 中需要被监听的事件
struct rb_root rbr;
// 双链表,存放着通过 epoll_wait() 返回的就绪事件
struct list_head rdlist;
...
};
每个epoll对象都有一个独立的eventpoll,用于存放通过epoll_ctl()添加进来的事件。这些事件维护在红黑树中,红黑树的插入时间效率是log(n)(n为树的高度)。
此外,被监听的事件都会与设备驱动程序建立回调关系,每当有被监听的事件就绪,系统注册的回调函数就会被调用,将就绪事件放到rdList中,时间复杂度O(1)。
当调用epoll_wait()时,无须遍历整个被侦听的描述符集,只需要遍历eventpoll对象中的rdlist双链表中是否有epitem元素即可。然后把就绪事件复制到用户态,同时将事件数量返回给用户。
在epoll中,对于每一个事件,都会建立一个epitem结构体:
struct epitem{
struct rb_node rbn; // 红黑树节点
struct list_head rdllink; // 双向链表节点
struct epoll_filefd ffd; // 事件句柄信息
struct eventpoll *ep; // 指向所属的eventpoll对象
struct epoll_event event; // 期待发生的事件类型
}
epoll除了提供水平触发(Level Triggered)外,还提供了边缘触发(Edge Triggered),这使得用户空间程序有可能缓存IO状态,减少epoll_wait的调用,提高应用程序效率。
- 水平触发(LT):默认工作模式,当epoll_wait检测到某描述符事件就绪并通知应用进程时,应用进程可以不立即处理该事件,下次调用epoll_wait时,会再次通知进程。
- 边缘触发(ET): 当epoll_wait检测到某描述符事件就绪并通知应用进程时,应用进程必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次通知此事件。这减少了同一事件的触发次数,使效率更高。