Linux IO模型及 select、poll、epoll详解

一、IO模型

对于一次IO访问(以read举例),数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。所以说,当一个read操作发生时,它会经历两个阶段:

1. 等待数据准备 (Waiting for the data to be ready)
2. 将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)

正式因为这两个阶段,linux系统产生了下面五种网络模式的方案。

  • - 阻塞 I/O(blocking IO)
  • - 非阻塞 I/O(nonblocking IO)
  • - I/O 多路复用( IO multiplexing)
  • - 信号驱动 I/O( signal driven IO)
  • - 异步 I/O(asynchronous IO)

注:由于signal driven IO在实际中并不常用。

阻塞式 I/O

在linux中,默认情况下所有的socket都是blocking,一个典型的读操作流程大概是这样: 

上图中,recvfrom() 用于接收 Socket 传来的数据,并复制到应用进程的缓冲区 buf 中。这里把 recvfrom() 当成系统调用。

当用户进程调用了recvfrom这个系统调用,kernel就开始了IO的第一个阶段:准备数据(对于网络IO来说,很多时候数据在一开始还没有到达。比如,还没有收到一个完整的UDP包。这个时候kernel就要等待足够的数据到来)。这个过程需要等待,也就是说数据被拷贝到操作系统内核的缓冲区中是需要一个过程的。而在用户进程这边,整个进程会被阻塞(当然,是进程自己选择的阻塞)。当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后kernel返回结果,用户进程才解除block的状态,重新运行起来。

所以,blocking IO的特点就是在IO执行的两个阶段都被block了。

应该注意到,在阻塞的过程中,其它应用进程还可以执行,因此阻塞不意味着整个操作系统都被阻塞。因为其它应用进程还可以执行,所以不消耗 CPU 时间,这种模型的 CPU 利用率效率会比较高。 

非阻塞式 I/O

当用户进程发出read操作时,如果kernel中的数据还没有准备好,那么它并不会block用户进程,而是立刻返回一个error。从用户进程角度讲 ,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read操作。一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户内存,然后返回。

所以,nonblocking IO的特点是用户进程需要不断的主动询问kernel数据好了没有,这种方式称为轮询(polling)。

由于 CPU 要处理更多的系统调用,因此这种模型的 CPU 利用率比较低。

I/O 复用

IO multiplexing就是我们说的select,poll,epoll,有些地方也称这种IO方式为event driven IO,即事件驱动 I/O。select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO。它的基本原理就是select,poll,epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。

当用户进程调用了select,那么整个进程会被block,而同时,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的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。

所以换句话说如果一个 Web 服务器没有 I/O 复用,而是每一个 Socket 连接都需要创建一个线程去处理。如果同时有几万个连接,那么就需要创建相同数量的线程。

相比于多进程和多线程技术,I/O 复用不需要进程线程创建和切换的开销,系统开销更小。

在IO multiplexing Model中,实际中,对于每一个socket,一般都设置成为non-blocking,但是,如上图所示,整个用户的process其实是一直被block的。只不过process是被select这个函数block,而不是被socket IO给block。

信号驱动 I/O

应用进程使用 sigaction 系统调用,内核立即返回,应用进程可以继续执行,也就是说等待数据阶段应用进程是非阻塞的。内核在数据到达时向应用进程发送 SIGIO 信号,应用进程收到之后在信号处理程序中调用 recvfrom 将数据从内核复制到应用进程中。

相比于非阻塞式 I/O 的轮询方式,信号驱动 I/O 的 CPU 利用率更高。

 

异步 I/O

Linux下的asynchronous IO其实用得很少。先看一下它的流程:

用户进程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。

异步 I/O 与信号驱动 I/O 的区别在于,异步 I/O 的信号是通知应用进程 I/O 完成,而信号驱动 I/O 的信号是通知应用进程可以开始 I/O。

五大 I/O 模型比较

阻塞和非阻塞的区别

调用blocking IO会一直block住对应的进程直到操作完成,而non-blocking IO在kernel还准备数据的情况下会立刻返回。

同步IO和异步IO的区别

在说明synchronous IO和asynchronous IO的区别之前,需要先给出两者的定义。POSIX的定义是这样子的:
- A synchronous I/O operation causes the requesting process to be blocked until that I/O operation completes;
- An asynchronous I/O operation does not cause the requesting process to be blocked;

两者的区别就在于synchronous IO做”IO operation”的时候会将process阻塞。按照这个定义,之前所述的blocking IO,non-blocking IO,IO multiplexing都属于同步IO。

有人会说,non-blocking IO并没有被block啊。这里有个非常“狡猾”的地方,定义中所指的”IO operation”是指真实的IO操作,就是例子中的recvfrom这个system call。non-blocking IO在执行recvfrom这个system call的时候,如果kernel的数据没有准备好,这时候不会block进程。但是,当kernel中数据准备好的时候,recvfrom会将数据从kernel拷贝到用户内存中,这个时候进程是被block了,在这段时间内,进程是被block的。

而asynchronous IO则不一样,当进程发起IO 操作之后,就直接返回再也不理睬了,直到kernel发送一个信号,告诉进程说IO完成。在这整个过程中,进程完全没有被block。

 通过上面的图片,可以发现non-blocking IO和asynchronous IO的区别还是很明显的。在non-blocking IO中,虽然进程大部分时间都不会被block,但是它仍然要求进程去主动的check,并且当数据准备完成以后,也需要进程主动的再次调用recvfrom来将数据拷贝到用户内存。而asynchronous IO则完全不同。它就像是用户进程将整个IO操作交给了他人(kernel)完成,然后他人做完后发信号通知。在此期间,用户进程不需要去检查IO操作的状态,也不需要主动的去拷贝数据。

最后,再举几个不是很恰当的例子来说明常用的四个IO Model:
有A,B,C,D四个人在钓鱼:
A用的是最老式的鱼竿,所以呢,得一直守着,等到鱼上钩了再拉杆;(阻塞)
B的鱼竿有个功能,能够显示是否有鱼上钩,所以呢,B就和旁边的MM聊天,隔会再看看有没有鱼上钩,有的话就迅速拉杆;(非阻塞)
C用的鱼竿和B差不多,但他想了一个好办法,就是同时放好几根鱼竿,然后守在旁边,一旦有显示说鱼上钩了,它就将对应的鱼竿拉起来;(IO复用)
D是个有钱人,干脆雇了一个人帮他钓鱼,一旦那个人把鱼钓上来了,就给D发个短信。(异步)


这里参考:https://blog.csdn.net/historyasamirror/article/details/5778378 

综上:

同步IO和异步IO的区别就在于:数据拷贝的时候进程是否阻塞

阻塞IO和非阻塞IO的区别就在于:应用程序的调用是否立即返回

二、IO复用

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

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

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

1.select

调用后select函数会阻塞,直到有描述符就绪(有数据 可读、可写、或者有except),或者超时(timeout指定等待时间,如果立即返回设为null即可),函数返回。当select函数返回后,可以 通过遍历fdset,来找到就绪的描述符。

原理:

  1. 先构造一张有关文件描述符的列表,将需要监听的文件描述符添加到该表中
  2. 然后调用一个函数,由内核去监听这些文件描述符,知道其中某一个文件描述符有IO操作时该函数才返回。(怎么判断有IO操作?只要去看读缓冲区是不是有新的数据)这里有两点需要注意:
  • 函数是阻塞函数 
  • 函数对文件描述符的检测是由内核完成的

    3. 在返回时,该函数会告诉进程有多少(或者哪些)描述符要进行IO操作。

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

有三种类型的描述符类型:readset、writeset、exceptset,分别对应读、写、异常条件的描述符集合。fd_set 使用数组实现,数组大小使用 FD_SETSIZE 定义。下图中很好的说明了文件描述符表和fd_set结构之间的对应关系:

Linux IO模型及 select、poll、epoll详解_第1张图片

 如上图,一个fd_set中存了1024个标志位,每个标志位对应文件描述符表中的一个文件描述符。由内核去监测这个文件描述符对应的内存区域。如果是readset,就去看这段内存中的read缓存区域有没有数据。

timeout这个参数是用来设置阻塞方式的:

  • NULL:永久阻塞(一直等到检测到文件描述符发生变化时才返回)
  • 指定超时时间:将timeval结构体的地址值传递到select函数的最后一个参数。此时,过了指定时间就返回(只不过返回0)
struct timeval
{
    long tv_sec;//秒
    long tv_usec;//毫秒
}

 select的工作过程:

Linux IO模型及 select、poll、epoll详解_第2张图片

 在用户空间创建fd_set 并初始化,然后把将需要监测的文件描述符位设为1。这时再把这个初始表传给内核,内核根据这个表去遍历检查那些需要监测的文件描述符,如果该文件描述符对应的缓冲有变化,则置为1;对没有变化的清为0;内核将这个表又重新返回给用户空间的&read处,覆盖掉原表。

void FD_ZERO(fd_set *fdset);            //清除fdset的所有位
void FD_SET(int fd,fd_set *fdset);      //打开fdset中的fd位
void FD_CLR(int fd,fd_set *fdset);      //清除fdset中的fd位
int FD_ISSET(int fd,fd_set *fdset);     //检查fdset中的fd位是否置位

 注意用select实现的IO复用,总共要记住五个函数,包括上面这四个和select()。而后面的epoll则只要记住三个。

【用select实现回声服务器】

//echo_selectserv.c

#include
#include
#include
#include
#include
#include
#include
#include
#define BUF_SIZE 100
void error_handling(char *buf);

int main(int argc,const char* argv[]){
  char buf[BUF_SIZE];
  if(argc<2)
  { 
    printf("eg: ./a.out port\n");
    exit(1);
  }
  struct sockaddr_in serv_addr,clnt_addr;
  int port = atoi(argv[1]);
  //创建套接字
  int serv_sock = socket(AF_INET,SOCK_STREAM,0);
  //初始化服务器 sockaddr_in  
  memset(&serv_addr,0,sizeof(serv_addr));
  serv_addr.sin_family = AF_INET;//地址族
  serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);//监听本机所有IP
  serv_addr.sin_port() = htons(port); //设置端口,主机字节序转网络字节序
  //绑定IP和端口
  if(bind(serv_sock,(struct sockaddr*)&serv_addr, sizeof(serv_addr))==-1)
    error_handling("bind() error");
  //设置同时监听最大个数,其实就是队列长度
  if(listen(serv_sock,5)==-1)
    error_handling("listen() error");
  printf("Start accept ......\n");

  struct timeval timeout;

  //创建一个文件描述符表
  fd_set reads,tmp;
  //初始化
  FD_ZERO(&reads);
  //委托内核去检测是否有客户端请求,将监听的lfd加入到读集合
  FD_SET(serv_sock,&reads);
  int maxfd = serv_sock;//目前只有这一个文件描述符

  int str_len,i;
  while(1)//一直委托内核进行检测
  {
    temp = reads;
    timeout.tv_sec=5;
    timeout.tv_usec=5000;
    int ret = select(maxfd+1,&temp,NULL,NULL,NULL);//每次调用select函数时向操作系统传递监视对象信息,应用程序给OS传递数据会给程序造成很大负担
    if(ret == -1)
    {
      error_handling("select error");
      exit(1);
    }
    if(ret == 0)
      continue;
    
    //如果是客户端发送数据
    for(int i=0;i<=maxfd+1;++i){
      if(FD_ISSET(i,&temp))    
      {
        if(i==serv_sock){//门卫监听到有客户端连接请求
          //有新连接就接受新连接
          int clnt_sock = accept(serv_sock,(struct sockaddr*)&clnt_addr,sizeof(clnt_addr));
          if(clnt_sock == -1){
            error_handling("accept error");
            exit(1);
          }
          //将clnt_scok
          FD_SET(clnt_sock,&temp);
          maxfd = maxfd < clnt_sock ? clnt_sock : maxfd;
          printf("connect client : %d \n",clnt_sock);
        }
        else//处理连接的IO操作
        {
           str_len = read(i,buf,BUF_SIZE);//返回从缓冲区中读的实际所读字节数
           if(str_len==0)//客户端断开链接,就从读集合中del掉这个cfd           
           {
              FD_CLR(i,&reads);
              close(i);
              printf("closed client: %d\n", i); 
           }
           else{
              write(i,buf,str_len);//echo,又写回到输出缓冲区
           }       
        }     
      } 
    }
  }
  close(serv_sock);
  return 0;
}
void error_handling(char *message){
  fputs(message,stderr);
  fputc('\n',stderr);
  exit(1);
}

 使用select的优缺点:

-- 优点

  • 跨平台

-- 缺点

  • 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时很大
  • 每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
  • 单进程能够监视的文件描述符有上限,Linux上一般为1024,可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但 是这样也会造成效率的降低

2.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 */
};

Linux IO模型及 select、poll、epoll详解_第3张图片

pollfd 使用链表实现。

// The structure for two events
struct pollfd fds[2];

// Monitor sock1 for input
fds[0].fd = sock1;
fds[0].events = POLLIN;

// Monitor sock2 for output
fds[1].fd = sock2;
fds[1].events = POLLOUT;

// Wait 10 seconds
int ret = poll( &fds, 2, 10000 );
// Check if poll actually succeed
if ( ret == -1 )
    // report error and abort
else if ( ret == 0 )
    // timeout; no event detected
else
{
    // If we detect the event, zero it out so we can reuse the structure
    if ( fds[0].revents & POLLIN )
        fds[0].revents = 0;
        // input event on sock1

    if ( fds[1].revents & POLLOUT )
        fds[1].revents = 0;
        // output event on sock2
}

pollfd结构包含了要监视的event和发生的event,同时,pollfd并没有最大数量限制(但是数量过大后性能也是会下降)。 和select函数一样,poll返回后,需要轮询pollfd来获取就绪的描述符。

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

比较

1. 功能

select 和 poll 的功能基本相同,不过在一些实现细节上有所不同。

  • select 会修改描述符,而 poll 不会;
  • select 的描述符类型使用数组实现,FD_SETSIZE 大小默认为 1024,因此默认只能监听 1024 个描述符。如果要监听更多描述符的话,需要修改 内核中的FD_SETSIZE 之后重新编译;而 poll 的描述符类型使用链表实现,没有描述符数量的限制;
  • poll 提供了更多的事件类型,并且对描述符的重复利用上比 select 高。
  • 如果一个线程对某个描述符调用了 select 或者 poll,另一个线程关闭了该描述符,会导致调用结果不确定。

2. 速度

select 和 poll 速度都比较慢。

  • select 和 poll 每次调用都需要将全部描述符从应用进程缓冲区复制到内核缓冲区。
  • select 和 poll 的返回结果中没有声明哪些描述符已经准备好,所以如果返回值大于 0 时,应用进程都需要使用轮询的方式来找到 I/O 完成的描述符。

3. 可移植性

几乎所有的系统都支持 select,但是只有比较新的系统支持 poll。

3.epoll

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

大致思路和select()相同,重点掌握下面三个函数:

三个函数:

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专用的文件描述符(epoll生成一棵树,这个文件描述符就是树的根节点),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);
//成功时返回0,失败时返回-1

--  控制函数

epoll_ctl() 用于向内核注册新的描述符或者是改变某个文件描述符的状态(注册、修改和删除)。已注册的描述符在内核中会被维护在一棵红黑树上。

参数:

  • epfd: epoll_create 生成的epoll专用文件描述符(树的根节点)
  • op :  对应的注册、修改和删除操作,只需要指定对应的宏:
  •       EPOLL_CTL_ADD : 添加

  •       EPOLL_CTL_MOD : 修改

  •       EPOLL_CTL_DEL : 删除

  • fd: 是需要监听的fd(文件描述符)
  • struct epoll_event  *event : 这个结构体其实就是要往epoll树上挂的节点,在这颗树上的每个节点,都是要告诉内核要监控什么事件。其实是对第三个参数fd的详细信息的描述。
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队列里

 

epoll树:

Linux IO模型及 select、poll、epoll详解_第4张图片

Linux IO模型及 select、poll、epoll详解_第5张图片

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

 -- 等待IO事件发生,可以设置阻塞的函数(对应前面的select函数和poll函数)

当内核监测到结构体中的fd对应的事件发生了的话,他会把这个节点对应的结构体再次拷贝到epoll_wait()中的结构体数组中去。

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

  • epfd : 需要监测的文件描述符
  • events : 保存发生事件的文件描述符集合的结构体地址
  • maxevents : events中可以保存的最大事件数
  • timeout : 超时时间
  •               -1 : 永久阻塞
  •               0  : 立即返回
  •               >0 :   设置的返回时间

针对select的几大缺点:

(1)每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大

(2)同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大

(3)select支持的文件描述符数量太小了,默认是1024

 我们来看看epoll都是怎么解决的?

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

	//epoll树根节点
	int epfd = epoll_create(EPOLL_SIZE);
	//存储发生变化的fd对应信息,OS会将发生变化的文件描述符填到这个数组中去,因此无需像select那样对所有文件描述符进行循环
	struct epoll_event *all_events;//这个将作为epoll_wait的第二个参数,它所指的缓冲去需要动态分配
	//动态分配所需缓冲区
	all_events = malloc(sizeof(struct epoll_event)*EPOLL_SIZE);
	//初始化
	struct epoll_event ev;
	//在ev中初始化 serv_sock 的信息
	ev.events = EPOLLIN;
	ev.data.fd = serv_sock;
	//监听的lfd挂到epoll树上
	epoll_ctl(epfd,EPOLL_ADD,serv_sock,&ev);
	while(1){
		//委托内核检测事件
		int ret = epoll_wait(epfd,all_events,sizeof(all_events)/sizeof(all_events[0]),-1);
		//根据ret遍历all数组
		for(int i = 0; i

epoll工作模式

epoll 的描述符事件有两种触发模式:LT(level trigger)和 ET(edge trigger)。

1. LT 模式

条件触发方式中,只要输入缓冲中有数据就一直会通知该事件。例如服务器输入缓冲收到50字节的数字时,服务器端操作系统就会通知该事件(注册到发生变化的文件描述符)。但服务器端读取20字节还剩30字节的情况下,仍会注册事件。也就是说,条件触发模式下,只要缓冲中还剩有数据,就将以事件方式再次注册。

当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait时,会再次响应应用程序并通知此事件。这是默认的一种模式,并且同时支持 Blocking 和 No-Blocking。在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的。

2. ET 模式

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

在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)。

很大程度上减少了 epoll 事件被重复触发的次数,因此效率要比 LT 模式高。只支持 No-Blocking,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。

LT VS ET 

二者的差异在于 水平触发 level-trigger 模式下只要某个 socket (或者说fd)处于 readable/writable 状态,无论什么时候

进行 epoll_wait 都会返回该 socket(fd);而 edge-trigger 模式下只有某个 socket 从 unreadable 变为 readable 或从

unwritable 变为 writable 时,epoll_wait 才会返回该 socket。如下两个示意图:

从socket读数据:

 

 

 

往socket写数据

所以, 在epoll的ET模式下, 正确的读写方式为:

读: 只要可读, 就一直读, 直到返回0, 或者 errno = EAGAIN

写: 只要可写, 就一直写, 直到数据发送完, 或者 errno = EAGAIN

 那么我们可能会觉得LT模式在epoll池中存在大量fd时效率要显著低于ET模式。但事实上,两者的模式处理逻辑差异很小,性能测试表示两者性能差异可以忽略。那这是为什么?

其实使用ET模式,要配合用户应用程序中的ready list结构,来收集已经出现event的fd,然后再轮询挨个处理。但由于ready list的实现千奇百怪,不都经过自己的推敲优化,所以导致ET的使用逻辑复杂,开销往往更大。

但不可否认的是,ET和LT的性能差异主要在于epoll_wait系统调用的处理速度。

epoll总结

在 select/poll中,进程只有在调用一定的方法后,内核才对所有监视的文件描述符进行扫描,而epoll事先通过epoll_ctl()来注册一 个文件描述符,一旦基于某个文件描述符就绪时,内核会采用类似callback的回调机制,迅速激活这个文件描述符,当进程调用epoll_wait() 时便得到通知(此处去掉了遍历文件描述符,而是通过监听回调的的机制这正是epoll的魅力所在。)

epoll的优点主要是一下几个方面:

1. 监视的描述符数量不受限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048。select的最大缺点就是进程打开的fd是有数量限制的。这对 于连接数量比较大的服务器来说根本不能满足。虽然也可以选择多进程的解决方案( Apache就是这样实现的),不过虽然linux上面创建进程的代价比较小,但仍旧是不可忽视的,加上进程间数据同步远比不上线程间同步的高效,所以也不是一种完美的方案。

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

应用场景

很容易产生一种错觉认为只要用 epoll 就可以了,select 和 poll 都已经过时了,其实它们都有各自的使用场景。

1. select 应用场景

select 的 timeout 参数精度为 1ns,而 poll 和 epoll 为 1ms,因此 select 更加适用于实时性要求比较高的场景,比如核反应堆的控制。

select 可移植性更好,几乎被所有主流平台所支持。

2. poll 应用场景

poll 没有最大描述符数量的限制,如果平台支持并且对实时性要求不高,应该使用 poll 而不是 select。

3. epoll 应用场景

只需要运行在 Linux 平台上,有大量的描述符需要同时轮询,并且这些连接最好是长连接。

需要同时监控小于 1000 个描述符,就没有必要使用 epoll,因为这个应用场景下并不能体现 epoll 的优势。

需要监控的描述符状态变化多,而且都是非常短暂的,也没有必要使用 epoll。因为 epoll 中的所有描述符都存储在内核中,所以每次需要对描述符的状态改变时都需要通过 epoll_ctl() 进行系统调用,频繁系统调用降低效率。并且 epoll 的描述符存储在内核,不容易调试。

你可能感兴趣的:(网络编程)