一、select、poll、epoll
select、poll、epoll都是IO多路复用的机制
且本质上都是同步I/O
。
IO多路复用就是通过一种机制,可以同时监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知进行相应的读写操作。
1.1 select
int select (int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
select函数监视的文件描述符分3类,分别是writefds、readfds和expectfds。调用后select函数会阻塞,直到有描述符就绪(有数据可读、可写或者有except),或者超时(timeout指定等待时间,如果立即返回设置为null即可),函数返回。当select函数返回后,可以通过遍历fdset,来找到就绪的描述符。
select目前几乎在所有的平台上支持,其良好的跨平台支持也是它的一个优点。select的一个缺点在于单个进程能够监视的文件描述符的数量存在最大限制,在linux上一般为1024,可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但是这样也会造成效率降低。
select的缺点:
- 每次调用select的时候需要将一整个fd集合的大块内存从用户空间拷贝到内核中,这个开销在fd很多时会很大
- 当有描述符的状态发生变化时,select并不知道是属于哪个流的,需要遍历传递进来的所有fd,那么每次轮询遍历的时间复杂度是O(n),这个开销在fd很多时也很大
- select最大可支持的描述符个数为1024个
- 每次调用select前都要重新设置文件描述符集合和时间,因为内核会修改传入的参数数组
1.2 poll
poll技术与select技术实现逻辑基本一致,重要区别在于其使用链表的方式存储描述符fd,没有最大连接数的限制,但是对于select存在的性能问题并没有解决。
定义了struct pollfd结构体,用掩码位指定应用程序对文件描述符fd感兴趣的事件,因此不会修改传入的参数数组。
int poll(struct pollfd *fds, // pollfd结构体的数组
unsigned long nfds, // 数组中最大描述符个数
int timeout);// 超时时间
struct pollfd {
int fd; // fd索引值
short events; // 输入事件
short revents; // 结果输出事件
};
1.3 epoll
epoll是在2.6内核中提出的,是之前的select和poll的增强版本。相对于select和poll来说,epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。
epoll操作过程需要三个接口,分别为:
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);
int epoll_create(int size);
创建一个epoll句柄,size用来告诉内核这个监听的数目一共有多大,这个参数不同于select()中的第一个参数,给出最大监听的fd+1值,参数size并不是限制了epoll所能监听的描述符最大个数,只是对内核初始分配内部数据结构的一个建议。
当创建好epoll句柄后,它就会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
对指定描述符fd执行op操作。
-epfd:是epoll_create()的返回值。
-op:表示op操作,用三个宏来表示:添加EPOLL_CTL_ADD, 删除EPOLL_CTL_DEL, 修改EPOLL_CTL_MOD。分别添加、删除和修改对fd的监听事件。
-fd:是需要监听的fd(文件描述符)
-epoll_event:是告诉内核需要监听什么事,struct epoll_event结构如下:
struct epoll_event {
__uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
//events可以是以下几个宏的集合:
EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
int epoll_wait(int epfd, struct epoll_event*events, int maxevents, int timeout)
等待epfd上的io事件,最多返回maxevents个事件。
参数events用来从内核得到事件的集合,maxevents告知内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size,参数timeout是超过时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)。该函数返回需要处理的事件数目,如返回0表示已超时。
epoll的设计和实现与select完全不同。epoll通过在linux内核中申请一个简易的文件系统来管理多个文件描述符。
当某一个进程调用epoll_create方法时,linux内核会创建一个eventpoll结构体,红黑树方便快速找到与文件描述符相关的epitem结构。
struct eventpoll{
....
/*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/
struct rb_root rbr;
/*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/
struct list_head rdlist;
....
};
用于存放通过epoll_ctl方法向epoll对象中添加进来的事件,这些事件都会挂载在红黑树中。而所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,给内核中断处理程序注册一个回调函数,当相应的事件发生时,就把它放到准备就绪链表里。
当调用epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否为空。如果rdlist不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户。
epoll_create创建红黑树,epoll_ctl往红黑树添加事件,epoll_wait检查事件是否发生。
在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的高效。
- 其次,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的边缘触发与水平触发
epoll对文件描述的操作有两种模式:LT(level trigger)和ET(edge trigger)。LT模式是默认模式,LT模式与ET模式的区别如下:
- LT模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,
应用程序可以不立即处理该事件
。下次调用epoll_wait时,会再次响应应用程序并通知此事件。 - ET模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,
应用程序必须立即处理该事件
。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。
LT模式
LT是缺省的工作方式,并且同时支持block和no-block socket。在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的。
ET模式
ET是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,知道你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致了一个EWOULDBLOCK错误)。但是请注意,如果一直不对这个fd做IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)。
ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。
对比
select缺点:
- 最大并发数限制:使用32个整数的32位,即32*32=1024来标识fd,虽然可修改,但是有以下第二点的瓶颈:
- 效率低:每次都会线性扫描整个fd_set,集合越大速度越慢
- 内核/用户空间内存拷贝问题。
epoll的提升:
- 本身没有最大并发连接的限制,仅受系统中进程能打开的最大文件数目限制;
- 效率提升:只有活跃的socket才会主动的去调用callback函数;
- 省去不必要的内存拷贝:epoll通过内核与用户空间mmap同一块内存实现。
当然,以上的优缺点仅仅是特定场景下的情况:高并发,且任一时间只有少数socket是活跃的。
如果在并发量低,socket都比较活跃的情况下,select就不见得比epoll慢了(就像我们常常说快排比插入排序快,但是在特定情况下这并不成立)。