网络编程---I/O多路转接之epoll

I/O多路转接之epoll

  • 1. epoll的相关系统调用
  • 2. epoll原理
  • 3. epoll的优点
  • 4. 实现EpollServer完整代码
  • 5. epoll工作方式
    • 5.1 水平触发(LT模式)
    • 5.2 边缘触发(ET)
    • 5.3 对比LT和ET

I/O多路转接之select链接: link.

I/O多路转接之poll链接:链接: link.

epoll它几乎具备了之前所说的一切优点,摒弃了一切的缺点被公认为Linux2.6下性能最好的多路I/O就绪通知方法

1. epoll的相关系统调用

epoll_create

int epoll_create(int size);
创建一个epoll的句柄(也就是文件描述符).(因为返回值本身也就是文件描述符)

  • 自从linux2.6.8之后,size参数是被忽略的.
  • 用完之后, 必须调用close()关闭.

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

epoll的事件注册函数.

  • 它不同于select()是在监听事件时告诉内核要监听什么类型的事件, 而是在这里先注册要监听的事件类型.
  • 第一个参数是epoll_create()的返回值(epoll的句柄).
  • 第二个参数表示动作,用三个宏来表示.
  • 第三个参数是需要监听的fd.
  • 第四个参数是告诉内核需要监听什么事

第二个参数的取值:

  1. EPOLL_CTL_ADD :注册新的fd到epfd中;
  2. EPOLL_CTL_MOD :修改已经注册的fd的监听事件;
    EPOLL_CTL_DEL :从epfd中删除

struct epoll_event结构如下:
网络编程---I/O多路转接之epoll_第1张图片

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

  • EPOLLIN : 表示对应的文件描述符
  • EPOLLOUT : 表示对应的文件描述符
  • EPOLLPRI : 表示对应的文件描述符
  • EPOLLERR : 表示对应的文件描述符
  • EPOLLHUP : 表示对应的文件描述符
  • EPOLLET : 将EPOLL设为边缘触发(Edge Triggered)模式, 这是相对于水平触发(Level Triggered)来说的.
  • EPOLLONESHOT:只监听一次事件, 当监听完这次事件之后, 如果还需要继续监听这个socket的话, 需要
  • 再次把这个socket加入到EPOLL队列里.

epoll_wait

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

收集在epoll监控的事件中已经发送的事件.

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

2. epoll原理

  1. 调用epoll_create:操作系统会1.创建一颗红黑树2.建立回调机制3.创建就绪队列
  2. 调用epoll_ctl:添加、删除、修改红黑树结点,建立对应文件描述符的回调函数
  3. 从就绪队列中直接取就绪结点即可

创建一颗红黑树,红黑树的本质是一颗近似平衡的二叉搜索树,当有需要关心什么文件描述符所对应的什么事件的时候,就好比喻创建了一个红黑树结点,里面放着fd和event,然后把他插入到红黑树当中,当事件就绪时,就可以执行原先在创建时所写的回调函数,然后把回调函数返回的event放到最开始所创建的就绪队列中

总结一下, epoll的使用过程就是三部曲:

  • 调用epoll_create创建一个epoll句柄;
  • 调用epoll_ctl, 将要监控的文件描述符进行注册;
  • 调用epoll_wait, 等待文件描述符就绪

比较官方的解释步骤,但是不太通俗易懂,如果想要捋顺核心步骤,看上面几句话即可。

网络编程---I/O多路转接之epoll_第2张图片 - 当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体中有两个成员与epoll的使用方式密切相关
网络编程---I/O多路转接之epoll_第3张图片

  • 每一个epoll对象都有一个独立的eventpoll结构体,用于存放通过epoll_ctl方法向epoll对象中添加进来的事件. 这些事件都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效的识别出来(红黑树的插入时间效率是lgn,其中n为树的高度).
  • 而所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当响应的事件发生时会调用这个回调方法.
  • 这个回调方法在内核中叫ep_poll_callback,它会将发生的事件添加到rdlist双链表中. 在epoll中,对于每一个事件,都会建立一个epitem结构体.
    网络编程---I/O多路转接之epoll_第4张图片
  • 当调用epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可.
  • 如果rdlist不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户. 这个操作的时间复杂度是O(1).

3. epoll的优点

select和poll需要轮询的检测那些文件描述符就绪了,在这个过程中既检查了已经就绪的,还检查了没有就绪的,然而检查了没有就绪的这个过程无非是一个浪费时间的过程。epoll就不一样了,他每次都是直接从就绪队列中拿已经就绪的文件描述符,这个过程根本不可能拿到没有就绪的文件描述符,所以对于epoll来说找到已经就绪的文件描述符的时间复杂度是O(1),然而select和poll的时间复杂度则为O(N).(其实还可以用一个小故事来解释:老师说:把作业都放到座位上,我要挨着检查谁没有写作业,那么这个过程就会即检查到已经写作业的也会检查到没有写作业的同学(也就是select和poll的方式)。换种说法:现在已经把作业写完的同学,就拿过来让我检查(这句话就相当于与建立了回调函数,也就是提供了一种方法,当达到什么条件的时候,就可以去做某件事),然后就可以回家,那么此时来找老师检查的同学就都是已经把作业写完的同学了,就不会检查到没有写作业的,这就是epoll的方式)

  • 接口使用方便: 虽然拆分成了三个函数, 但是反而使用起来更方便高效. 不需要每次循环都设置关注的文件描述符, 也做到了输入输出参数分离开
  • 数据拷贝轻量: 只在合适的时候调用 EPOLL_CTL_ADD 将文件描述符结构拷贝到内核中, 这个操作并不频繁(而select/poll都是每次循环都要进行拷贝)
  • 事件回调机制: 避免使用遍历, 而是使用回调函数的方式, 将就绪的文件描述符结构加入到就绪队列中,
  • epoll_wait 返回直接访问就绪队列就知道哪些文件描述符就绪. 这个操作时间复杂度O(1). 即使文件描述符数目很多, 效率也不会受到影响.
  • 没有数量限制: 文件描述符数目无上限

4. 实现EpollServer完整代码

就想要实现一个client端给server端发送一个定长的字符串,当达到这个定长字符串长度之后,server端给client端返回刚才给server发送的内容,然后关闭连接。(其实这个过程就是http中的request和response,当server端不接收到完整的一个request的时候,是不会作出response的)

main.cc

#include"EpollServer.hpp"

void Usage(std::string proc)
{
  cout <<"Usage :\n\t" << proc << " port"<<endl;
}

int main(int argc,char *argv[])
{
  if(argc != 2)
  {
    Usage(argv[0]);
    exit(0);
  }

  EpollServer *es = new EpollServer();
  es->InitServer();
  es->Start();

  return 0;
}

Sock.hpp

#pragma once 

#include
#include
#include

#include
#include
#include
#include
#include
#include

#include
#include
using namespace std;

#define BACKLOG 5
class Sock
{
  public:
    static int Socket()
    {
      int sock = socket(AF_INET,SOCK_STREAM,0);
      if(sock < 0)
      {
        cerr << "socket error!" << endl;
        exit(2);
      }
      return sock;
    }

    static void Bind(int sock,int port)
    {
      struct sockaddr_in local;
      bzero(&local,sizeof(local));

      local.sin_family = AF_INET;
      local.sin_port = htons(port);
      local.sin_addr.s_addr = htonl(INADDR_ANY);
      if(bind(sock,(struct sockaddr*)&local,sizeof(local)) < 0)
      {
        cerr << "bind error!"<< endl;
        exit(3);
      }
    }

    static void Listen(int sock)
    {
      if(listen(sock,BACKLOG) < 0)
      {
        cerr << "listen error!"<< endl;
        exit(4);
      }
    }

    static int Accept(int sock)
    {
      struct sockaddr_in peer;
      socklen_t len = sizeof(peer);
      int fd = accept(sock,(struct sockaddr*)&peer,&len);
      if(fd < 0)
      {
        cerr << "accept error!"<<endl;
      }
      return fd;
    }

    static void SetsockOpt(int sock)
    {
      //因为对于server来说,主动断开连接的时候会进入time_wait状态,所以要端口复用
      int opt = 1;
      setsockopt(sock,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt));
    }
};

EpollServer.hpp

#include"Sock.hpp"
#include
#define SIZE 64

//可以理解为定制了一个协议
class bucket
{
  public:
    char buffer[20];//这里所设定的是一个定长数组,可以把它换成我们所需要的request,如果不把这个完整的request接收完,那么服务器将不会给他response
    int pos;//这里定义这个变量,就是为了解决网络通信中的request可能一次性读不完,记录一下,直到读完这个resquest才算结束
    int fd;

    bucket(int sock):fd(sock),pos(0)
    {
      memset(buffer,0,sizeof(buffer));
    }
    ~bucket()
    {}
}; //目的就是要让每一个文件描述符都有一个对应的缓冲区,因为有可能一次读取上来的数据并不是完整的

class EpollServer
{
  private:
    int lsock;
    int port;
    int epfd; //需要一个epoll模型

  public:
    EpollServer(int _p = 8081):port(_p)
    {}

    void InitServer()
    {
      lsock = Sock::Socket();
      Sock::Bind(lsock,port);
      Sock::Listen(lsock);

      epfd = epoll_create(256); //这里面的参数并不重要,随便写
      if(epfd < 0){
        cerr << "epoll_create error!"<< endl;
        exit(5);
      }

      cout << "listen socket : " <<lsock << endl;
      cout << "epfd : " <<epfd << endl;
    }

    void AddEvent2Epoll(int sock,uint32_t event)
    {
      //对于第二个参数可以理解为动作增 删 修改三种,最后一个参数接收的是struct epoll_event* event 
      struct epoll_event ev;
      ev.events = EPOLLIN;
      //但是这里还没有完,因为对于struct epoll_event结构体来说还有一个date变量
      if(sock == lsock){
        //对于监听套接字是可以不设置缓冲区的,因为他只要是负责接收链接的,并不负责数据交互
        ev.data.ptr = nullptr;
      }
      else{
        ev.data.ptr = new bucket(sock);//要让每个文件描述符都有一个对应的缓冲区 
      }
      epoll_ctl(epfd,EPOLL_CTL_ADD,sock,&ev);
    }

    void DelEventFromEpoll(int sock)
    {
      close(sock);
      epoll_ctl(epfd,EPOLL_CTL_DEL,sock,nullptr);
    }

    void HandlerEvents(struct epoll_event revs[],int num)
    {
      //这里就相当于从就绪队列中取出来
      for(int i = 0; i < num; ++i)
      {
        uint32_t ev = revs[i].events;//已经就绪的事件的内容
        if(ev & EPOLLIN)
        {
          //读事件就绪还分为两种
          if(revs[i].data.ptr != nullptr){
              //说明是一个普通的sock
              bucket *bp = (bucket*)revs[i].data.ptr;
              //在添加sock进epoll模型的时候,给对应的sock开了一个属于他的缓冲区
              //一直读到bucket里面的buffer满的时候,再开始response
              ssize_t s = recv(bp->fd,bp->buffer+bp->pos,sizeof(bp->buffer)-bp->pos,0); //recv是4个参数,此时最后一个参数已经不起作用了
              if(s > 0){
                //如果是request那就一直读,读到空行就结束
                bp->pos += s;
                cout << "client# " << bp->buffer<<endl;
                if(bp->pos >= sizeof(bp->buffer)){
                  //可以理解为此时request已经接收完了,此时服务器应该开始response
                  //但是此时并不知道这个文件描述符的写是件是否就绪了,所以还需要把这个sock从原来的关心读时间修改为写事件
                  struct epoll_event temp;
                  temp.events = EPOLLOUT;
                  temp.data.ptr = bp;
                  epoll_ctl(epfd,EPOLL_CTL_MOD,bp->fd,&temp);
                }
              }
              else if(s == 0){
                //close(bp->fd);
                //还需要把这个结点从红黑树里面拿出来
                //epoll_ctl(epfd,EPOLL_CTL_DEL,bp->fd,nullptr);//因为这是一个红黑树,所以通过Key值就可以找到对应的value,所以这里不再需要写清楚这个value
                //原来还给他创建了一个bucket,也要删除掉
                DelEventFromEpoll(bp->fd);
                delete bp;
              }
              else{
                cerr << "recv error"<< endl;
                epoll_ctl(epfd,EPOLL_CTL_DEL,bp->fd,nullptr);
                delete bp;
              }
            } 
          else{
              //listen sock 
              int sock = Sock::Accept(lsock);
              if(sock > 0){
                    //把新的文件描述符添加到epoll模型当中
                    AddEvent2Epoll(sock,EPOLLIN);//相当与创建结点,添加到红黑树当中
               }
            } 
        }
        else if(ev & EPOLLOUT){
          //此时说明你原来给我发的20个字节我已经都读到了
          //因为返回的内容都还在这个sock所对应的buffer中存放着
          bucket *bp = (bucket*)revs[i].data.ptr; 
          send(bp->fd,bp->buffer,sizeof(bp->buffer),0);
          //server端进行一个echo,然后就算结束了一次完整通信,就可以关闭文件描述符了
          DelEventFromEpoll(bp->fd);
          delete bp;
        }
        else{
          //other events 
        }
      }
    }
    void Start()
    {
      //此时和select/poll都一样,最开始的时候只关心lsock的读时间是否就绪(是否有新的链接到来)
      //对epoll_ctl在进行一层封装最大的好处就是方便我们后续使用
      AddEvent2Epoll(lsock,EPOLLIN);
      //int timeout = 1000;
      struct epoll_event revs[SIZE];//把所有已经就绪的文件描述符给我们放到这个缓冲区里面
      for(;;){
          //走到这里的时候其实OS已经知道了应该帮你关心那些文件描述符上面的那些事件是否发生
          //但是这里可能会有一个疑惑?就是缓冲区定义了SIZE大小也就是64,但是假设现在已经就绪了500个文件描述符呢?
          //事实上并不影响,拿下来多少个就处理多少个,没拿下的处理的,下一次就会去拿过来处理
          //这里的SIZE就相当于原来所学的,你期望处理多少个,但是num作为返回值表示实际要处理多少个文件描述符
          //SIZE个那我怎么知道是哪几个文件描述符就绪呢?难道是要遍历整个SIZE个吗? 并不是,他会把已经就绪的文件描述符从0号数组下标依次放入,所以只需要遍历num个就好了
          int num = epoll_wait(epfd,revs,SIZE,-1);//因为第二个和第三个都是输出型参数
          switch(num){
            case 0:
              cout << "time out!"<< endl;
              break;
            case -1:
              cerr << "epoll_wait error!"<<endl;
              break;
            default:
              HandlerEvents(revs,num);
              break;
          }
      }
    }

    ~EpollServer()
    {
      close(lsock);
      close(epfd);//因为epfd模型规定要关闭,并且他们本身也都是文件描述符
    }

};

网络编程---I/O多路转接之epoll_第5张图片

5. epoll工作方式

你正在吃鸡, 眼看进入了决赛圈, 你妈饭做好了, 喊你吃饭的时候有两种方式:

  1. 如果你妈喊你一次, 你没动, 那么你妈会继续喊你第二次, 第三次…(亲妈, 水平触发LT)
  2. 如果你妈喊你一次, 你没动, 你妈就不管你了(后妈, 边缘触发ET)

假如有这样一个例子:

  • 我们已经把一个tcp socket添加到epoll描述符
  • 这个时候socket的另一端被写入了2KB的数据
  • 调用epoll_wait,并且它会返回. 说明它已经准备好读取操作(调用的过程就是从就绪队列中拷贝数据到用户缓冲区中)
  • 然后调用read, 只读取了1KB的数据
  • 继续调用epoll_wait…

5.1 水平触发(LT模式)

简单说:如果底层的数据就绪,那么就一直向上层通知有数据准备好了,快来取,如果没有取完,那么也会不停的发送通知。

epoll默认状态下就是LT工作模式.

  • 当epoll检测到socket上事件就绪的时候, 可以不立刻进行处理. 或者只处理一部分.
  • 如上面的例子, 由于只读了1K数据, 缓冲区中还剩1K数据, 在第二次调用 epoll_wait 时, epoll_wait仍然会立刻返回并通知socket读事件就绪.直到缓冲区上所有的数据都被处理完, epoll_wait 才不会立刻返回.
  • 支持阻塞读写和非阻塞读写

5.2 边缘触发(ET)

简单说:底层数据来了,回想上层通知一次,数据准备就绪,快来取,如果没有取或者一次性没有取完,那么数据就会被丢弃,且再不会向上层发送通知。

  • 当epoll检测到socket上事件就绪时, 必须立刻处理.
  • 如上面的例子, 虽然只读了1K的数据, 缓冲区还剩1K的数据, 在第二次调用 epoll_wait 的时候, epoll_wait 不会再返回了.
  • 也就是说, ET模式下, 文件描述符上的事件就绪后, 只有一次处理机会.
  • ET的性能比LT性能更高( epoll_wait 返回的次数少了很多). Nginx默认采用ET模式使用epoll.
  • 只支持非阻塞的读写
    网络编程---I/O多路转接之epoll_第6张图片

5.3 对比LT和ET

  1. LT是 epoll 的默认行为. 使用 ET 能够减少 epoll 触发的次数. 但是代价就是强逼着程序员一次响应就绪过程中就把所有的数据都处理完.

  2. 相当于一个文件描述符就绪之后, 不会反复被提示就绪, 看起来就比 LT 更高效一些(简单说为什么ET比LT高效:因为ET的通知方式没有做重复的动作). 但是在 LT 情况下如果也能做到每次就绪的文件描述符都立刻处理, 不让这个就绪被重复提示的话, 其实性能也是一样的

  3. 另一方面, ET 的代码复杂程度更高了

epoll惊群

  • 在多线程或者多进程环境下,有些人为了提高程序的稳定性,往往会让多个线程或者多个进程同时在epoll_wait监听的socket描述符。当一个新的链接请求进来时,操作系统不知道选派那个线程或者进程处理此事件,则干脆将其中几个线程或者进程给唤醒,而实际上只有其中一个进程或者线程能够成功处理accept事件,其他线程都将失败,且errno错误码为EAGAIN。这种现象称为惊群效应,结果是肯定的,惊群效应肯定会带来资源的消耗和性能的影响。

epoll的使用场景

epoll的高性能, 是有一定的特定场景的. 如果场景选择的不适宜, epoll的性能可能适得其反.

  • 对于多连接, 且多连接中只有一部分连接比较活跃时, 比较适合使用epoll.
  • 例如, 典型的一个需要处理上万个客户端的服务器, 例如各种互联网APP的入口服务器, 这样的服务器就很适合epoll.
  • 如果只是系统内部, 服务器和服务器之间进行通信, 只有少数的几个连接, 这种情况下用epoll就并不合适. 具体要根据需求和场景特点来决定使用哪种IO模型

喜欢问关于select、poll、epoll的问题

  • select的缺点、poll的缺点、各自的缺点以及不同
  • epoll接口的基本使用、为什么会那么高效、和前两种有什么差别
  • 他们的使用场景:很多的长链接

参考blog:

epoll的惊群详解链接: link.

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