【网络】高级I/O多路复用之select、poll和epoll

Unix下I/O模型有五种

1、阻塞I/O

      应用程序调用一个IO函数,导致应用程序阻塞,等待数据准备好。

      如果数据没有准备好,一直等待。数据准备好了,从内核拷贝到用户空间,IO函数返回成功指示。

2、非阻塞I/O

      我们把一个套接口设置为非阻塞就是告诉内核,当所请求的I/O操作无法完成时,不要将进程睡眠,而是返回一个错误。这样我们的I/O操作函数将不断的测试数据是否已经准备好,如果没有准备好,继续测试,直到数据准备好为止。在这个不断测试的过程中,会大量的占用CPU的时间。

3、信号驱动I/O

       首先我们允许套接口进行信号驱动I/O,并安装一个信号处理函数,进程继续运行并不阻塞。当数据准备好时,进程会收到一个SIGIO信号,可以在信号处理函数中调用I/O操作函数处理数据。

4、I/O复用

       I/O复用模型会用到select或者poll函数,这两个函数也会使进程阻塞,但是和阻塞I/O所不同的的,这两个函数可以同时阻塞多个I/O操作。而且可以同时对多个读操作,多个写操作的I/O函数进行检测,直到有数据可读或可写时,才真正调用I/O操作函数。

5、异步I/O

      调用aio_read函数,告诉内核描述字,缓冲区指针,缓冲区大小,文件偏移以及通知的方式,然后立即返回。当内核将数据拷贝到缓冲区后,再通知应用程序。

总结:

I/O请求分两步:

1)进行等待:等待读或者写事件准备就绪。

2)进行数据搬迁:将数据从存储介质拷贝到内存缓冲区,此时数据已经准备好了,可以被用户应用程序进行读写,进行用户应用程序拷贝内核缓冲区中的数据到用户缓冲区。

      前四种I/O模型都是同步I/O,即自己进行数据的等待和搬迁,只是等待的方式不同。异步I/O指自己不参与等待和数据搬迁,将任务交给其他人去处理。

       系统提供了select、poll和epoll函数来实现多路复用输入/输出模型,但它们各有不同,其中epoll最为强大。下面依次进行说明,最后说明其各自的优缺点。

1、select

      select系统调用是用来让我们的程序监视多个文件句柄的状态变化的。程序会在select这里进行等待,直到被监视的文件句柄有一个或多个发生了状态变化(数据从无到有)。关于文件句柄就是一个整数,我们熟悉的句柄有0,1,2三个,0位标准输入,1为标准输出,2为标准错误。对应的FILE*结构的表示就是stdin、stdout和stderr。

函数原型如下:


参数:

-------nfds:需要监视的最大文件描述符值+1

-------readfds、writefds和exceptfds:分别对应需要检测的可读文件描述符的集合,可写文件描述符的集合及异常文件描述符的集合。

-------timeout:struct timeval结构用于描述一段时间长度。

参数timeout为结构timeval,用来设置select()的等待时间,其结构定义如下:(microseconds毫秒数)

1、timeout为特定值:表示如果在这个时间内,需要监视的描述符没有事件发生则函数返回,select会超时返回0。

2、timeout为NULL:表示select()没有timeout,select会一直阻塞,直到某个文件描述符上发生了事件。

3、timeout为0:表示仅检测到了描述符集合的状态,然后立即返回,并不等待事件的发生。


fd_set是一组文件描述字(fd)的集合,它用一位来表示一个fd,下面的宏提供了处理这三种描述词组(readfds、writefds和exceptfds)的方式:
FD_SET(int fd,fd_set* set);用来设置描述词组set中相关fd 的位
FD_ISSET(int fd,fd_set *set);用来测试描述词组set中相关fd 的位是否为真
FD_CLE
(int fd,fd_set*set);用来清除描述词组set中相关fd的位

FD_ZERO(fd_set *set);用来清除描述词组set的全部位

过去,一个fd_set通常只能包含<32的fd(文件描述字),因为fd_set其实只用了一个32位矢量来表示fd;现在,UNIX系统通常会在头文件中定义常量FD_SETSIZE,它是数据类型fd_set的描述字数量,其值通常是1024,这样就能表示<1024的fd。


函数返回值:执行成功则返回文件描述符状态已改变的个数。如果返回0标志在描述符改变前已超时timeout时间,没有返回。当有错误发生时返回-1,错误原因存在于error,此时参数readfds,writefds,exceptfds和timeout的值变成不可预测。

错误值可能为:EBADF 文件描述词为无效的或该文件已关闭;EINTR 此调用被信号所中断;EINVAL 参数n 为负值;ENOMEM 核心内存不足。

注意:

函数中:创建所关心时间的文件描述符集fd_set,设置readfds/writefds/exceptfds表示读/写/错误描述符,说明只关心该集合中的读/写/错误事件。

返回:读或写事件中哪些文件描述符已经准备就绪了


理解select模型很重要:
      关键在于理解fd_set,为说明方便,取fd_set长度为1字节,fd_set中的每一bit可以对应一个文件描述符fd。则1字节长的fd_set最大可以对应8个fd。
    (1)执行fd_set set; FD_ZERO(&set);则set用位表示是0000,0000。
    (2)若fd=5,执行FD_SET(fd,&set);后set变为0001,0000(第5位置为1)
    (3)若再加入fd=2,fd=1,则set变为0001,0011
    (4)执行select(6,&set,0,0,0)阻塞等待
    (5)若fd=1,fd=2上都发生可读事件,则select返回,此时set变为0000,0011。注意:没有事件发生的fd=5被清空。


基于上面的讨论,可以轻松得出select模型的特点:
1、可监控的文件描述符个数取决与sizeof(fd_set)的值。本人服务器上sizeof(fd_set)=512,每bit表示一个文件描述符,服务器上支持的最大文件描述符
4096(512*8)。         据说可调,另有说虽然可调,但调整上限受于编译内核时的变量值。

2、将fd加入select监控集的同时,还要使用一个数据结构array保存放到select监控集中的fd。一是用于在select返回后,array作为源数据和fd_set进行FD_ISSET判断。二是          select返回后会把以前加入的但并无事件发生的fd清空,则每次开始select前都要重新从array取得fd逐一加入(FD_ZERO最先),扫描array的同时取得fd最大值maxfd,用      于select的第一个参数。

3、可见select模型必须在select前循环array(加fd,取maxfd),select返回后循环array(FD_ISSET判断是否有时间发生)。

下面编写select服务器,如下所示:
                                                                                       

运行结果如下:


 

select的优点:

       支持阻塞操作的设备驱动通常会实现一组自身的等待队列如读/写等待队列用于支持上层(用户层)所需的BLOCK或NONBLOCK操作。当应用程序通过设备驱动访问该设备时(默认为BLOCK操作),若该设备当前没有数据可读或写,则将该用户进程插入到该设备驱动对应的读/写等待队列让其睡眠一段时间,等到有数据可读/写时再将该进程唤醒。         select就是巧妙的利用等待队列机制让用户进程适当在没有资源可读/写时睡眠,有资源可读/写时唤醒。


select的缺点:
1、每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd大多数会很大。

2、同时调用select都需要在内核遍历传递进来的所有发fd,判断是不是我所关系的事件,这个开销在发的大多数也很大。

3、select支持·的文件描述符数量太小,默认为1024.


2、poll

不同于select使用三个位图来表示三个fd_set的方式,poll使用一个pollfd的指针实现。poll函数原型如下:


2.1 、函数说明:该函数允许进程指示内核等待多个事件中任何一个发生,并只在有一个或多个事件发生的时候才唤醒。

2.2、参数说明:

------fds:是一个struct pollfd结构体类型的数组,用于存放需要检测其状态的socket描述符。

------nfds:用于标记fds中的结构体元素的总数量

------timeout:poll函数的阻塞时间,单位是毫秒。-----注意区别select的timeout,两者类型不同

 timeout  ==  0:poll立即返回不阻塞,

 timeout  > 0:阻塞的毫秒数

 timeout == -1:poll一直阻塞,直到检测到socket描述符上关心的事件发生才返回。

2.3、返回值:

        大于0表示数组fds中准备就绪的socket描述符总量;等于0:表示超时返回;等于-1表示出错返回。

      poll与select不同在于描述符存储方式不同和参数类型不同。 

1)、对于结构体数组的管理:pollfd结构包含了要监控的event和发生的event,不再使用select*“参数值”传递的方式。

       当每次有需要关心的文件描述符时,将其写入结构体中;每次有无效的描述符后,将其描述符置为-1,下次poll函数会忽略它。当新的描述符加入时,从头遍历结构体,将为-1的元素设为关心的描述符事件状态。注意:当新的描述符加到结构体数组末尾时要更新关系的描述符个数,即poll的第二个参数。

       每当调用这个函数后,系统不会清空这个数组,特别是对于socket 连接比较多的情况下,在一定程度上可以提高处理的效率。这一点与select()函数不同,在调用select之后,select()函数会清空它所检测的socket描述符集合,导致每次在调用select之后,select()函数会清空它所检测到的情况。

       pollfd并没有最大数量限制(但是数量过大后性能也会下降)。poll和select函数一样,poll返回后,需要轮询pollfd来获取就绪的描述符。从表面上看,select和poll都需要再返回后,通过遍历文件描述符来获取已经就绪的socket。事实上,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。

2)、每次调用poll后,结构体元素revents会存储就绪事件状态,当每次重新调用poll之前时,系统会自己设置其为0,重新监听关心事件(不需要用户重新置0)

3)、poll参数不是输入、输出型,因此timeout,fds参数不需要重置,nfds看情况(参照第一点),而select函数是输入输出类型,每次调用前重置。


实现IO复用:关心输入输出条件就绪:


运行结果如下:



poll的优点:

       poll()系统调用是System V的多元I/O解决方案。它有三个参数,第一个是pollfd结构的数组指针,也就是指向一组fd及其相关信息的指针,因为这个结构包含的除了fd,还有期待的事件掩码和返回的事件掩码,实质上就是将select的中的fd,传入和传出参数归到一个结构之下,也不再把fd分为三组,也不再硬性规定fd感兴趣的事件,这由调用者自己设定。这样,不使用位图来组织数据,也就不需要位图的全部遍历了。按照一般队列地遍历,每个fd做poll文件操作,检查返回的掩码是否有期待的事件,以及做是否有挂起和错误的必要性检查,如果有事件触发,就可以返回调用了。


poll的缺点:

       poll对于select来说包含了一个pollfd结构,pollfd结构包含了要监视的event和发生的revent,而不像select那样使用参数-值的传递方式。同时poll没有最大数量的限制。但是仍存在以下缺点
1、数量过大以后其效率也会线性下降。
2、poll和select一样需要遍历文件描述符来获取已经就绪的socket。当数量很大时,开销也就很大。


3、epoll

       poll和select的共同点,面对高并发多连接的应用情境,它们显现出原来没有考虑到的不足,虽然poll比起select又有所改进了。除了上述的关于每次调用都需要做一次从用户空间到内核空间的拷贝,还有这样的问题,就是当处于这样的应用情境时,poll和select会不得不多次操作,并且每次操作都很有可能需要多次进入睡眠状态,也就是多次全部轮询fd,我们应该怎么处理一些会出现重复而无意义的操作,故引进了epoll。epoll与poll本质区别不大,原理都是创建一个关注事件的描述符的集合,然后等待事件的发生,在轮询描述符集合,检查有没有事件的发生,如果有,就进行处理。

     select和poll处理的重复而无意义的操作有:

1、从用户到内核空间拷贝,既然长期监视这几个fd,甚至连期待的事件也不会改变,那拷贝无疑就是重复而无意义的,我们可以让内核长期保存所有需要监视的fd甚至期待事       件,或者可以在需要时对部分期待事件进行修改(MOD,ADD,DEL);

2、将当前线程轮流加入到每个fd对应设备的等待队列,这样做无非是哪一个设备就绪时能够通知进程退出调用,聪明的开发者想到,那就找个“代理”的回调函数,代替当前      进程加入fd的等待队列好了。这样,像poll系统调用一样,做poll文件操作发现尚未就绪时,它就调用传入的一个回调函数,这是epoll指定的回调函数,它不再像以前的          poll系统调用指定的回调函数那样,而是就将那个“代理”的回调函数加入设备的等待队列就好了,这个代理的回调函数就自己乖乖地等待设备就绪时将它唤醒,然后它就        把这个设备fd放到一个指定的地方,同时唤醒可能在等待的进程,到这个指定的地方取fd就好了(ET与LT)。


       我们把1和2结合起来就可以这样做了,只拷贝一次fd,一旦确定了fd就可以做poll文件操作,如果有事件当然好啦,马上就把fd放到指定的地方,而通常都是没有的,那就给这个fd的等待队列加一个回调函数,有事件就自动把fd放到指定的地方,当前进程不需要再一个个poll和睡眠等待了,这种做法就是epoll了。

 

       总结:epoll是linux特有的I/O复用函数,它能显著提高程序在大量并发连接中只有少量活跃情况下的系统cpu利用率并且epoll使用一组函数来完成任务,而不是单个函数,它无需遍历整个被监听的描述符集,只要遍历那些内核I/O时间异步唤醒而加入ready队列的描述符集即可。但epoll需要使用一个额外的文件描述符,来唯一标识内核中的这个事件表。epoll由三个系统调用组成,分别是epoll_create,epoll_ctl和epoll_wait。epoll_create用于创建和初始化一些内部使用的数据结构;epoll_ctl用于添加,删除或者修改指定的fd及其期待的事件;epoll_wait就是用于等待任何先前指定的fd事件。下面进行函数说明。


epoll_create函数:创建文件描述符


size参数现在并不起作用,只是给内核一个提示,告诉它事件表需要多大。该函数返回的文件描述符将作用其他所有epoll系统调用的第一个参数,以指定要访问的内核事件表。


epoll_ctl函数:用来操作内核事件表--增删改


epoll的事件注册函数,它不同于select函数是在监听事件时告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型。

参数:

-------epfd:要操作的事件表的文件描述符,即epoll_create()的返回值。

-------op:表示动作,指定要操作的类型,用三个宏来表示:

                 EPOLL_CTL_ADD:往事件表eptd中注册fd上的事件

                 EPOLL_CTL_MOD:修改已经注册的fd的监听事件

                 EPOLL_CTL_DEL:从epfd中删除一个fd

-------fd:需要监听的fd

-------event:告诉内核需要监听什么事。指定事件,它是epoll_event结构类型的指针


events成员描述事件类型,可以是以下几个宏的集合:
EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这⾥里应该表⽰示有带外数据到来);
EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(LevelTriggered)来说的。
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里。
epoll支持的事件类型和poll基本相同。表示epoll事件类型的宏是在poll对应的宏前加上“E”;data成员用于存储用户数据。

返回值:成功返回0,失败返回-1并设置error。



epoll_wait函数:在一段超时时间内等待一组文件描述符上的事件


返回值:该函数成功时返回就绪的文件描述符的个数,失败时返回-1并设置errno。

       epfd和events参数同create_ctl中的,timeout为等待的时间。epoll_wait函数如果检测到事件,就将所有就绪事件从内核事件表(由epfd参数指定)中复制到它的第二个参数events指向的数组中。这个数组只用于输出epoll_wait检测到的就绪事件。maxevents是告诉内核这个events参数有多大,这个maxevents的值不能小于创建epoll_create()时的size。


下面使用epoll,完成简单http消息回显,并使用浏览器测试,如下所示:




运行结果如下:

epoll的实现有两种模式,上面的程序是LT模式(epoll默认为LT模式
1、LT模式:从无到有一直“打电话”通知(level triggered)--水平触发。LT(level triggered)是epoll缺省的工作方式,并且同时支持block和no-block socket。        在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的。故相比ET      模式,数据不会丢失,epoll又称为大号的poll。
2、ET模式:只有从无到有时才打电话通知 (edge-triggered)--边缘触发。
    ET模式是高速工作方式,只支持no-block socket,它效率要比LT更高。ET与LT的区别在于,当一个新的事件到来时,ET模式下当然可以从epoll_wait调用中获取到这个事件,可是如果这次没有把这个事件对应的套接字缓冲区处理完,在这个套接字中没有新的事件再次到来时,在ET模式下是无法再次从epoll_wait调用中获取这个事件的。
     ET模式下数据可能会丢失。由于只触发一次,进行数据读取时,必须一次进行读取完所有数据(通知过来的数据),否则可能会丢数据。
     在epoll服务器下,文件描述必须为非阻塞的,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。最好以下面的方式调用ET模式的epoll接口。
     i 、基于非阻塞文件句柄
     ii 、只有当read(2)或者write(2)返回EAGAIN时才需要挂起,等待。但这并不是说每次read()时都需要循环读,直到读到产生一个EAGAIN才认为此次事件处理            完成,当read()返回的读到的数据长度小于请求的数据长度时,就可以确定此时缓冲中已没有数据了,也就可以认为此事读事件已处理完成。

***谈下epoll ET模式为何fd必须要设置为非阻塞这个问题
       ET边缘触发,数据就只会通知一次,也就是说,如果要使用ET模式,当数据就绪时,需要一直read,知道完成或出错为止。但倘若当前fd为阻塞的方式,那么当读完成缓冲区数据时,而对端并没有关闭写端,那么该read就会阻塞,影响其他fd以及他以后的逻辑,所以需要设置为非阻塞,当没有数据的时候,read必然读取不到数据,但是肯定不会阻塞,那么说明此时数据已经读取完毕,可以继续处理后续逻辑了(读取其他的fd或者进入wait)。 总的来说就是,ET模式下必须把数据读完,需要循环读数据,否则读到结尾会阻塞,故需要手动实现my_read;同理需要手动实现my_write。有兴趣的童鞋可以自己实现。


epoll往往比select和poll高效许多,下面主要说说epoll为什么高效。

epoll的优点:

优点一:支持一个进程打开大数目的socket描述符
       select 最不能忍受的是一个进程所打开的fd是有一定限制的,由FD_SETSIZE设置,默认值是2048。对于那些需要支持的上万连接数目的IM服务器来说显然太少了。这时候你一是可以选择修改这个宏然后重新编译内核,不过资料也同时指出这样会带来网络效率的下降,二是可以选择多进程的解决方案(传统的 Apache方案),不过虽然linux上面创建进程的代价比较小,但仍旧是不可忽视的,加上进程间数据同步远比不上线程间同步的高效,所以也不是一种完美的案。不过 epoll则没有这个限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远远于2048,举个例子,在1GB内存的机器上大约是10万左右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。

优点二:IO效率不随FD数目增加而线性下降
        传统的select/poll另一个致命弱点就是当你拥有一个很大的socket集合,不过由于网络延时,任一时间只有部分的socket是"活跃"的,但是select/poll每次调用都会线性扫描全部的集合,导致效率呈现线性下降。但是epoll不存在这个问题,它只会对"活跃"的socket进行操作---这是因为在内核实现中epoll是根据每个fd上面的callback函数实现的。那么,只有"活跃"的socket才会主动的去调用 callback函数,其他idle状态socket则不会,在这点上,epoll实现了一个"伪"AIO,因为这时候推动是在os内核。在一些 benchmark中,如果所有的socket基本上都是活跃的---比如一个高速LAN环境,epoll并不比select/poll有什么效率,相反,如果过多使用epoll_ctl,效率相比还有稍微的下降。但是一旦使用idle connections模拟WAN环境,epoll的效率就远在select/poll之上了

优点三:使用mmap加速内核与用户空间的消息传递
      这点实际上涉及到epoll的具体实现了。无论是select,poll还是epoll都需要内核把FD消息通知给用户空间,如何避免不必要的内存拷贝就很重要,在这点上,epoll是通过内核与用户空间mmap同一块内存实现的。而如果你想像我一样从2.5内核就关注epoll的话,一定不会忘记手工mmap这一步的。(mmap底层是使用红黑树加队列实现的,每次需要在操作的fd,先在红黑树中拿到,放到队列中,那么用户收到epoll_wait消息以后只需要看一下消息队列中有没有数据,有我就取走)

优点四:内核微调
        这一点其实不算epoll的优点了,而是整个linux平台的优点。也许你可以怀疑linux平台,但是你无法回避linux平台赋予你微调内核的能力。比如,内核TCP/IP协议栈使用内存池管理sk_buff结构,那么可以在运行时期动态调整这个内存pool(skb_head_pool)的大小-- 通过echoXXXX>/proc/sys/net/core/hot_list_length完成。再比如listen函数的第2个参数(TCP完成3次握手的数据包队列长度),也可以根据你平台内存大小动态调整。更甚至在一个数据包里面数据巨大但同时每个数据包本本身大小却很小的特殊系统上尝试最新的NAPI网卡驱动架构。

以上内容如果错误,请留言,多多指教。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值