select,poll,epoll都是IO多路复用的机制。 I/O多路复用就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但 select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,并且这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核缓冲区拷贝到用户空间

一、select==>时间复杂度O(n)

1、select()原型

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

select()函数把可读描述符、可写描述符、错误描述符分在了三个集合里,这三个集合可以看做是用bit位来标记一个描述符,一旦有若干个描述符状态发生变化,那么它将被置位,而其他没有发生变化的描述符的bit位将被clear(实际上fd_set是一个long型数组)

调用select函数后程序会阻塞,直到有描述副就绪,或者超时,函数返回。select各个参数含义如下:

int 	n:最大描述符值 + 1
fd_set *readfds:对可读感兴趣的描述符集
fd_set *writefds:对可写感兴趣的描述符集
fd_set *errorfds:对出错感兴趣的描述符集
struct timeval *timeout:超时时间(注意:对于linux系统,此参数没有const限制,每次select调用完毕timeout的值都被修改为剩余时间,而unix系统则不会改变timeout值)

2、fd_set结构体(select的实现)

fd_set结构体内部实际上是一long类型的数组,操作系统定义的该数组大小为1024,每一个数组元素都能与一打开的文件句柄(不管是socket句柄,还是其他文件或命名管道或设备句柄)建立联系,建立联系的工作由程序员完成,当调用select()时,由内核根据IO状态修改fd_set的内容,由此来通知执行了select()的进程哪一socket或文件可读。

3、select()的返回值

select函数会在发生以下情况时返回:

readfds集合中有描述符可读
writefds集合中有描述符可写
errorfds集合中有描述符遇到错误条件
指定的超时时间timeout到了

select()的返回值如下:

-1:有错误产生
 0:超时时间到,而且没有描述符有状态变化
>0:有状态变化的描述符个数

当select函数返回后,可以通过遍历fdset,来找到就绪的描述符。也可以用FD_ISSET宏对描述符进行测试以找到状态变化的描述符。

4、设置描述符集合

设置描述符集合通常用如下几个宏定义:

fd_set set; 
FD_ZERO(&set); /*将set清零使集合中不含任何fd*/
FD_SET(fd, &set); /*将fd加入set集合*/ 
FD_CLR(fd, &set); /*将fd从set集合中清除*/ 
FD_ISSET(fd, &set); /*测试fd是否在set集合中*/

5、select()就绪条件

  1. 读就绪:socket接收缓冲区中的字节数大于等于低水位标记。
  2. 写就绪:socket发送缓冲区中的可用字节数大于等于低水位标记。
  3. 异常就绪:socket上收到带外数据。

6、select()的缺点

  1. 单个进程可监视的fd数量被限制,即能监听端口的大小有限。32位机默认是1024个。64位机默认是2048
  2. 对socket进行扫描时是线性扫描,即采用轮询的方法,效率较低
  3. 调用select时,要传递一个标记了所有监视文件描述符的数据结构给内核,调用返回时,内核再将整个文件描述符集合拷贝回用户态,然后用户态再通过遍历的方法找到可读或者可写的fd,然后对其处理,这样会使得用户空间和内核空间在传递该结构时复制开销大

7、select所能监视的描述符限制为什么是1024?

主要有以下两方面的原因:

  1. fd_set是一个__int32_t类型的数组,数组的大小为1024(由FD_SETSIZE定义)。
  2. 进程的文件描述符上限默认是1024,正是因为这个原因,select设计时才把数组大小设计为1024。

其中进程的文件描述符上限是可以手动修改的,但是fd_set的大小是改不了的,要改只能重新编译内核

8、使用实例

uint32 SocketWait(TSocket *s,bool rd,bool wr,uint32 timems)    
{
     fd_set rfds,wfds;
#ifdef _WIN32
     TIMEVAL tv;
#else
     struct timeval tv;
#endif   /* _WIN32 */ 
 
     FD_ZERO(&rfds);
     FD_ZERO(&wfds); 
 
     if (rd)     //TRUE
          FD_SET(*s,&rfds);   //添加要测试的描述字 
     if (wr)     //FALSE
          FD_SET(*s,&wfds); 
 
     tv.tv_sec=timems/1000;     //second
     tv.tv_usec=timems%1000;     //ms 
 
     for (;;) //如果errno==EINTR,反复测试缓冲区的可读性
          switch(select((*s)+1,&rfds,&wfds,NULL,(timems==TIME_INFINITE?NULL:&tv)))  //测试在规定的时间内套接口接收缓冲区中是否有数据可读
         {                                              //0--超时,-1--出错
         case 0:     /* time out */
              return 0; 
         case (-1):    /* socket error */
              if (SocketError()==EINTR)
                   break;              
              return 0; //有错但不是EINTR 
          default:
              if (FD_ISSET(*s,&rfds)) //如果s是rfds或wfds中的一员返回非0,否则返回0
                   return 1;
 
              if (FD_ISSET(*s,&wfds))
                   return 2;
              return 0;
         };
}

二、poll==>时间复杂度O(n)

1、poll()原型

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

poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时。

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

各参数含义如下:

struct pollfd *fds:一个结构体数组,用来保存各个描述符的相关状态。
unsigned long nfds:fdarray数组的大小,即里面包含有效成员的数量。
int timeout:设定的超时时间。(以毫秒为单位)

2、pollfd结构体(poll的实现)

pollfd结构体第一项fd代表描述符,第二项代表要监听的事件,也就是感兴趣的事件,而第三项代表poll()返回时描述符的状态。如下所示:

struct pollfd {
    int fd; /* 描述符 */
    short events; /* 要监听的事件 */
    short revents; /* 返回时描述符的状态 */
};

pollfd的合法状态如下:

POLLIN:    	有普通数据或者优先数据可读
POLLRDNORM: 有普通数据可读
POLLRDBAND: 有优先数据可读
POLLPRI:    有紧急数据可读
POLLOUT:    有普通数据可写
POLLWRNORM: 有普通数据可写
POLLWRBAND: 有紧急数据可写
POLLERR:    有错误发生
POLLHUP:    有描述符挂起事件发生
POLLNVAL:   描述符非法

对于timeout的设置如下:

INFTIM:	wait forever
0:		return immediately, do not block
>0:		wait specified number of milliseconds

3、poll()的返回值

poll函数返回值及含义如下:

-1:有错误产生
 0:超时时间到,而且没有描述符有状态变化
>0:有状态变化的描述符个数

4、poll()就绪条件

poll()与select()中的socket就绪条件一致。

5、poll()的优缺点

优点:

  1. 它没有最大连接数的限制

缺点:

  1. 对socket进行扫描时是线性扫描,即采用轮询的方法,效率较低
  2. 调用poll时,要传递一个标记了所有监视文件描述符的数据结构给内核,调用返回时,内核再将整个文件描述符集合拷贝回用户态,然后用户态再通过遍历的方法找到可读或者可写的Socket,然后对其处理,这样会使得用户空间和内核空间在传递该结构时复制开销大

6、使用实例

#include <stdio.h>
 
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <errno.h>
 
#include <poll.h>
#include <fcntl.h>
#include <unistd.h>
 
#define MAX_BUFFER_SIZE 1024
#define IN_FILES 3
#define TIME_DELAY 60*5
#define MAX(a,b) ((a>b)?(a):(b))
 
int main(int argc ,char **argv)
{
  struct pollfd fds[IN_FILES];
  char buf[MAX_BUFFER_SIZE];
  int i,res,real_read, maxfd;
  fds[0].fd = 0;
  if((fds[1].fd=open("data1",O_RDONLY|O_NONBLOCK)) < 0)
    {
      fprintf(stderr,"open data1 error:%s",strerror(errno));
      return 1;
    }
  if((fds[2].fd=open("data2",O_RDONLY|O_NONBLOCK)) < 0)
    {
      fprintf(stderr,"open data2 error:%s",strerror(errno));
      return 1;
    }
  for (i = 0; i < IN_FILES; i++)
    {
      fds[i].events = POLLIN;
    }
  while(fds[0].events || fds[1].events || fds[2].events)
    {
      if (poll(fds, IN_FILES, TIME_DELAY) <= 0)
    {
     printf("Poll error\n");
     return 1;
    }
      for (i = 0; i< IN_FILES; i++)
    {
     if (fds[i].revents)
       {
         memset(buf, 0, MAX_BUFFER_SIZE);
         real_read = read(fds[i].fd, buf, MAX_BUFFER_SIZE);
         if (real_read < 0)
        {
         if (errno != EAGAIN)
           {
             return 1;
           }
        }
         else if (!real_read)
        {
         close(fds[i].fd);
         fds[i].events = 0;
        }
         else
        {
         if (i == 0)
           {
             if ((buf[0] == 'q') || (buf[0] == 'Q'))
            {
             return 1;
            }
           }
         else
           {
             buf[real_read] = '\0';
             printf("%s", buf);
           }
        }
       }
    }
    }
  exit(0);
}

三、epoll==>时间复杂度O(1)

1、epoll_create()函数

int epoll_create(int size)

创建一个epoll的句柄,size用来告诉内核监听的文件描述符数目的大小。这个参数不同于select()中的第一个参数(select第一个参数()给出最大监听的fd号+1的值)

当创建好epoll句柄后,它就是会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽

2、epoll_ctl()函数

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

epoll的事件注册函数,它不同与select()是在监听事件时告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型,参数如下:

第一个参数是epoll_create()的返回值 ,
第二个参数表示动作,用三个宏来表示 :
		EPOLL_CTL_ADD:注册新的fd到epfd中;
		EPOLL_CTL_MOD:修改已经注册的fd的监听事件;
		EPOLL_CTL_DEL:从epfd中删除一个fd;
第三个参数 是需要监听的fd ;
第四个参数 是告诉内核需要监听什么事件。

epoll_event结构如下:

typedef union epoll_data {  
void *ptr;  
int fd;  
__uint32_t u32;  
__uint64_t u64;  
} epoll_data_t;  
  
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队列里。

举例
ev.data.fd的用法:

struct epoll_event ev;
//设置与要处理的事件相关的文件描述符
ev.data.fd=listenfd;
//设置要处理的事件类型
ev.events=EPOLLIN|EPOLLET;
//注册epoll事件
epoll_ctl(epfd,EPOLL_CTL_ADD,listenfd,&ev);

event.data.ptr的用法:

//自定义一个结构体或者类
struct myevent_s {
    int fd;
    int events;
    void *arg;
    void (*call_back)(int fd, int events, void *arg);
    int status;
    char buf[BUFLEN];
    int len;
    long last_active;
};
struct myevent_s g_event; 
struct epoll_event ev;
//设置要处理的事件类型
ev.events=EPOLLIN|EPOLLET;
//令ev.data.ptr指向myevent_s类型的对象g_event
ev.data.ptr=&g_event;
//然后在epoll_wait()的第二个参数中的data.ptr会原封不动的把上边的g_event返回给我们,所以我们可以利用这个ptr传参。

3、epoll_wait()函数

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

epoll_wait()等侍注册在epfd上的socket fd的事件的发生,如果发生则将发生的sokct fd和事件类型放入到events数组中。并且将注册在epfd上的socket fd的事件类型给清空,所以如果下一个循环你还要关注这个socket fd的话,则需要用epoll_ctl(epfd,EPOLL_CTL_MOD,listenfd,&ev)来重新设置socket fd的事件类型。这时不用EPOLL_CTL_ADD,因为socket fd并未清空,只是事件类型清空。epoll_wait()参数如下:

epfd:由epoll_create 生成的epoll专用的文件描述符;
events:用于存储待处理事件的数组;
maxevents:events数组中成员的个数;
timeout:等待I/O事件发生的超时值(单位我也不太清楚)-1相当于阻塞,0相当于非阻塞。一般用-1即可

该函数返回需要处理的事件数目,如返回0表示已超时。
返回的事件集合存储在events数组中,即数组中实际存放的成员个数是函数的返回值。

4、epoll各函数调用顺序

  1. 先是使用int epoll_create(int size):在内存中创建一个指定size大小的事件空间。
  2. 再使用int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)事件注册函数:注册新的fd到epfd中,并指明event(可读写啊等等),注意:在注册新事件fd的过程中,也在内核中断处理程序里注册fd对应的回调函数callback,告诉内核,一旦这个fd中断了,就把它放到ready队列里面去。
  3. 再使用int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout):在epoll对象对应的活跃事件数组events里取就绪的fd,并使用内存映射mmap拷贝到用户空间。
  4. 再在用户空间依次处理相关的fd。

5、epoll的工作模式

epoll对文件描述符的操作有两种模式:LT(level trigger)和ET(edge trigger)。LT模式是默认模式
 
LT模式:同时支持block和no-block socket,当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait时,会再次响应应用程序并通知此事件。

ET模式:只支持no-block socket,当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。

epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。

LT和ET的处理流程

LT的处理过程:

  • accept一个连接,epoll监听EPOLLIN事件。(注意这里没有关注EPOLLOUT事件)
  • 当EPOLLIN事件到达时,read fd中的数据并处理。
  • 当需要写出数据时,把数据write到fd中;如果数据较大,无法一次性写出,那么在epoll中监听EPOLLOUT事件。如果数据写出完毕,那么在epoll中关闭EPOLLOUT事件。

ET的处理过程:

  • accept一个连接,epoll监听EPOLLIN|EPOLLOUT事件。
  • 当EPOLLIN事件到达时,read fd中的数据并处理,read需要一直读,直到返回EAGAIN为止。
  • 当EPOLLOUT事件到达时,把数据write到fd中,直到数据全部写完,或者write返回EAGAIN。

从ET的处理过程中可以看到,ET的要求是需要一直用while循环读写,直到返回EAGAIN,否则就会遗漏事件。而LT的处理过程中,直到返回EAGAIN不是硬性要求,LT比ET多了一个开关EPOLLOUT事件的步骤

EAGAIN:例如,以 O_NONBLOCK的标志打开文件/socket/FIFO,如果你连续做read操作而没有数据可读,此时程序不会阻塞起来等待数据准备就绪返回,read函数会返回一个错误EAGAIN,提示你的应用程序现在没有数据可读请稍后再试。

LT和ET的优缺点

LT模式的优点如下:

  1. 对于read操作比较简单,只要有read事件就读,读多读少都可以。

LT模式的缺点如下:

  1. write相关操作较复杂,由于socket在空闲状态时发送缓冲区一定是不满的,故若socket一直在epoll wait列表中,则epoll会一直通知write事件,所以必须保证没有数据要发送的时候,要把socket的write事件从epoll wait列表中删除。而在需要的时候在加入回去,这就是LT模式的最复杂部分。

ET模式的优点如下:

  1. 对于write事件,发送缓冲区由满到未满时才会通知,若无数据可写,忽略该事件,若有数据可写,直接写。socket的write事件可以一直发在epoll的wait列表。

ET模式的缺点如下:

  1. epoll read事件触发时,必须保证socket的读取缓冲区数据全部读完(事实上这个要求很容易达到)。

LT和ET哪个性能更好?

一般认为ET更好,毕竟可以从内核中少拷贝就绪文件描述符。但是,ET伴随着使用非阻塞socket,要一次性读完、写完数据,至于是否真的更好,目前没有定论,需要在更多的环境、场景下去测试。

6、epoll的工作流程

创建epoll对象

如下图所示,当某个进程调用epoll_create方法时,内核会创建一个eventpoll对象(也就是程序中epfd所代表的对象)。eventpoll对象也是文件系统中的一员,和socket一样,它也会有等待队列。创建一个代表该epoll的eventpoll对象是必须的,因为内核要维护 就绪列表(rdlist)等数据
在这里插入图片描述

维护监视列表

创建epoll对象后,可以用epoll_ctl添加或删除所要监听的socket。以添加socket为例,如下图,如果通过epoll_ctl添加sock1、sock2和sock3的监视,内核会将eventpoll添加到这三个socket的等待队列中。当socket收到数据后,中断程序会操作eventpoll对象,而不是直接操作进程。
在这里插入图片描述

接受数据

当socket收到数据后,中断程序会给eventpoll的rdlist添加socket引用。如下图展示的是sock2和sock3收到数据后,中断程序让rdlist引用这两个socket。eventpoll对象相当于是socket和进程之间的中介,socket的数据接收并不直接影响进程,而是通过改变eventpoll的就绪列表来改变进程状态。当程序执行到epoll_wait时,如果rdlist已经引用了socket,那么epoll_wait直接返回,如果rdlist为空,阻塞进程。
在这里插入图片描述

阻塞和唤醒进程

假设计算机中正在运行进程A和进程B,在某时刻进程A运行到了epoll_wait语句。如下图所示,内核会将进程A放入eventpoll的等待队列中,阻塞进程。
在这里插入图片描述

当socket接收到数据,中断程序一方面修改rdlist,另一方面唤醒eventpoll等待队列中的进程,进程A再次进入运行状态(如下图)。也因为rdlist的存在,进程A可以知道哪些socket发生了变化。
在这里插入图片描述

7、epoll的实现细节

如下图所示,eventpoll包含了lock、mtx、wq(等待队列)、rdlist(就绪队列)、rbr(索引结构)成员。rdlistrbr是比较重要的两个成员。
在这里插入图片描述

  • rdlist引用着就绪的socket,所以它应能够快速的插入数据。程序可能随时调用epoll_ctl添加监视socket,也可能随时删除。当删除时,若该socket已经存放在就绪列表中,它也应该被移除。所以rdlist应是一种能够快速插入和删除的数据结构。双向链表就是这样一种数据结构,epoll使用双向链表来实现就绪队列(对应上图的rdllist)。
  • epoll使用rbr来保存监视的socket,rbr至少要方便的添加和移除,还要便于搜索,以避免重复添加。红黑树是一种自平衡二叉查找树,搜索、插入和删除时间复杂度都是O(log(N)),效率较好,因此epoll使用了红黑树作为rbr。

8、epoll的优缺点

  1. 没有最大并发连接的限制,能打开的FD的上限远大于1024(1G的内存上能监听约10万个端口);
  2. 效率提升,不是轮询的方式,不会随着FD数目的增加效率下降。只有活跃可用的FD才会调用callback函数;即Epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll的效率就会远远高于select和poll。
  3. epoll通过epoll_ctl 在内核建立了一个数据结构,该接口会将要监视的文件描述符记录下来,之后调用epoll_wait时就不用再传递任何文件描述符的信息给内核了,但是每次添加文件描述符的时候需要执行一次系统调用。即select每次传入和取回时都要涉及到fd数组在用户态和内核态之间的复制,而epoll只复制一次

9、为什么 epoll 要把 ctl 和 wait 分开

select用FD_SET/FD_CLR/FD_ZERO/FD_ISSET几个宏来需要关注的文件描述符集合,注意,这是宏,不是系统调用,他们只是简单的操作一个bit集合而已,只有select是内核提供出来的。与之相反,epoll则不同,有三个由内核提供出来的接口,他们都可以操作epoll内核对象(即有epoll_create创建的)。因此,在epoll_wait睡眠时,另一个线程可以操作这个epoll对象,例如给他添加要检测的文件描述符,这个添加操作会打断epoll_wait的睡眠,从而让他及时处理,而select则不行。

这种需要打断epoll_wait/select的需求之一在:多个线程,一个用于像缓冲区写数据,另一个负责管理文件描述符(如把缓冲区的数据写到文件描述符里),如果一个文件描述符fd1可写,但是它的缓冲区没有数据,fd1不被加入到被监测文件描述符集合中,这时,如果fd1的缓冲区里有数据了,而epoll/select还阻塞在其他文件描述符集合(不包括fd1),就需要打断阻塞。如何做到这一点呢?select可以建立一个本地文件描述符用于传递命令,而对于epoll则不必,直接调用epoll_ctl就可以了。

10、为何epoll_wait复杂度为O(1)

当socket就绪时,中断程序会操作eventPoll,在eventPoll中的就绪列表(rdlist),添加scoket引用。这样的话,进程A只需要不断循环遍历rdlist,从而获取就绪的socket,而不会遍历所有的socket。

所以,poll和select的时间复杂度为O(n):每个监听的文件描述符都遍历一遍。epoll的时间复杂度为O(1):只遍历就绪的socket,内核中断程序会添加就绪的socket到rdlist。

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐