IO多路转接之select、poll、epoll

目录

select

poll

epoll


IO分两步:<1> 等         <2> 数据拷贝

  • 高效IO:拷贝数据的比重越高 --> 大部分时间进行数据传输 --> IO越高效
  • 低效IO:等待的比重越高 -->大部分时间在阻塞等待-->IO越低效

 

五种IO模型:(钓鱼例子 【前四种为同步IO,第五种是异步IO】)

  • 阻塞IO: 在内核将数据准备好之前, 系统调用会⼀直等待,所有的套接字, 默认都是阻塞方式  (最常见的IO模型)
  • 非阻塞IO: 如果内核还未将数据准备好, 系统调用仍然会直接返回, 并且返回错误码 (常以轮询方式读写fd,浪费资源)
  • 信号驱动IO: 内核将数据准备好的时候, 使用SIGIO信号通知应用程序进行数据拷贝操作
  • IO多路转接: 与阻塞IO类似,实际上最核心在于IO多路转接能够同时等待多个文件描述符的就绪状态,有效减少等待时间
  • 异步IO: 由内核在数据拷贝完成时, 通知应用程序(而信号驱动是告诉应用程序何时可以开始拷贝数据)

 

同步通信和异步通信:

  • 同步:由调用者主动等待这个调用的结果
  • 异步:调用在发出之后,这个调用就直接返回了,所以没有返回结果

I/O多路转接 也叫做 I/O多路复用:

IO多路复用是指内核一旦发现进程指定的一个或者多个IO条件准备读取,它就通知该进程。

IO多路复用适用如下场合:

  • (1)当客户处理多个描述字时(一般是交互式输入和网络套接口),必须使用I/O复用。
  • (2)当一个客户同时处理多个套接口时,而这种情况是可能的,但很少出现。
  • (3)如果一个TCP服务器既要处理监听套接口,又要处理已连接套接口,一般也要用到I/O复用。
  • (4)如果一个服务器即要处理TCP,又要处理UDP,一般要使用I/O复用。
  • (5)如果一个服务器要处理多个服务或多个协议,一般要使用I/O复用。

  与多进程和多线程技术相比,I/O多路复用技术的最大优势是系统开销小,系统不必创建进程/线程,也不必维护这些进程/线程,从而大大减小了系统的开销。


接下来我们来了解I/O多路转接下的三种方式:

select

一、函数原型:

        select只负责等,用户将需要监听的文件描述符集合通知给select,当有一个或多个文件描述符就位的时候,它就返回已就绪的文件描述符的数目。

#include 
#include 

int select(int maxfdp1,fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval *timeout)
返回值:就绪描述符的数目,超时返回0,出错返回-1


maxfdp1:一般设为需要监视的三组fd_set中所含的最大fd值+1(如在readset, writeset, exceptset中所含最大的fd为5,则nfds=6,因为fd是从0开始的)

readset、writeset、exceptset:分别对应于需要检测的可读⽂件描述符的集合,可写⽂件描述符的集合及异常⽂件描述符的集合;如果对某一个的条件不感兴趣,就可以把它设为空指针。
struct fd_set可以理解为一个位图,这个集合中存放的是文件描述符,※※※既是输入型参数,又是输出型参数,用来传递信息,可通过以下四个宏进行设置:
          void FD_ZERO(fd_set *fdset);          //清空描述词组set的全部位

          void FD_SET(int fd, fd_set *fdset);   //设置描述词组set中相关fd的位

          void FD_CLR(int fd, fd_set *fdset);   //清除描述词组set中相关fd的位

          int FD_ISSET(int fd, fd_set *fdset);  //测试描述词组set中相关fd的位是否为真

timeout:告知内核等待所指定描述字中的任何一个就绪可花多少时间。其timeval结构⽤来设置select()的等待时间,该参数有三个返回值:
         执⾏成功则返回⽂件描述词状态已改变的个数
         如果返回0代表在描述词状态改变前已超过timeout时间,没有返回
         当有错误发⽣时则返回-1,错误原因存于errno,此时参数readfds,writefds, exceptfds和timeout的值变成不可预测。

二、理解select的执行过程:

        理解select模型的关键在于理解fd_set,为说明⽅便,取fd_set⻓度为1字节(8bite),fd_set中的每⼀bit可以对应⼀个文件描述符fd。则1字节长的fd_set最大可以对应8个fd.  

  • (1)执行fd_set set:FDZERO(&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(fdset)的值, 若服务器上sizeof(fdset)=128,每一个bit表示⼀个文件描述符,则服务器上支持的最大文件描述符是128*8=1024

<2>将fd加入select监控集的同时,还要再使用⼀个数组array保存放到select监控集中的fd. ⼀是用于在select 返回后,array作为源数据和fdset进行FDISSET判断。 ⼆是select返回后会把以前加入的但并无事件发生的fd清空(参考上文执行过程),则每次开始select前都要重新从array取得fd逐⼀加入(FD_ZERO最先),扫描array的同时取得fd最大值maxfd,用于select的第⼀个参数。     ( fd_set的大小可以调整,涉及到重新编译内核) 

四、select的缺点:(优点:可以同时处理多个请求 , 效率相对而言高)

  • 每次调用select, 都需要手动设置fd_set集合, 从接口使用角度来说也非常不便.
  • 每次调用select,都需要把fd_set集合从用户态拷贝到内核态,这个开销在fd很多时会很大.
  • 每次调用select都需要在内核(轮询)遍历传递进来的所有fd,这个开销在fd很多时也很大. 
  • select支持的文件描述符数量太小,原因:fd_set决定了select同时管理的链接数是有上限的(通常是128*8=1024).

适用场景:拥有大量连接但是只有少量连接是活跃的。


poll

        poll的机制与select类似,与select在本质上没有多大差别,管理多个描述符也是进行轮询,根据描述符的状态进行处理,但是poll没有最大文件描述符数量的限制poll和select同样存在一个缺点就是,包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增大。

一、函数原型:

   调用时用户告诉系统关心fd描述符的events事件,返回时系统会告诉用户该事件已就绪。

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

//fds是⼀个poll函数监听的结构列表. 每⼀个元素中, 包含了三部分内容: ⽂件描述符, 监听的事件集
//合, 返回的事件集合.

//nfds表⽰fds数组的⻓度.

//timeout表⽰poll函数的超时时间, 单位是毫秒(ms)

struct pollfd
{
  int fd;             /* 文件描述符 */
  short events;       /* 等待的事件 */
  short revents;      /* 实际发生了的事件 */
} ; 

/*
返回值⼩于0, 表⽰出错;
返回值等于0, 表⽰poll函数等待超时;
返回值⼤于0, 表⽰poll由于监听的⽂件描述符就绪⽽返回.
*/

/*
events和revents的取值:

 事件                描述                            
POLLIN         有数据可读。
POLLRDNORM        有普通数据可读。
POLLRDBAND        有优先数据可读。
POLLPRI        有紧迫数据可读。
POLLOUT          写数据不会导致阻塞。
POLLWRNORM       写普通数据不会导致阻塞。
POLLWRBAND        写优先数据不会导致阻塞。
POLLMSGSIGPOLL    消息可用。
POLLER             指定的文件描述符发生错误。
POLLHUP            指定的文件描述符挂起事件。
POLLNVAL           指定的文件描述符非法。
*/

二、poll的优点:

  • 不同与select使用三个位图来表示三个fdset的⽅式,poll使用⼀个pollfd的指针实现.
  • pollfd结构包含了要监视的event和发生的event,将输入、输出的参数分开,不用每次对参数进行重新设置。不再使用select“参数-值”传递的方式. 接口使用比select更方便.
  • poll并没有最大文件描述符数量限制 (但是数量过大后性能也是会下降).     /*与select最大的区别*/

三、poll的缺点:

poll中监听的文件描述符数目增多时 :

  • 和select函数⼀样,poll返回后,需要轮询pollfd来获取就绪的描述符(revents)
  • 每次调用poll都需要把⼤量的pollfd结构从用户态拷贝到内核中.
  • 同时连接的⼤量客户端在⼀时刻可能只有很少的处于就绪状态, 因此随着监视的描述符数量的增长, 其效率也会线性下降.

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的句柄*/
int epoll_create(int size);  

//⾃从linux2.6.8之后,size参数是被忽略的.
//⽤完之后, 必须调⽤close()关闭,返回值epfd.


/*epoll的事件注册函数*/
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

//它不同于select()是在监听事件时告诉内核要监听什么类型的事件, ⽽是在这⾥先注册要监听的事件类型.
//第⼀个参数是epoll_create()的返回值(epoll的句柄).
//第⼆个参数表⽰动作,⽤三个宏来表示:
                    EPOLL_CTL_ADD :注册新的fd到epfd中;
                    EPOLL_CTL_MOD :修改已经注册的fd的监听事件;
                    EPOLL_CTL_DEL :从epfd中删除⼀个fd;
//第三个参数是需要监听的fd.
//第四个参数是告诉内核需要监听什么事:events可以是以下⼏个宏的集合:
                    EPOLLIN : 表⽰对应的⽂件描述符可以读 (包括对端SOCKET正常关闭);
                    EPOLLOUT:表⽰对应的⽂件描述符可以写;
                    EPOLLPRI:表⽰对应的⽂件描述符有紧急的数据可读(这⾥应该表⽰有带外数据到来);
                    EPOLLERR : 表⽰对应的⽂件描述符发⽣错误;
                    EPOLLHUP : 表⽰对应的⽂件描述符被挂断;
                    EPOLLET : 将EPOLL设为边缘触发(ET)模式, 这是相对于⽔平触发(LT)来说的.
                    EPOLLONESHOT:只监听⼀次事件, 当监听完这次事件之后, 如果还需要继续监听这个socket的话, 需要再次把这个socket加⼊到EPOLL队列⾥.


/*收集在epoll监控的事件中已经发送的事件*/
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

//参数events是分配好的epoll_event结构体数组.
//epoll将会把发⽣的事件赋值到events数组中 (events不可以是空指针,内核只负责把数据复制到这个events数组中,不会去帮助我们在⽤户态中分配内存).
//maxevents告知内核这个events有多⼤,这个maxevents的值不能⼤于创建epoll_create()时的size.
//参数timeout是超时时间 (毫秒,0会⽴即返回,-1是永久阻塞).
//如果函数调⽤成功,返回对应I/O上已准备好的⽂件描述符数目,如返回0表⽰已超时, 返回⼩于0表示函数失败.

epoll的使用过程:(共3步)

  •  epoll_create:创建了一个epoll模型(操作系统创建维护):就绪队列,红黑树,回调机制
  • epoll_ctl:将要监控的文件描述符进行注册;(注册进红黑树)
  • epoll_wait:等待文件描述符就绪(就绪队列),操作系统将已经OK的文件描述符返回给用户

二、工作原理:

  1. 当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体中有两个成员与epoll的使用方式密切相关。
  2. 每一个epoll对象都有一个独立的eventpoll结构体,用于存放通过epoll_ctl方法向epoll对象中添加进来的事件。这些事件都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效的识别出来(红黑树的插入时间效率是lgn,其中n为树的高度)。
  3. 而所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当相应的事件发生时会调用这个回调方法。这个回调方法在内核中叫ep_poll_callback,它会将发生的事件添加到rdlist双链表中。
  4. 当调用epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可。如果rdlist不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户

IO多路转接之select、poll、epoll_第1张图片

三、epoll的优点:

<1>文件描述符数目无上限: 通过epoll_ctl()来注册⼀个文件描述符, 内核中使用红黑树来管理所有需要监控的文件描述符.

<2>基于事件的就绪通知方式: ⼀旦被监听的某个⽂件描述符就绪, 内核会采用类似于callback的回调机制, 迅速激活这个文件描述符. 这样随着文件描述符数量的增加, 也不会影响判定就绪的性能;

<3>维护就绪队列: 当文件描述符就绪, 就会被放到内核中的⼀个就绪队列中. 这样调用epoll_wait获取就绪文件描述符的时候, 只要取队列中的元素即可, 操作的时间复杂度是O(1);

<4>内存映射机制: 内核直接将就绪队列通过mmap的方式映射到用户态. 避免了拷贝内存这样的额外性能开销. 

epoll既然是对select和poll的改进,就应该能避免select的三个缺点:

  • 每次调用用select,都需要把fd_set集合从用户态拷贝到内核态,这个开销在fd很多时会很大.
  • 每次调用select都需要在内核(轮询)遍历传递进来的所有fd,这个开销在fd很多时也很大. 
  • select支持的文件描述符数量太小,原因:fd_set决定了select同时管理的链接数是有上限的(通常是128*8=1024).

那epoll都是怎么解决的呢?

  • 对于第一个缺点,epoll的解决方案在epoll_ctl函数中。每次注册新的事件到epoll句柄中时(在epoll_ctl中指定EPOLL_CTL_ADD),会把所有的fd拷贝进内核,而不是在epoll_wait的时候重复拷贝。epoll保证了每个fd在整个过程中只会拷贝一次。
  • 对于第二个缺点,epoll的解决方案不像select或poll一样每次都把current轮流加入fd对应的设备等待队列中,而只在epoll_ctl时把current挂一遍(这一遍必不可少)并为每个fd指定一个回调函数,当设备就绪,唤醒等待队列上的等待者时,就会调用这个回调函数,而这个回调函数会把就绪的fd加入一个就绪链表)。epoll_wait的工作实际上就是在这个就绪链表中查看有没有就绪的fd(利用schedule_timeout()实现睡一会,判断一会的效果)
  • 对于第三个缺点,epoll没有这个限制,它所支持的fd上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。

总结:

(1)select,poll实现需要自己不断轮询所有fd集合,直到设备就绪,期间可能要睡眠和唤醒多次交替。而epoll其实也需要调用epoll_wait不断轮询就绪链表,期间也可能多次睡眠和唤醒交替,但是它是设备就绪时,调用回调函数,把就绪fd放入就绪链表中,并唤醒在epoll_wait中进入睡眠的进程。虽然都要睡眠和交替,但是select和poll在“醒着”的时候要遍历整个fd集合,而epoll在“醒着”的时候只要判断一下就绪链表是否为空就行了,这节省了大量的CPU时间。这就是回调机制带来的性能提升。

(2)select,poll每次调用都要把fd集合从用户态往内核态拷贝一次,并且要把current往设备等待队列中挂一次,而epoll只要一次拷贝,而且把current往等待队列上挂也只挂一次(在epoll_wait的开始,注意这里的等待队列并不是设备等待队列,只是一个epoll内部定义的等待队列)。这也能节省不少的开销。

select是不断轮询去监听的socket,socket个数有限制,一般为1024个;

poll还是采用轮询方式监听,只不过没有个数限制;

epoll并不是采用轮询方式去监听了,而是当socket有变化时通过回调的方式主动告知用户进程

 

 

四、epoll工作方式

epoll有2种工作方式:select和poll其实也是工作在LT模式下. epoll既可以支持LT, 也可以支持ET.

  • 水平触发(LT) :LT模式下,直到缓冲区上的所有数据都被处理完,epoll_wait才不会再次返回。(epoll默认模式)
  • 边缘触发(ET):ET模式下, 文件描述符上的事件就绪后, 只有⼀次处理机会,否则就算数据未读完第二次也不会再返回。

总结:

LT:读数据时只要没读完每一次操作系统EPOLL模型都会将该文件描述符对应的读事件就绪添加到就绪队列(不用一次读完) 

ET:只有数据到来时或增多时才会将事件添加到就绪队列,所以必须一次读完  【从无到有,有到多】 

ET模式:循环读 + 非阻塞接口

  • 循环读:ET模式下数据就绪只会通知⼀次,也就是说,如果要使用ET模式,当数据就绪时,需要⼀直read,直到出错或完成为止,因此epoll_wait返回的次数少了很多,所以ET的性能比LT性能更高。
  • 非阻塞若当前fd为阻塞(默认),那么在当读完缓冲区的数据时,如果对端并没有关闭写端,那么该read函数会⼀直阻塞,影响其他fd以及后续逻辑. 所以此时将该fd设置为非阻塞,当没有数据的时候,read虽然读取不到任何内容,但是肯定不会被hang住, 那么此时,说明缓冲区数据已经读取完毕,需要继续处理后续逻辑(读取其他fd或者进⼊wait)

五、epoll的使用场景

         对于多连接, 且多连接中只有⼀部分连接比较活跃时, 比较适合使用epoll.

        例如, 典型的⼀个需要处理上万个客户端的服务器, 例如各种互联网APP的入口服务器, 这样的服务器就很适合epoll.

        如果只是系统内部, 服务器和服务器之间进行通信, 只有少数的几个连接, 这种情况下用epoll就并不合适

 

六、epoll的惊群问题

       有一个单进程的linux epoll服务器程序,在服务高峰期间并发的网络请求非常大,目前的单进程版本的支撑不了:单进程时只有一个循环先后处理epoll_wait()到的事件,使得某些不幸排队靠后的socket fd的网络事件得不到及时处理;所以希望将它改写成多进程版本:

  • 主进程先监听端口: listen_fd = socket(...)  
  • 创建epoll,epoll_fd = epoll_create(...);
  • 开始fork(),每个子进程进入大循环,去等待新的accept,epoll_wait(...),处理事件等。

       接着就遇到了“惊群”现象:当listen_fd有新的accept()请求过来,操作系统会唤醒所有子进程(因为这些进程都epoll_wait()同 一个listen_fd,操作系统又无从判断由谁来负责accept,索性干脆全部叫醒……),但最终只会有一个进程成功accept,其他进程 accept失败。外国IT友人认为所有子进程都是被“吓醒”的,所以称之为Thundering Herd(惊群)。
        打个比方,街边有一家麦当劳餐厅,里面有4个服务小窗口,每个窗口各有一名服务员。当大门口进来一位新客人,“欢迎光临!”餐厅大门的感应式门铃自动响了 (相当于操作系统底层捕抓到了一个网络事件),于是4个服务员都抬起头(相当于操作系统唤醒了所有服务进程)希望将客人招呼过去自己所在的服务窗口。但结 果可想而知,客人最终只会走向其中某一个窗口,而其他3个窗口的服务员只能“失望叹息”(这一声无奈的叹息就相当于accept()返回EAGAIN错误),然后埋头继续忙自己的事去。 
这样子“惊群”现象必然造成资源浪费,那有没有好的解决办法呢?

        lighttpd的解决思路:无视惊群。采用Watcher/Workers模式,具体措施有优化fork()与epoll_create()的位置(让每个子进程自己去 epoll_create()和epoll_wait()),捕获accept()抛出来的错误并忽视等。这样子一来,当有新accept时仍将有多个 lighttpd子进程被唤醒。

你可能感兴趣的:(计算机网络基础)