IO多路复用之epoll

IO多路复用之epoll

认识epoll

由于poll并没有很大程度的解决select中的缺点,而且还带来了一些额外的开销。在处理大量socket时,select和poll要进行频繁的拷贝和遍历,效率低。所以poll也不建议使用。下面介绍的epoll不会随文件描述符的增多而开销变大,基本上解决了上述问题,被大家广泛使用。

函数原型:
这里写图片描述
创建一个epoll句柄,用来控制和等待大量的文件描述符
进程间通信的信号量,消息队列,共享内存这些都是随内核的
而这里的epoll句柄本质是文件描述符,是随进程的
参数size可以忽略,无意义。未废除是因为要向前兼容

这里写图片描述
epoll的事件注册函数。
第一个参数epfd就是上面我们创建的epoll句柄
第二个参数op表示动作,对应三个宏

  • EPOLL_CTL_ADD:注册新的fd到epfd中
  • EPOLL_CTL_MOD:修改已注册的fd的监听事件
  • EPOLL_CTL_DEL:从epfd中删除一个fd

第三个参数是要监听的文件描述符
第四个参数是epoll_event的一个结构体指针,表示要监听的事件
这里写图片描述
epoll_event结构体中的第一个成员是要监听的事件,底层用位图来表示。
它的取值有:

  • EPOLLIN:对应文件描述符可读
  • EPOLLOUT:对应文件描述符可写
  • EPOLLPRI:对应文件描述符有紧急事件可读
  • EPOLLERR:对应文件描述符发生错误
  • EPOLLHUP:对应文件描述符可读
  • EPOLLET:将EPOLL设为边缘触发
  • EPOLLIN:对该文件描述符只监听一次,若要继续监听,需要重新注册

第二个成员是用户自定制的数据,是一个联合体,根据数据类型的不同进行调整。一般情况下,我们将要监听的文件描述符作为用户数据传过去,以便在epoll_wait返回时,我们能够拿到fd来判断是什么类型的fd就绪,并对其进行相应的处理。
fd和event相当于一个键值对

这里写图片描述
文件描述符的等待。
第一个参数epfd就是上面我们创建的epoll句柄
第二个参数是epoll_event数组的首地址,它是一个输出型参数,一旦哪个文件描述符就绪,就将对应得epoll_event结构体放到这个数组中,方便用户访问
第三个参数表示缓冲区的最大值,即数组中最多可容纳多少个epoll_event结构体
第四个参数和前面一样表示超时时间,0表示不等待直接返回,-1表示阻塞等待。
返回值:-1失败,0超时,>0表示就绪的文件描述符个数
epoll使用的函数接口一共有三个

epoll实现原理

底层实现是红黑树。
红黑树:比较平衡的二叉搜索树,在平衡和旋转之间找一个平衡点,使插入删除的效率不算太低。
AVL树:严格平衡的二叉搜索树,弊端明显,每次插入都要检查高度差不超过1,否则就要触发旋转操作,有一定开销。
内核中使用红黑树来管理这些键值对,key是fd,value是epoll_event。
摒弃循环/轮询的方法,采用回调机制,每个节点绑定一个回调函数,看有哪些文件描述符就绪,网卡告诉驱动,驱动告诉内核,然后将调用回掉函数
还有一个就绪队列,一旦某个文件描述符就绪,回调函数就把该节点放入就绪队列中,返回时直接遍历就绪队列,而不用去便利所有的文件描述符,拷贝时直接将就绪队列中的结点从内核态拷贝到用户态,大大减少了开销,提高性能
时间复杂度O(1)

实现epoll版本的回显服务器

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/epoll.h>
#include <netinet/in.h>
#include <arpa/inet.h>
typedef struct sockaddr sockaddr;
typedef struct sockaddr_in sockaddr_in;
typedef struct epoll_event epoll_event;
int SocketInit(const char* ip,short port)
{
  int listen_socket=socket(AF_INET,SOCK_STREAM,0);
  if(listen_socket<0)
  {
    perror("socket");
    return -1;
  }
  sockaddr_in addr;
  addr.sin_family=AF_INET;
  addr.sin_addr.s_addr=inet_addr(ip);
  addr.sin_port=htons(port);
  int ret=bind(listen_socket,(sockaddr*)&addr,sizeof(addr));
  if(ret<0)
  {
    perror("bind");
    return -1;
  }
  ret=listen(listen_socket,5);
  if(ret<0)
  {
    perror("listen");
    return -1;
  }
  return listen_socket;
}
void ProcessListenSocket(int epoll_fd,int listen_socket)
{
  //1.调用accept
  sockaddr_in peer;
  socklen_t len=sizeof(peer);
  int new_socket=accept(listen_socket,(sockaddr*)&peer,&len);
  if(new_socket<0)
  {
    perror("accept");
    return;
  }
  //2.将new_socket加入到epoll之中
  epoll_event event;
  event.events=EPOLLIN;
  event.data.fd=new_socket;//一定要记得带上这里的文件描述符
  int ret=epoll_ctl(epoll_fd,EPOLL_CTL_ADD,new_socket,&event);
  if(ret<0)
  {
    perror("epoll_ctl add");
    return;
  }
  printf("[client %d] connect!\n",new_socket);
  return;
}

void ProcessNewSocket(int epoll_fd,int new_socket)
{
  //1.进行读写数据
  char buf[1024]={0};
  ssize_t read_size = read(new_socket, buf, sizeof(buf) - 1);
  if (read_size < 0) {
    perror("read");
    return;
  }
  if (read_size == 0) {
    //2.一旦读到返回值为0,对端关闭了文件描述符,本端也应该关闭文件描述符
    //并且也应该把文件描述符从epoll之中删除掉
    close(new_socket);
    epoll_ctl(epoll_fd,EPOLL_CTL_DEL,new_socket,NULL);
    printf("[client %d] disconnect!\n",new_socket);
    return;
  }
  printf("[client %d] say %s\n",new_socket,buf);
  write(new_socket,buf,strlen(buf));
}
int main(int argc,char* argv[])
{
  if(argc<3)
  {
    printf("Usage: ./epoll_server [ip] [port]\n");
    return 1;
  }

  //1.创建并初始化socket
  int listen_socket=SocketInit(argv[1],atoi(argv[2]));
  if(listen_socket<0)
  {
    printf("SocketInit failed!\n");
    return 1;
  }
  //2.创建并初始化epoll对象
  //  把listen_socket放到epoll对象之中
  int fd=epoll_create(10);
  if(fd<0)
  {
    perror("epoll_create");
    return 1;
  }
  epoll_event event;
  event.events=EPOLLIN;//表示等待读就绪
  event.data.fd=listen_socket;
  int ret=epoll_ctl(fd,EPOLL_CTL_ADD,listen_socket,&event);
  if(ret<0)
  {
    perror("epoll_ctl");
    return -1;
  }
  //服务器初始化完成
  printf("ServerInit OK!\n");

  //进入循环
  while(1)
  {
    epoll_event output_event[100];
    int nfds=epoll_wait(fd,output_event,100,-1);
    if(nfds<0)
    {
      perror("epoll_wait");
      continue;
    }
    //epoll_wait的返回之后,都有哪些文件描述符就绪,就写到output_event里面
    int i=0;
    for(;i<nfds;++i)
    {
      if(listen_socket==output_event[i].data.fd)
      {
        //a)listen_socket就绪,调用accept
        ProcessListenSocket(fd,listen_socket);

      }
      else
      {
        //b)new_socket就绪,调用一次read/write
        ProcessNewSocket(fd,output_event[i].data.fd);
      }
    }
  }
  return 0;
}

优点

1.接口使用方便,将epoll对象的创建,文件描述符的注册和等待三种操作拆分开,等待操作循环进行,而创建epoll对象只在刚开始时调用。不用每次设置输入。
2.在注册新的文件描述符时不用从拷贝数据从用户到内核。只在返回时拷贝少量数据。epoll_wait返回时只拷贝就绪队列中的文件描述符,时间复杂度O(1),而select和poll将三种操作在一个函数中实现,每次循环都要进行用户态到内核态的数据拷贝,开销很大。
3.避免遍历。因为采用了回调机制,直接将就绪的fd加入就绪队列,而select在返回时需要遍历整个位图,来找到就绪的文件描述符,在用户态也要进行遍历。
4.可监控的文件描述符个数不受限制,只要有被注册的fd,就将其挂载到红黑树上。而select可监控的fd受fd_set的大小限制,不能监控特别多的文件描述符。

注意
epoll中未使用内存映射机制
内存映射机制:内核直接将就绪队列通过mmap的方式映射到用户态,避免内存拷贝的开销。
实际上,epoll_event是我们自己在栈上获得的内存,一定还是将内核数据拷到用户空间的内存中的。

适用场景:

适合处理多连接,但连接中只有一部分比较活跃,这时使用epoll非常合适。
因为在内核中epoll是根据每个fd上面的callback函数实现的,只有活跃的socket才会主动去调用回调函数,其他一般不会调用。比如各互联网APP的入口服务器,这时就适合使用epoll。
若只是系统内部,服务器和服务器之间进行通信,只有少数几个连接,此时就不太适合。或是有大量连接,并且所有的socket都处于活跃状态,如高速LAN环境,epoll的效率并不比select高,相反,由于过多使用epoll_ctl,其效率可能还不如select。但若是LAN环境,其效率会远高于select。

epoll的工作方式:

epoll有两种工作方式:水平触发和边缘触发
水平触发(LT):

  • 当epoll检测到socket事件就绪时,可以不立刻处理,或者只处理一部分
  • 当第一次未读取完缓冲区中所有数据,在第二次调用epoll_wait时,epoll_wait会立刻返回并通知socket读事件就绪。
  • 当缓冲区中所有数据处理完,再调用epoll_wait,它才不会立刻返回
  • 支持阻塞读写和非阻塞读写

边缘触发(ET)

  • 当epoll检测到socket事件就绪时,必须立刻处理
  • 当第一次未读取完缓冲区中所有数据,在第二次调用epoll_wait时,epoll_wait不会再返回了。
  • 在ET模式下,文件描述符上的时间就绪后,只有一次处理机会
  • 比起LT更加高效,因为epoll_wait返回的次数少了很多

总之,ET是高速模式,强制要求一次读取全部,强制程序员把代码写得更高效。
在我们将socket注册到epoll句柄时使用EPOLLET标志,其会进入ET工作模式。
select和epoll默认是工作在LT模式下的。无ET模式。

epoll中的惊群问题:

惊群现象是指多个进程或线程在同时阻塞等待同一个事件,若这个事件发生,会唤醒所有的进程或线程,但最终只有一个进程或线程处理该事件,其他进程或线程在失败后重新进入休眠状态,这种性能浪费就是惊群。
惊群造成的结果是系统对用户的进程或线程频繁做无效的调度,上下文切换,系统性能大大降低。

网络编程中常用到多进程或多线程模型。父进程首先创建socket,接着bind,再监听listen,然后通过fork创建多个子进程,每个子进程继承父进程的socket,接着调用accept等待网络连接。此时有多个子进程同时等待连接,当有连接到来时,这些子进程同时被唤醒,即为”惊群“。

其实在Linux2.6版本后,内核已经解决了accept()函数的惊群问题,处理方式为:当内核接收到一个客户端连接后,只会唤醒等待队列上的第一个进程或线程,若服务器采用accept阻塞调用方式,在最新的linux系统上,已经没有了”惊群“问题。

但实际工作中的服务器程序中,我们常使用select、poll、epoll机制,服务器并不是阻塞在accept上,而是阻塞在select、poll、epoll_wait上,此时的惊群仍要考虑。

因为这些进程都epoll_wait()同一个listen_fd,操作系统又无从判断由谁来负责accept,索性干脆全部叫醒……),但最终只会有一个进程成功accept,其他进程accept失败。

解决惊群问题
Nginx中使用mutex互斥锁解决这个问题,具体措施有使用全局互斥锁,每个子进程在epoll_wait()之前先去申请锁,申请到则继续处理,获取不到则等待,并设置了一个负载均衡的算法(当某一个子进程的任务量达到总设置量的7/8时,则不会再尝试去申请锁)来均衡各个进程的任务量

猜你喜欢

转载自blog.csdn.net/shidantong/article/details/81048001