select、poll和epoll

一、多路复用模型

 二、select()、poll()、epoll()

  select,poll,epoll都是IO多路复用的机制。I/O多路复用就是通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。

  select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。  

  epoll跟select都能提供多路I/O复用的解决方案。在现在的Linux内核里有都能够支持,其中epoll是Linux所特有,而select则是POSIX所规定,一般操作系统均有实现。poll是Linux中的字符设备驱动中的一个函数,Linux 2.5.44版本后,poll被epoll取代,和select实现的功能差不多,poll的作用是把当前的文件指针挂到等待队列。

1、select()------时间复杂度O(n)

  1.1、select()简述

  select()函数仅仅知道有I/O事件发生了,却并不知道是哪几个流(可能有一个,多个,甚至全部),我们只能轮询所有流,找出能读出数据或者能写入数据的流,对他们进行操作。所以select具有O(n)的无差别轮询复杂度,同时处理的流越多,无差别轮询时间就越长。

  该函数准许进程指示内核等待多个事件中的任何一个发送,并只在有一个或多个事件发生或经历一段指定的时间后才唤醒。函数原型如下:

#include <sys/select.h>
#include <sys/time.h>

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

  函数参数介绍如下:

  (1)第一个参数maxfdp1指定待测试的描述字个数,它的值是待测试的最大描述字加1(因此把该参数命名为maxfdp1),描述字0、1、2...maxfdp1-1均将被测试。因为文件描述符是从0开始的。

  (2)中间的三个参数readset、writeset和exceptset指定我们要让内核测试读、写和异常条件的描述字。如果对某一个的条件不感兴趣,就可以把它设为空指针。struct fd_set可以理解为一个集合,这个集合中存放的是文件描述符,可通过以下四个宏进行设置:

void FD_ZERO(fd_set *fdset);          //清空集合
void FD_SET(int fd, fd_set *fdset);   //将一个给定的文件描述符加入集合之中
void FD_CLR(int fd, fd_set *fdset);   //将一个给定的文件描述符从集合中删除
int FD_ISSET(int fd, fd_set *fdset);  //检查集合中指定的文件描述符是否可以读写 

  (3)timeout告知内核等待所指定描述字中的任何一个就绪可花多少时间。其timeval结构用于指定这段时间的秒数和微秒数。

struct timeval
{
    long tv_sec;   //seconds
    long tv_usec;  //microseconds
};

这个参数有三种可能:

(1)永远等待下去:仅在有一个描述字准备好I/O时才返回。为此,把该参数设置为空指针NULL。

(2)等待一段固定时间:在有一个描述字准备好I/O时返回,但是不超过由该参数所指向的timeval结构中指定的秒数和微秒数。

(3)根本不等待:检查描述字后立即返回,这称为轮询。为此,该参数必须指向一个timeval结构,而且其中的定时器值必须为0。

 

  select的调用过程如下所示:

 

  (1)使用copy_from_user从用户空间拷贝fd_set到内核空间

  (2)注册回调函数__pollwait

  (3)遍历所有fd,调用其对应的poll方法(对于socket,这个poll方法是sock_poll,sock_poll根据情况会调用到tcp_poll,udp_poll或者datagram_poll)

  (4)以tcp_poll为例,其核心实现就是__pollwait,也就是上面注册的回调函数。

  (5)__pollwait的主要工作就是把current(当前进程)挂到设备的等待队列中,不同的设备有不同的等待队列,对于tcp_poll来说,其等待队列是sk->sk_sleep(注意把进程挂到等待队列中并不代表进程已经睡眠了)。在设备收到一条消息(网络设备)或填写完文件数据(磁盘设备)后,会唤醒设备等待队列上睡眠的进程,这时current便被唤醒了。

  (6)poll方法返回时会返回一个描述读写操作是否就绪的mask掩码,根据这个mask掩码给fd_set赋值。

  (7)如果遍历完所有的fd,还没有返回一个可读写的mask掩码,则会调用schedule_timeout是调用select的进程(也就是current)进入睡眠。当设备驱动发生自身资源可读写后,会唤醒其等待队列上睡眠的进程。如果超过一定的超时时间(schedule_timeout指定),还是没人唤醒,则调用select的进程会重新被唤醒获得CPU,进而重新遍历fd,判断有没有就绪的fd。

  (8)把fd_set从内核空间拷贝到用户空间。

  1.2、select的优缺点

  (1)、优点

  知道epoll函数后,可能有人对select函数失望,但大家应该掌握select函数,epoll只在Linux下支持,也就是说改进的I/O复用模型不具兼容性,而大部分操作系统都支持select函数。所以,select具有以下几个优点:

  • 服务端接入者少
  • 程序应具有兼容性
  • select的可移植性更好,目前几乎在所有的平台上支持,在某些Unix系统上不支持poll()和epoll;
  • select对于超时值提供了更好的精度:微秒,而poll是毫秒;

  (2)、缺点

  • 每次调用select函数时都要向内核传递监视对象信息,这意味着需要将用户态的fd集合列表从用户态拷贝到内核态,如果以万计的句柄会导致每次都要copy几十几百KB的内存到内核态,非常低效。
  • 每次调用select时内核都需要遍历(即轮询)传递进来的全部fd,浪费CPU时间;
  • select在单个进程中能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024,可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但 是这样也会造成效率的降低。      

  1.3、原理图

 

2、poll-------时间复杂度O(n) 

  2.1、poll简述

  poll是Linux中的字符设备驱动中的一个函数。Linux 2.5.44版本后,poll被epoll取代。和select实现的功能差不多,poll的作用是把当前的文件指针挂到等待队列。

  poll的实现和select非常相似,只是描述fd集合的方式不同,poll使用pollfd结构而不是select的fd_set结构,poll管理多个描述符也是进行轮询,根据描述符的状态进行处理,但是poll没有最大文件描述符数量的限制。它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,但是它没有最大连接数的限制,原因是它是基于链表来存储的。如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了屡次无谓的遍历。

  poll和select同样存在一个缺点就是,包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增大。

  它没有最大连接数的限制,原因是它是基于链表来存储的,但是同样有一个缺点:

  (1)大量的fd的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义。                   

  (2)poll还有一个特点是“水平触发”,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。

  函数格式如下所示:

# include <poll.h>
int poll(struct pollfd * fds, unsigned int nfds, int timeout);

  pollfd结构体定义如下:

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

   每一个pollfd结构体指定了一个被监视的文件描述符,可以传递多个结构体,指示poll()监视多个文件描述符。每个结构体的events域是监视该文件描述符的事件掩码,由用户来设置这个域。revents域是文件描述符的操作结果事件掩码,内核在调用返回时设置这个域。events域中请求的任何事件都可能在revents域中返回。合法的事件如下:

POLLIN         //有数据可读
POLLRDNORM        //有普通数据可读
POLLRDBAND       //有优先数据可读
POLLPRI        //有紧迫数据可读
POLLOUT         //写数据不会导致阻塞
POLLWRNORM       //写普通数据不会导致阻塞
POLLWRBAND       //写优先数据不会导致阻塞
POLLMSGSIGPOLL    //消息可用

  此外,revents域中还可能返回下列事件:

POLLER     //指定的文件描述符发生错误
POLLHUP    //指定的文件描述符挂起事件
POLLNVAL   //指定的文件描述符非法

  这些事件在events域中无意义,因为它们在合适的时候总是会从revents中返回。

  使用poll()和select()不一样,你不需要显式地请求异常情况报告。

  POLLIN | POLLPRI等价于select()的读事件,POLLOUT |POLLWRBAND等价于select()的写事件。POLLIN等价于POLLRDNORM |POLLRDBAND,而POLLOUT则等价于POLLWRNORM。例如,要同时监视一个文件描述符是否可读和可写,我们可以设置 events为POLLIN |POLLOUT。在poll返回时,我们可以检查revents中的标志,对应于文件描述符请求的events结构体。如果POLLIN事件被设置,则文件描述符可以被读取而不阻塞。如果POLLOUT被设置,则文件描述符可以写入而不导致阻塞。这些标志并不是互斥的:它们可能被同时设置,表示这个文件描述符的读取和写入操作都会正常返回而不阻塞。

  timeout参数指定等待的毫秒数,无论I/O是否准备好,poll都会返回。timeout指定为负数值表示无限超时,使poll()一直挂起直到一个指定事件发生;timeout为0指示poll调用立即返回并列出准备好I/O的文件描述符,但并不等待其它的事件。这种情况下,poll()就像它的名字那样,一旦选举出来,立即返回。

  返回值和错误代码
  成功时,poll()返回结构体中revents域不为0的文件描述符个数;如果在超时前没有任何事件发生,poll()返回0;失败时,poll()返回-1,并设置errno为下列值之一:

EBADF        // 一个或多个结构体中指定的文件描述符无效。
EFAULTfds   //指针指向的地址超出进程的地址空间。
EINTR      //请求的事件之前产生一个信号,调用可以重新发起。
EINVALnfds  //参数超出PLIMIT_NOFILE值。
ENOMEM      //可用内存不足,无法完成请求。

  2.2、poll优点和缺点

   (1)、优点

    poll() 不要求开发者计算最大文件描述符加一的大小;

    poll() 在应付大数目的文件描述符的时候速度更快,相比于select;

    它没有最大链接数的限制,缘由是它是基于链表来存储的;

  (2)、缺点

    大量的fd的数组被总体复制于用户态和内核地址空间之间,而无论这样的复制是否是有意义;

    与select同样,poll返回后,须要轮询pollfd来获取就绪的描述符。

3、epoll--------时间复杂度O(1)  

  3.1、epoll简述

  epoll是在2.6内核中提出的,是之前的select和poll的增强版本。相对于select和poll来说,epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。

  select复用方法由来已久,因此,利用该技术后,无论如何优化程序性能也无法同时接入上百个客户端。这种select方式并不适合以web服务端开发为主流的现代开发环境,所以要学习Linux平台下的epoll。

  调用select函数后,并不是把发生变化的文件描述符单独集中到一起,而是通过观察作为监视对象的fd_set变量的变化,找出发生变化的文件描述符,因此无法避免针对所有监视对象的循环语句。而且,作为监视对象的fd_set变量会发生变化,所以调用select函数前应复制并保存原有信息,并在每次调用select函数时传递新的监视对象;

  只看代码的话很容易认为是循环,但相比循环,更大的障碍是每次传递监视对象信息。因为传递监视对象信息的含义为:每次调用select函数时向操作系统传递监视对象信息。

  应用程序向操作系统传递数据将对程序造成很大负担,而且无法通过优化代码解决,因此成为性能上的致命弱点;

  那为何需要把监视对象信息传递给操作系统呢?有些函数不需要操作系统的帮助就能完成的功能,而有些则必须借助操作系统。假设各位定义了四则运算相关函数,此时无需操作系统的帮助。但select函数与文件描述符相关,更准确地说,是监视套接字变化的函数。而套接字是由操作系统管理的,所以select函数绝对需要借助于操作系统才能完成。select函数的这一缺点的弥补方式为:仅仅向操作系统传递一次监视对象,监视范围或内容发生变化时只通知发生变化的事项。

  这样就无需每次调用select函数时都向操作系统传递监视对象信息,但前提是操作系统支持这种处理方式(每种操作系统支持的程度和方式都存在差异)。Linux的支持方式是epoll,Windows的支持方式是IOCP。

  所以,实际上在你调用epoll_create后,内核就已经在内核态开始准备帮你存储要监控的句柄了,每次调用epoll_ctl只是在往内核的数据结构里塞入新的socket句柄。

  在内核里,一切皆文件。所以,epoll向内核注册了一个文件系统,用于存储上述的被监控socket。当你调用epoll_create时,就会在这个虚拟的epoll文件系统里创建一个file结点。当然这个file不是普通文件,它只服务于epoll。

  epoll在被内核初始化时(操作系统启动),同时会开辟出epoll自己的内核高速cache区,用于安置每一个我们想监控的socket,这些socket会以红黑树的形式保存在内核cache里,以支持快速的查找、插入、删除。这个内核高速cache区,就是建立连续的物理内存页,然后在之上建立slab层,简单的说,就是物理上分配好你想要的size的内存对象,每次使用时都是使用空闲的已分配好的对象。

   (1)epoll的高效

  epoll的高效就在于,当我们调用epoll_ctl往里塞入百万个句柄时,epoll_wait仍然可以飞快的返回,并有效的将发生事件的句柄给我们用户。这是由于我们在调用epoll_create时,内核除了帮我们在epoll文件系统里建了个file结点,在内核cache里建了个红黑树用于存储以后epoll_ctl传来的socket外,还会再建立一个list链表,用于存储准备就绪的事件,当epoll_wait调用时,仅仅观察这个list链表里有没有数据即可。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。所以,epoll_wait非常高效。

  而且,通常情况下即使我们要监控百万计的句柄,大多一次也只返回很少量的准备就绪句柄而已,所以,epoll_wait仅需要从内核态copy少量的句柄到用户态而已,如何能不高效。

  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的解决方案在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()实现睡一会,判断一会的效果,和select实现中的第7步是类似的)。

  对于第三个缺点,epoll没有这个限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。

  (2)就绪list链表维护 

  那么,这个准备就绪list链表是怎么维护的呢?当我们执行epoll_ctl时,除了把socket放到epoll文件系统里file对象对应的红黑树上之外,还会给内核中断处理程序注册一个回调函数,告诉内核,如果这个句柄的中断到了,就把它放到准备就绪list链表里。所以,当一个socket上有数据到了,内核在把网卡上的数据copy到内核中后就来把socket插入到准备就绪链表里了。

  如此,一颗红黑树,一张准备就绪句柄链表,少量的内核cache,就帮我们解决了大并发下的socket处理问题。执行epoll_create时,创建了红黑树和就绪链表,执行epoll_ctl时,如果增加socket句柄,则检查在红黑树中是否存在,存在立即返回,不存在则添加到树干上,然后向内核注册回调函数,用于当中断事件来临时向准备就绪链表中插入数据。执行epoll_wait时立刻返回准备就绪链表里的数据即可。

  3.2、epoll操作过程需要三个接口

#include <sys/epoll.h>
int epoll_create(int size);//创建保存epoll文件描述符的空间
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);//和select函数类似,等待文件描述符发生变化

  (1) int epoll_create(int size);

  select方式中为了保存监视对象文件描述符,直接声明了fd_set变量。但epoll方式下由操作系统负责保存监视对象文件描述符,因此需要向操作系统请求创建保存文件描述符的空间,此时使用的函数就是epoll_create。

  创建一个epoll的对象,这个对象包含了一个红黑树和一个双向链表。并与底层建立回调机制。 size用来告诉内核这个监听的数目一共有多大。这个参数不同于select()中的第一个参数,给出最大监听的fd+1的值。需要注意的是,当创建好epoll句柄后,它就是会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。

  调用epoll_create函数时创建的文件描述符保存空间称为“epoll例程”,但有些情况下名称不同,需要稍加注意。通过参数size传递的值决定epoll例程的大小,但该值只是向操作系统提的建议,换言之,size并非决定epoll例程的大小,而是仅供操作系统参考,在Linux2.6.8之后的内核将完全忽略传入epoll_create函数的size参数,因为内核会根据情况调整epoll例程的大小。

  epoll_create函数创建的资源与套接字相同,也由操作系统管理。因此,该函数和创建套接字对的情况相同,也会返回文件描述符。也就是说,该函数返回的文件描述符主要用于区分epoll例程。需要终止时,与其他文件描述符相同,也要调用close函数。

  (2)int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

  为了添加和删除监视对象文件描述符,select方式中需要FD_SET、FD_CLR函数。但在epoll方式中,通过epoll_ctl函数请求操作系统完成。还有,select方式中通过fd_set变量查看监视对象的状态变化(事件发生与否),而epoll方式中通过如下结构体epoll_event将发生变化的文件描述符单独集中到一起。

  声明足够大的epoll_event结构体数组后,传递给epoll_wait函数时,发生变化的文件描述符信息将被填入该数组。因此,无需像select函数那样针对所有文件描述符进行循环。

  epoll的事件注册函数,向epoll对象中添加这要连接的套接字它不同与select()是在监听事件时告诉内核要监听什么类型的事件epoll的事件注册函数,它不同与select()是在监听事件时告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型。

epfd:用于注册监视对象的epoll例程的文件描述符
op:用于指定监视对象的添加、删除或更改等操作
fd:需要注册的监视对象文件描述符
event:监视对象的事件类型

   第一个参数是epoll_create()的返回值;

  第二个参数表示动作,用三个宏来表示:

EPOLL_CTL_ADD:将文件描述符注册到epoll例程
EPOLL_CTL_DEL:从epoll例程中删除文件描述符
EPOLL_CTL_MOD:更改注册的文件描述符的关注事件发生情况

  第三个参数是需要监听的fd;

  第四个参数是告诉内核需要监听什么事,如前所述,epoll_event结构体用于保存发生事件的文件描述符集合。但也可以在epoll例程中注册文件描述符时,用于注册关注的事件。

struct epoll_event event;
……
event.events=EPOLLIN;//发生需要读取数据的情况(事件)时
event.data.fd=sockfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &event);
……

  struct epoll_event结构如下:

struct epoll_event 
{
    __uint32_t events;  /* Epoll events */
    epoll_data_t data;  /* User data variable */
};
typedef union epoll_data {
void *ptr; int fd; __uint32_t u32; __uint64_t u64; } epoll_data_t;

  events可以是以下几个宏的集合:

EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里

  与其他epoll函数相比,该函数有些复杂,但通过调用语句就很容易理解。假设按照如下形式调用epoll_ctl函数:

epoll_ctl(A, EPOLL_CTL_ADD, B, C);

  第二个参数EPOLL_CTL_ADD意味着“添加”,因此上述语句的含义是:epoll例程A中注册文件描述符B,主要目的是监视参数C中的事件

  再介绍一个调用语句:

epoll_ctl(A, EPOLL_CTL_DEL, B, NULL);  

  上述语句中第二个参数EPOLL_CTL_DEL指“删除”,因此该语句的含义为:从epoll例程A中删除文件描述符B

  从上述调用语句中可以看到,从监视对象中删除时,不需要监视类型(事件信息),因此向第四个参数传递NULL。

  如前所示,向epoll_ctl的第二个参数传递EPOLL_CTL_DEL时,应同时向第四个参数传递NULL。但Linux2.6.9之前的内核不允许传递NULL。虽然被忽略掉,但也应传递epoll_event结构体变量的地址值。其实这是BUG,但也没必要因此怀疑epoll的功能,因为我们使用的标准函数中也存在BUG。

  (3) int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

  select方式中通过fd_set变量查看监视对象的状态变化(事件发生与否),而epoll中是通过调用epoll_wait函数。
  等待事件的产生,收集发生事件的连接,类似于select()调用。参数events用来从内核得到事件的集合,maxevents告之内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size,参数timeout是超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)。该函数返回需要处理的事件数目,如返回0表示已超时。

epfd:表示事件发生监视范围的epoll例程的文件描述符
events:保存发生事件的文件描述符集合的结构体地址值
maxevents:第二个参数中可以保存的最大事件数
timeout:以1/1000秒为单位的等待时间,传递-1时,一直等待直到发生事件

 该函数的调用方式如下,需要注意的是,第二个参数所指缓冲需要动态分配

int event_cnt;
struct epoll_event * ep_events;
……
ep_events = malloc(sizeof(struct epoll_event)*EPOLL_SIZE);//EPOLL_SIZE是宏常量
……
event_cnt = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);
……

  调用函数后,返回发生事件的文件描述符,同时在第二个参数指向的缓冲中保存发生事件的文件描述符集合。因此,无需像select那样插入针对所有文件描述符的循环

  3.3、工作模式

  epoll对文件描述符的操作有两种模式:LT(level trigger条件触发)和ET(edge trigger边沿触发)。LT模式是默认模式,ET是“高速”模式。LT模式与ET模式的区别如下:

  条件触发:只要输入缓冲区有数据就会一直通知该事件。即就会将没有处理完的事件继续放回到就绪队列之中(即那个内核中的链表),一直进行处理。 例如,服务端输入缓冲收到50字节的数据时,服务端操作系统将通知该事件(注册到发生变化的文件描述符)。但服务端读取20字节后还剩30字节的情况下,仍会注册事件。也就是说,条件触发方式中,只要输入缓冲中还剩有数据,就将以事件方式再次注册。

       当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait时,会再次响应应用程序并通知此事件;

       只要这个fd还有数据可读,每次 epoll_wait都会返回它的事件,提醒用户程序去操作;

  边缘触发:输入缓冲收到数据时仅注册一次事件,即使输入缓冲中还留有数据,也不会再进行注册。若没有处理完会在下次的其它事件就绪时再进行处理,而若以后再也没有就绪的事件,那么剩余的那部分数据也会随之而丢失。 

       当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。

       它只会提示一次,直到下次再有数据流入之前都不会再提示了,无论fd中是否还有数据可读;

  由此可见:ET模式的效率比LT模式的效率要高很多。只是如果使用ET模式,就要保证每次进行数据处理时,要将其处理完,不能造成数据丢失,这样对编写代码的人要求就比较高。 
  注意:ET模式只支持非阻塞的读写:为了保证数据的完整性。

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

  所以在ET模式下,读一个fd的时候一定要把它的buffer读光,也就是说一直读到read的返回值小于请求值,或者 遇到EAGAIN错误。还有一个特点是,epoll使用“事件”的就绪通知方式,通过epoll_ctl注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait便可以收到通知。

 

  区别是,LT模式下,只要一个句柄上的事件一次没有处理完,会在以后调用epoll_wait时次次返回这个句柄,而ET模式仅在第一次返回。

  这件事怎么做到的呢?当一个socket句柄上有事件时,内核会把该句柄插入上面所说的准备就绪list链表,这时我们调用epoll_wait,会把准备就绪的socket拷贝到用户态内存,然后清空准备就绪list链表,最后,epoll_wait干了件事,就是检查这些socket,如果不是ET模式(就是LT模式的句柄了),并且这些socket上确实有未处理的事件时,又把该句柄放回到刚刚清空的准备就绪链表了。所以,非ET的句柄,只要它上面还有事件,epoll_wait每次都会返回。而ET模式的句柄,除非有新中断到,即使socket上的事件没有处理完,也是不会次次从epoll_wait返回的。

其中涉及到的数据结构:

epoll用kmem_cache_create(slab分配器)分配内存用来存放struct epitem和struct eppoll_entry。

当向系统中添加一个fd时,就创建一个epitem结构体,这是内核管理epoll的基本数据结构:

struct epitem 
{   
struct rb_node rbn;       //用于主结构管理的红黑树   struct list_head rdllink;   //事件就绪队列   struct epitem *next;      //用于主结构体中的链表   struct epoll_filefd ffd;    //这个结构体对应的被监听的文件描述符信息   int nwait;            //poll操作中事件的个数   struct list_head pwqlist; //双向链表,保存着被监视文件的等待队列,功能类似于select/poll中的poll_table   struct eventpoll *ep; //该项属于哪个主结构体(多个epitm从属于一个eventpoll)   struct list_head fllink; //双向链表,用来链接被监视的文件描述符对应的struct file。因为file里有f_ep_link,用来保存所有监视这个文件的epoll节点   struct epoll_event event; //注册的感兴趣的事件,也就是用户空间的epoll_event }

   而每个epoll fd(epfd)对应的主要数据结构为:

struct eventpoll 
{
    spin_lock_t lock;        //对本数据结构的访问
    struct mutex mtx;        //防止使用时被删除
    wait_queue_head_t wq;      //sys_epoll_wait()使用的等待队列
    wait_queue_head_t poll_wait; //file->poll()使用的等待队列
    struct list_head rdllist;    //事件满足条件的链表 /*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/
    struct rb_root rbr;          //用于管理所有fd的红黑树(树根)/*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/
    struct epitem *ovflist;      //将事件到达的fd进行链接起来发送至用户空间
}

    epoll为什么要有EPOLLET触发模式?

  如果采用EPOLLLT模式的话,系统中一旦有大量你不需要读写的就绪文件描述符,它们每次调用epoll_wait都会返回,这样会大大降低处理程序检索自己关心的就绪文件描述符的效率.。而采用EPOLLET这种边沿触发模式的话,当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据全部读写完(如读写缓冲区太小),那么下次调用epoll_wait()时,它不会通知你,也就是它只会通知你一次,直到该文件描述符上出现第二次可读写事件才会通知你。这种模式比水平触发效率高,系统不会充斥大量你不关心的就绪文件描述符。

  3.4、epoll的优点和缺点

  (1)优点

  • 无需编写循环语句来监视发生状态变化的文件描述符,即调用对应于select函数的epoll_wait函数时无需每次传递监视对象信息;
  • 调用epoll_wait时就相当于以往调用select/poll,但是这时却不用传递socket句柄给内核,因为内核已经在epoll_ctl中拿到了要监控的句柄列表;
  • 只向操作系统传递一次监视对象,监视范围或内容发生变化是只通知发生变化的事项;
  • 监视的描述符数量不受限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左 右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大;
  • 效率提升,epoll不是轮询的方式,IO效率不会随FD数目增长而线性降低。这是由于在内核实现中epoll是根据每一个fd上面的callback函数实现的,而只有活跃可用的FD才会主动去调用callback函数,即Epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll的效率就会远远高于select和poll;
  • 内存拷贝,利用mmap()文件映射内存加速与内核空间的消息传递,即epoll使用mmap减少复制开销,epoll是通过内核于用户空间mmap同一块内存,避免了无畏的内存拷贝;
  • 支持电平触发和边沿触发两种方式,理论上边缘触发的性能要更高一些,但是代码实现相当复杂;

4、select、poll和epoll三者对比与区别

 

 

select

poll

epoll

支持最大连接数

1024(x86)或2048(x64)

poll没有最大连接数的限制,原因是它是基于链表来存储的

虽然连接数有上限,但是很大,1G内存的机器上可以打开10万左右的连接,2G内存的机器可以打开20万左右的连接

IO效率

每次调用时都会进行线性遍历,所以随着FD的增加会造成遍历速度慢的“线性下降性能问题”,时间复杂度为O(N)        

每次调用时都会进行线性遍历,所以随着FD的增加会造成遍历速度慢的“线性下降性能问题”,时间复杂度为O(N)         

使用事件通知方式,系统注册的回调函数就会被调用,将就绪fd放到rdllist里面,这样epoll_wait返回的时候我们就拿到了就绪的fd。时间复杂度O(1)

fd拷贝

每次select都拷贝

每次poll都拷贝

调用epoll_ct时拷贝进内核并由内核保存,之后每次epoll_wait不拷贝,epoll通过内核和用户空间共享一块内存来实现的。

 

  总结:

  综上,在选择select,poll,epoll时要根据具体的使用场合以及这三种方式的自身特点。 

  (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内部定义的等待队列)。这也能节省很多的开销。

  (3)、表面上看epoll的性能最好,但是在连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好,毕竟epoll的通知机制需要很多函数回调。

  (4)、select低效是因为每次它都需要轮询。但低效也是相对的,视情况而定,也可通过良好的设计改善 

 

posted @ 2020-05-19 15:10  孤情剑客  阅读(913)  评论(0编辑  收藏  举报