您的位置:首页 > 理论基础 > 计算机网络

IO复用模式--select、poll、epoll详解

2018-01-29 11:33 525 查看
     IO模式,从大类上可以分为同步IO和异步IO,同步和异步的区别主要是针对IO事件的执行者,拿读数据来说,异步IO是内核等待内核缓存区数据就绪,然后由内核负责将数据拷贝到用户空间缓冲区,再发送实现完成信号,而同步IO是发送内核缓冲区数据就绪信号,将数据copy到用户缓冲区还是由应用程序进行io系统调用实现。同步IO又包括阻塞IO、非阻塞IO、IO多路复用和信号IO模式。其中,IO复用是现在实现高性能网络服务器的常用手段,可以用其代替一连接-一线程的服务器编程模式。

    在Linux 内核2.6之前,采用select和poll方式来实现IO复用,在2.6引入了高性能的epoll实现方式。另外,Java NIO的实现底层也是基于epoll的,高性能的服务器网络编程框架/NIO框架netty、mina也是在nio的基础上做了进一步的包装,解决了Java NIO诸如epoll bug空轮询等bug,netty等高性能网络框架也一般作为网络服务器、RPC、hadoop的底层通信框架。为了更好的理解,下边对select、poll和epoll做一些基本学习。

IO复用模式

当用户调用select时,会阻塞在select调用上,同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。

所以,I/O
多路复用的特点是通过一种机制一个进程能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个进入读就绪状态,select()函数就可以返回。




这个图和blocking IO的图其实并没有太大的不同,事实上,还更差一些。因为这里需要使用两个system call (select 和 recvfrom),而blocking IO只调用了一个system call (recvfrom)。但是,用select的优势在于它可以同时处理多个connection。

所以,如果处理的连接数不是很高的话,使用select/epoll的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延迟还更大。select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。)

在IO multiplexing Model中,实际中,对于每一个socket,一般都设置成为non-blocking,但是,如上图所示,整个用户的process其实是一直被block的。只不过process是被select这个函数block,而不是被socket IO给block
select,poll,epoll都是IO多路复用的机制。I/O多路复用就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的

select、poll实现

select函数为:

int select (int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

select 函数监视的文件描述符分3类,分别是writefds、readfds、和exceptfds。调用后select函数会阻塞,直到有描述副就绪(有数据
可读、可写、或者有except),或者超时(timeout指定等待时间,如果立即返回设为null即可),函数返回。当select函数返回后,可以 通过遍历fdset,来找到就绪的描述符。
 
  select的一 个缺点在于单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024,可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但 是这样也会造成效率的降低。要注意:select的maxfd=监听的最大fd+1。注意,select可以监听的文件描述符fd为1024,并不是指它支持的并发量只能在1024以下,这里的1024是指某一时刻同时监听的个数,当处理完socket读写时间后,会关闭其fd,然后加入新的监听fd。

  poll的机制与select类似,与select在本质上没有多大差别,管理多个描述符也是进行轮询,根据描述符的状态进行处理,但是poll没有最大文件描述符数量的限制。

int poll (struct pollfd *fds, unsigned int nfds, int timeout);

不同与select使用三个位图来表示三个fdset的方式,poll使用一个
pollfd的指针实现。
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events to watch */
short revents; /* returned events witnessed */
};

pollfd结构包含了要监视的event和发生的event,不再使用select“参数-值”传递的方式。

select的缺点:

(1)单个进程能够监视的文件描述符的数量有限制,通常为1024,可以通过重新编译内核改变,但是由于select和poll在内核中都是通过轮询的方式扫描文件描述符来判断事件发生的,文件描述符越多,性能越差。

(2)内核/用户空间的内存拷贝问题。每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大。

(3)当监视的文件描述符上有事件发生时,select返回的是含有整个fd的数组,应用程序需要遍历整个fdset才能发现哪些fd发生了事件。

(4)select的触发方式是水平触发(LE),应用程序如果没有完成对一个已经就绪的文件描述符进行IO操作,那么以后每次调用select调用时,还是会将这些文件描述符通知进程。

epoll的实现

epoll既然是对select和poll的改进,就应该能避免上述的三个缺点。那epoll都是怎么解决的呢?在此之前,我们先看一下epoll和select和poll的调用接口上的不同,select和poll都只提供了一个函数——select或者poll函数。而epoll提供了三个函数,epoll_create,epoll_ctl和epoll_wait,epoll_create是创建一个epoll句柄;epoll_ctl是注册要监听的事件类型;epoll_wait则是等待事件的产生epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次

int epoll_create(int size);//创建一个epoll的句柄,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);

(1) int
epoll_create(int size);
  创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大。这个参数不同于select()中的第一个参数,给出最大监听的fd+1的值。需要注意的是,当创建好epoll句柄后,它就是会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。
   epoll可以同时监听大量的fd,在内存为4G的情况下,可以达到10万多个文件描述符。
(2)int
epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
  epoll的事件注册函数,它不同与select()是在监听事件时告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型。第一个参数是epoll_create()的返回值,第二个参数op表示动作,用三个宏来表示:
EPOLL_CTL_ADD:注册新的fd到epfd中;
EPOLL_CTL_MOD:修改已经注册的fd的监听事件;
EPOLL_CTL_DEL:从epfd中删除一个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队列里
(3) int epoll_wait(int
epfd, struct epoll_event * events, int maxevents, int timeout);

等待事件的产生,类似于select()调用。参数events用来从内核得到事件的集合,maxevents告之内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size,参数timeout是超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)。该函数返回需要处理的事件数目,如返回0表示已超时。

注意:epoll在内核中维护了一个事件表(mmap的红黑树?),无需像Select那样,每次调用都需要重复的将文件描述符集合传入内核。当epoll_wait
检测到事件,那就将这些事件从事件表复制到第二个参数epoll_event * events中,返回这些就绪的事件,所以在用户空间中,只需遍历这些就绪事件即可,而不是像select那样还需要在用户空间遍历所有的fd,找出发生事件就绪的fd。

epoll的LT和ET的工作方式

epoll对文件描述符的操作有两种模式:LT(level
trigger)和ET(edge
trigger)。LT模式是默认模式,LT模式与ET模式的区别如下:


. LT模式

LT(level triggered)是缺省的工作方式,并且同时支持block和no-block socket.在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,下次调用epoll_wait时,内核还是会继续通知你的。


2. ET模式

ET(edge-triggered)是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知。如果不处理的话,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。

ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。

select、poll、epoll的对比

1. select/poll只有LE水平触发这一种工作模式,而epoll支持更高效的边沿触发工作模式。
2. select在内核中通过该轮询的方式判断是否有事件就绪O(n),而epoll是通过回调的方式判断,O(1),所以epoll的时间开销不会随着fd的增加而线性增加。
3. select可以进行同时监听的fd数量有限制,而epoll没有。
4. epoll当有就绪事件发生时,即epoll_wait返回时,只返回就绪列表中的就绪事件的fd,从事件表复制到epoll_wait第二个参数epoll_event
* events中,返回这些就绪的事件,而select返回所有的fd,还得通过遍历来得到就绪的事件。
5. select每次调用时都会存在内核/用户空间的fdset拷贝,产生巨大开销,而epoll不需要(mmap+事件表)。
6. select/poll是单一函数实现,epoll使用了三个函数。

eopll的高效原因

上边我们学习了epoll和select/poll的相关基础,并且比较了它们的区别,发现epoll性能高效,那我们需要思考,epoll高效的原因何在。
先总结一下:epoll通过mmap+红黑树+双链表+回调机制,造就了epoll 的高效。
    epoll是通过内核与用户空间mmap同一块内存实现的。mmap将用户空间的一块地址和内核空间的一块地址同时映射到相同的一块物理内存地址(不管是用户空间还是内核空间都是虚拟地址,最终要通过地址映射映射到物理地址),使得这块物理内存对内核和对用户均可见,减少用户态和内核态之间的数据交换。内核可以直接看到epoll监听的句柄,效率高。

  
红黑树将存储epoll所监听的套接字。上面mmap出来的内存如何保存epoll所监听的套接字,必然也得有一套数据结构,epoll在实现上采用红黑树去存储所有套接字,当添加或者删除一个套接字时(epoll_ctl),都在红黑树上去处理,红黑树本身插入和删除性能比较好,时间复杂度O(logN)。

    一个fd被添加到epoll中之后(调用epoll_ctl的epoll_ctl_add模式),内核会为其生成一个对应的epitem结构对象,然后epitem会被添加到红黑树中,红黑树的作用就是在添加、删除、修改fd时,能够快速实现。


 添加以及返回事件

      通过epoll_ctl函数添加进来的事件fd都会被放在红黑树的某个节点内,所以,重复添加是没有用的。

建立回调+回调的逻辑是将就绪事件fd加入到双向链表中,epoll_wait调用时只需判断链表是否为空即可。

当把事件fd添加进来的时候时候会完成关键的一步,那就是该事件(fd(socket+事件类型))都会与相应的设备(网卡)驱动程序建立回调关系,当相应的事件发生后(O(1)就可判断事件是否发生),就会调用这个回调函数,该回调函数在内核中被称为:ep_poll_callback,这个回调函数的逻辑就是就所把这个事件添加到rdllist这个双向链表中。一旦有事件发生,epoll就会将该事件添加到双向链表中。那么当我们调用epoll_wait时,epoll_wait只需要检查rdlist双向链表中是否有存在注册的事件,效率非常可观。这里也需要将链表中就绪的事件复制到用户态内存中即可。



  epoll_wait的工作流程:
epoll_wait调用ep_poll,当rdlist为空(无就绪fd)时挂起当前进程,直到rdlist不空时进程才被唤醒。
文件fd状态改变(buffer由不可读变为可读或由不可写变为可写),导致相应fd上的回调函数ep_poll_callback()被调用。
ep_poll_callback将相应fd对应epitem加入rdlist,导致rdlist不空,进程被唤醒,epoll_wait得以继续执行。
ep_events_transfer函数将rdlist中的epitem拷贝到txlist中,并将rdlist清空。
ep_send_events函数(很关键),它扫描txlist中的每个epitem,调用其关联fd对用的poll方法。此时对poll的调用仅仅是取得fd上较新的events(防止之前events被更新),之后将取得的events和相应的fd发送到用户空间(封装在struct epoll_event,从epoll_wait返回)。 
   

从上边的分析中,我们就可以看到,epoll为何如此高效。

参考:
NIO: http://blog.csdn.net/zmx729618/article/details/54584583 http://blog.csdn.net/scucscheng/article/details/51940552
https://www.cnblogs.com/lojunren/p/3856290.html http://www.cnblogs.com/Anker/p/3265058.html
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息