select、poll、epoll都是IO多路复用的机制且本质上都是同步I/O。
I/O多路复用就是通过一种机制,可以同时监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。
一、select
可以同时监视多个套接字。用户传入3个文件描述符集合,分别是可读,可写,异常
代码语言:javascript复制#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
int select(int numfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, struct timeval *timeout);
// 返回值:就绪描述符的数目,超时返回0,出错返回-1
select的几大缺点:
(1)每次调用select的时候需要将一整个fd集合的大块内存从用户空间拷贝到内核中,这个开销在fd很多时会很大
(2)当有描述符的状态发生变化时,select并不知道是属于哪个流的,需要遍历传递进来的所有fd,那么每次轮询遍历的事件复杂度是O(n),这个开销在fd很多时也很大
(3)select最大可支持的描述符个数为1024个
(4)每次调用select前都要重新设置文件描述符集合和时间,因为内核会修改传入的参数数组
二、poll
poll技术与select技术实现逻辑基本一致,重要区别在于其使用链表的方式存储描述符fd,没有最大连接数的限制,但是对于select存在的性能问题并没有解决。
定义了struct pollfd 结构体,用掩码位指定应用程序对文件描述符 fd 感兴趣的事件,因此不会修改传入的参数数组
代码语言:javascript复制int poll(struct pollfd *fds, // pollfd结构体的数组
unsigned long nfds, // 数组中最大描述符个数
int timeout);// 超时时间
struct pollfd {
int fd; // fd索引值
short events; // 输入事件
short revents; // 结果输出事件
};
三、epoll
epoll提供了三个函数,epoll_create,epoll_ctl和epoll_wait,
epoll_create是创建一个epoll句柄;
epoll_ctl是注册要监听的事件类型;对文件描述符上事件的增删改操作
epoll_wait则是等待事件的产生。通过此调用收集在epoll监控中已经发生的事件。
struct epoll_event 结构体定义事件
代码语言:javascript复制// 创建保存epoll文件描述符的空间,该空间也称为“epoll例程”
int epoll_create(int flag); // 使用红黑树的数据结构
// epoll注册/修改/删除 fd的操作
long epoll_ctl(int epfd, // 上述epoll空间的fd索引值
int op, // 操作识别,EPOLL_CTL_ADD | EPOLL_CTL_MOD | EPOLL_CTL_DEL
int fd, // 注册的fd
struct epoll_event *event); // epoll监听事件的变化
struct epoll_event {
__poll_t events;
__u64 data;
} EPOLL_PACKED;
// epoll等待,与select/poll的逻辑一致
epoll_wait(int epfd, // epoll空间
struct epoll_event *events, // epoll监听事件的变化
int maxevents, // epoll可以保存的最大事件数
int timeout); // 超时时间
epoll的设计和实现与select完全不同。epoll通过在Linux内核中申请一个简易的文件系统来管理多个文件描述符。
当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,红黑树方便快速找到与文件描述符相关的epitem结构。
代码语言:javascript复制struct eventpoll{
....
/*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/
struct rb_root rbr;
/*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/
struct list_head rdlist;
....
};
用于存放通过epoll_ctl方法向epoll对象中添加进来的事件,这些事件都会挂载在红黑树中。而所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,给内核中断处理程序注册一个回调函数,当相应的事件发生时,就把它放到准备就绪链表里。
当调用epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否为空。如果rdlist不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户。
在epoll中,对于每一个事件,都会建立一个epitem结构体,如下所示:
代码语言:javascript复制struct epitem{
struct rb_node rbn;//红黑树节点
struct list_head rdllink;//双向链表节点
struct epoll_filefd ffd; //事件句柄信息
struct eventpoll *ep; //指向其所属的eventpoll对象
struct epoll_event event; //期待发生的事件类型
}
- 通过红黑树和双链表数据结构,并结合回调机制,造就了epoll的高效。
- 其次,epoll注册将拆分为ADD/MOD/DEL三个操作,分别只对相应的操作进行处理,大大降低频繁调用的次数,相比select/poll机制,由原先高频率的注册等待转换为高频等待,低频注册的处理逻辑
- 效率提升,不是轮询的方式,即Epoll最大的优点就在于它只管“活跃”的连接,只有活跃可用的FD才会调用callback函数;而跟连接总数无关,不会随着FD数目的增加效率下降。Epoll的效率就会远远高于select和poll。
- select,poll实现需要自己不断轮询所有fd集合,直到设备就绪,期间可能要睡眠和唤醒多次交替,epoll其实也需要调用epoll_wait不断轮询就绪链表,看是否为空,开销会小
- select,poll每次调用都要把fd集合从用户态往内核态拷贝一次,而epoll只要一次拷贝,epoll在内核中通过虚拟内存方式将内核空间与用户空间的一块地址同时映射到相同的物理内存地址中,这块内存对用户空间以及内核空间均为可见,因此可以减少用户空间与内核空间之间的数据拷贝
epoll技术的边缘触发与水平触发
- 水平触发
1) socket接收缓冲区不为空的时候,则一直触发读事件,相当于"不断地询问是否有数据可读",
2) socket发送缓冲区不全满的时候,则一直触发写事件,相当于"不断地询问是否有空闲区域可以让数据写入"
- 边缘触发
1) socket接收缓冲区发生变化,则触发读取事件,也就是当空的接收数据的socket缓冲区这个时候有数据传送过来的时候触发
2) socket发送缓冲区发生变化,则触发写入事件,也就是当满的发送数据的socket缓冲区这个时候刚刷新数据初期的时候触发 。
1) 水平触发:只要socket有数据可读,每次epoll_wait都会返回这个事件
2) 边缘触发:epoll_wait只会返回一次该事件,直到该描述符出现了下一次读写事件
为什么有边缘触发:不是所有的就绪文件描述符都需要读写的,这种模式比水平触发效率高,系统不会充斥大量你不关心的就绪文件描述符。
对比
select缺点:
- 最大并发数限制:使用32个整数的32位,即32*32=1024来标识fd,虽然可修改,但是有以下第二点的瓶颈;
- 效率低:每次都会线性扫描整个fd_set,集合越大速度越慢;
- 内核/用户空间内存拷贝问题。
epoll的提升:
- 本身没有最大并发连接的限制,仅受系统中进程能打开的最大文件数目限制;
- 效率提升:只有活跃的socket才会主动的去调用callback函数;
- 省去不必要的内存拷贝:epoll通过内核与用户空间mmap同一块内存实现。
当然,以上的优缺点仅仅是特定场景下的情况:高并发,且任一时间只有少数socket是活跃的。
如果在并发量低,socket都比较活跃的情况下,select就不见得比epoll慢了(就像我们常常说快排比插入排序快,但是在特定情况下这并不成立)。