IO多路复用之select,poll,epoll总结


前篇: 网络编程之IO模型和IO多路复用

select, poll, epoll 在历史上是先后按顺序出现的,后者的提出都是为了解决前者遗留的问题。select是POSIX早期提出的规范,windows和linux等各大标准库都有实现。poll和epoll都是linux上的实现,其中poll和select差别很小, 在高并发和性能上有很大局限,而epoll则解决了大部分问题。

select

select原型函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
/* 
* maxfdp1: 最大的fd加1 (fd是一个整形)
* fd_set: 文件描述符fd的集合,通过FD_ZERO,FD_SET,FD_CLR,FD_ISSET等宏来控制
* return: int 若有就绪描述符返回其数目,若超时则为0,若出错则为-1
*/
int select (int maxfdp1, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

/////////////////////////////////////////////////
//相关接口:
void FD_ZERO (fd_set *fdset); // clear all bits in fdset
void FD_SET (int fd,fd_set *fdset); // turn on the bit for fd in fdset
void FD_CLR (int fd,fd_set *fdset); // turn off the bit for fd in fdset
int FD_ISSET(int fd,fd_set *fdset); // is the bit for fd on in fdset

select存在以下限制:

1 调用select函数会阻塞进程,直到有描述副就绪,或者超时
2 单个进程能够监视的文件描述符的数量存在最大限制,通常是1024;想要修改限制只能通过修改内核宏定义;
3 由于select采用轮询的方式扫描文件描述符,文件描述符数量越多,性能越差;
4 每次调用select,都需要把fd_set集合从用户态拷贝到内核态,如果fd_set集合很大,这个开销也很大;
5 select返回的是含有整个fd集合,应用程序需要遍历整个数组才能发现哪些句柄发生了事件;
6 select的触发方式是水平触发,应用程序如果没有完成对一个已经就绪的文件描述符进行IO操作,那么之后每次select调用还是会将这些文件描述符通知进程。

poll

poll 和 select 实现机制相似,只是内核用结构链表取代固定数组保存文件描述符集合,也就没有了最大文件描述数量的限制;但是select面临的其他问题,poll上依然存在。

poll原型函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* 
* fds: pollfd数组,文件描述及其监听事件的结构体集合
* nfds: pollfd数组的大小
* timeout: 超时时间,-1表示永久阻塞,0表示立即返回
* return: int 若有就绪描述符返回其数目,若超时则为0,若出错则为-1
*/
int poll (struct pollfd *fds, unsigned int nfds, int timeout);

/////////////////////////////////////////////////
//相关结构:
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events to watch */
short revents; /* returned events witnessed */
};

可以看到,poll对select参数传值(readfds, writefds)稍有改进,pollfd结构包含了要监视的event和发生的event。和select函数一样,poll返回后,依然需要轮询pollfd数组来获取就绪的描述符。

epoll

鉴于select和poll的缺陷,epoll采用了完全不同的事件驱动机制,上述select和poll存在的问题在epoll上不复存在。
epoll在Linux内核中建立一个简易的文件系统(文件系统一般用什么数据结构实现?B+树),用来管理epoll对象。epoll对象封装了对于网络IO的所有操作,这样用户对IO的操作就转变为对epoll对象的操作。

epoll对用户提供三个调用接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
/* 创建一个epoll对象
* size: 监听的描述符数量
* return: epoll句柄描述符 or -1 if failed
*/
int epoll_create(int size);

/* 向epoll对象添加/删除/修改fd描述符,以及要监听的事件类型
* epfd: epoll句柄描述符
* op: fd操作类型: EPOLL_CTL_ADD, EPOLL_CTL_MOD, EPOLL_CTL_DEL
* fd: 操作的fd描述符
* event: 注册/取消监听的事件
* return: 0 if success or -1 if failed
*/
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

/* 等待事件的就绪
* epfd: epoll句柄描述符
* events: 接收事件的数组
* maxevents: 事件数组的大小
* fd: 操作的fd描述符
* timeout: 超时时间,-1表示永久阻塞,0表示立即返回
* return: 就绪fd的数量 or -1 if failed
*/
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

/////////////////////////////////////////////////
// 相关结构
struct epoll_event {
__uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};

typedef union epoll_data {
void *ptr;
int fd;
__uint32_t u32;
__uint64_t u64;
} epoll_data_t;

//events可以是以下几个宏的集合:
EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里

简单来说,epoll的工作机制是这样的:

  • 当调用epoll_create时,内核会创建一个epoll对象,epoll对象通过红黑树管理监听事件,通过双向链表存放发生的事件。
  • 用户通过调用epoll_ctl向epoll对象注册/销毁监听事件;对于每一个添加到epoll对象中的事件,epoll都会向设备驱动程序注册回调(ep_poll_callback)。当驱动程序有事件发生时会调用该回调函数,它会将发生的事件添加到双向链表中。
  • 当用户调用epoll_wait时,内核只需要检查epoll对象中的双向链表是否有事件。如果事件不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户。(注意,epoll_wait依然可能造成阻塞。)

由此可见,epoll通过红黑树,双向链表等数据结构,以及事件回调机制,保证了其高效和应对并发的能力。

另外,epoll除了提供select/poll那种IO事件的水平触发(Level Triggered)外,还提供了边缘触发(Edge Triggered),这就使得用户空间程序有可能缓存IO状态,减少epoll_wait/epoll_pwait的调用,提高应用程序效率。

  • 水平触发(LT):默认工作模式,即当epoll_wait检测到某描述符事件就绪并通知应用程序时,应用程序可以不立即处理该事件;下次调用epoll_wait时,会再次通知此事件。

  • 边缘触发(ET): 当epoll_wait检测到某描述符事件就绪并通知应用程序时,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次通知此事件。(直到你做了某些操作导致该描述符变成未就绪状态了,也就是说边缘触发只在状态由未就绪变为就绪时只通知一次)。

总结

可以用以下表格对比来看select, poll, epoll的区别:

select poll epoll
操作方式 遍历 遍历 回调
底层实现 数组 链表 红黑树
IO效率 每次调用都进行线性遍历,时间复杂度为O(n) 每次调用都进行线性遍历,时间复杂度为O(n) 事件通知方式,每当fd就绪,系统注册的回调函数就会被调用,将就绪fd放到readyList里面,时间复杂度O(1)
最大连接数 1024(x86)或2048(x64) 无上限 无上限
fd拷贝 每次调用select,都需要把fd集合从用户态拷贝到内核态 每次调用poll,都需要把fd集合从用户态拷贝到内核态 调用epoll_ctl时拷贝进内核并保存,之后每次epoll_wait不拷贝

epoll是Linux目前大规模网络并发程序开发的首选模型。在绝大多数情况下性能远超select和poll。目前流行的高性能web服务器Nginx正式依赖于epoll提供的高效服务。但是,在并发连接不高的情况下,多线程+阻塞I/O方式可能性能更好。