Linux高级IO ------ poll ,epoll(重要)

目录

Select,Poll,Epoll更适合于长连接

poll

1.poll函数

2.poll服务器

3.poll的优点

4.poll的缺点

epoll

1.初识epoll

2.epoll相关的三个系统调用接口

3.epoll的工作原理

4.epoll服务器

5.epoll的优点

6.epoll的工作方式 


 

Select,Poll,Epoll更适合于长连接

poll

1.poll函数

函数: 

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

参数:

  • fds:一个poll函数监视的结构列表,每一个元素包含三部分内容:文件描述符、监视的事件集合、就绪的事件集合。
  • nfds:表示fds数组的长度。
  • timeout:表示poll函数的超时时间,单位是毫秒(ms)。

参数timeout的取值:

  • -1:poll调用后进行阻塞等待,直到被监视的某个文件描述符上的某个事件就绪。
  • 0:poll调用后进行非阻塞等待,无论被监视的文件描述符上的事件是否就绪,poll检测后都会立即返回。
  • 特定的时间值:poll调用后在指定的时间内进行阻塞等待,如果被监视的文件描述符上一直没有事件就绪,则在该时间后poll进行超时返回。

返回值

  • 如果函数调用成功,则返回有事件就绪的文件描述符个数。
  • 如果timeout时间耗尽,则返回0。
  • 如果函数调用失败,则返回-1,同时错误码会被设置。

poll调用失败时,错误码可能被设置为:

  • EFAULT:fds数组不包含在调用程序的地址空间中。
  • EINTR:此调用被信号所中断。
  • EINVAL:nfds值超过RLIMIT_NOFILE值。
  • ENOMEM:核心内存不足。
     

(1)struct pollfd结构 

  • fd:特定的文件描述符,若设置为负值则忽略events字段并且revents字段返回0。
  • events:需要监视该文件描述符上的哪些事件。
  • revents:poll函数返回时告知用户该文件描述符上的哪些事件已经就绪。

 

(2)events和revents的取值

事件描述是否可作为输入是否可作为输出
POLLIN数据(包括普通数据和优先数据)可读
POLLRDNORM普通数据可读
POLLRDBAND优先级带数据可读(Linux不支持)
POLLPRI高优先级数据可读,比如TCP带外数据
POLLOUT数据(包括普通数据和优先数据)可写
POLLWRNORM普通数据可写

POLLWRBAND优先级带数据可写
POLLRDHUPTCP连接被对方关闭,或者对方关闭了写操作,它由GNU引入
POLLERR错误
POLLHUP挂起。比如管道的写端被关闭后,读端描述符上将收到POLLHUP事件
POLLNVAL文件描述符没有打开

                         

  •  大写的定义取值实际都是以宏的方式进行定义的,它们的二进制序列当中有且只有一个比特位是1,且为1的比特位是各不相同的。
  • 因此在调用poll函数之前,可以通过 运算符将要监视的事件添加到events成员当中。
  • 在poll函数返回后,可以通过 运算符检测revents成员中是否包含特定事件,以得知对应文件描述符的特定事件是否就绪。

                

2.poll服务器

  • PollServer类
#pragma once 

#include<iostream>
#include<poll.h>
#include"sock.hpp"

#define BACK_LOG 5
#define NUM 1024
#define DFL_FD -1

namespace ns_poll{

class Poll_Server{
    private:
       int listen_sock; //监听套接字
       int port;  //端口号
    public:
       Poll_Server(int _port):port(_port){};
       ~Poll_Server()
       {
          if(listen_sock >=0 ){
             close(listen_sock);
          }
       }

       void InitPollServer()
       {
           listen_sock = ns_sock::Sock::Socket();
           ns_sock::Sock::Bind(listen_sock,port);
           ns_sock::Sock::Listen(listen_sock,BACK_LOG);
       }

       void Run()
       {
         struct pollfd fds[NUM];
         ClearPollfds(fds,NUM);
         SetPollfds(fds,NUM,listen_sock);
         for(;;){
            switch(poll(fds,NUM,-1)){ //阻塞式等待
               case 0:
                  std::cout << "timeout ... " << std::endl;
                  break;
               case -1:
                  std::cerr << "poll error" << std::endl;
                  break;
               default:
                  //正常处理事件
                  //std::cout << "有事件发生 ... " << std::endl;
                  HandlerEvent(fds,NUM);
                  break;

            }
         }
       }


       void HandlerEvent(struct pollfd* fds,int num)
       {
          for(int i = 0; i < num ; ++i){
             if(fds[i].fd == DFL_FD){
               continue;
             }
             
             //有效位置
             if(fds[i].fd == listen_sock && (fds[i].revents&POLLIN)){ //连接事件就绪
                struct sockaddr_in peer;
                memset(&peer, 0, sizeof(peer));
 				    socklen_t len = sizeof(peer);
                int sock = accept(listen_sock,(struct sockaddr*)&peer,&len);
                if(sock < 0){
                  std::cerr << "accept error" << std::endl;
                  continue;
                }

                std::string _ip = inet_ntoa(peer.sin_addr);
                int _port = ntohs(peer.sin_port);
                std::cout << "get a new link [ " << _ip << " : " << _port << " ]" << std::endl;

                //将获取到的套接字添加到fds数组中,并关心其读事件
               if(!SetPollfds(fds,num,sock)){
                  close(sock);
                  std::cout << "poll server is full, close fd: " << sock << std::endl;
               }

             }
             else if(fds[i].revents&POLLIN){ //读事件就绪
               char buffer[1024];
               ssize_t size = read(fds[i].fd,buffer,sizeof(buffer)-1);
               if(size > 0){
                 buffer[size-1] = 0; //清除 /n
                 std::cout << "echo# " << buffer << std::endl;
               }
               else if(size == 0){ //对方关闭
                 std::cout << "client quit" << std::endl;
                 close(fds[i].fd);
                 UnSetPollfds(fds,i);
               }
               else{ //读取错误
                 std::cerr << "read error" << std::endl;
                 close(fds[i].fd);
                 UnSetPollfds(fds,i);
               }

             }
             else{ //保证代码逻辑
               //TODO
             }
          }
       }
       

    private: //内部函数使用
       void ClearPollfds(struct pollfd* fds,int num)
       {
           for(int i = 0 ; i < num ; ++i){
             fds[i].fd = DFL_FD;
             fds[i].events = 0;
             fds[i].revents = 0;
           }
       } 

       void UnSetPollfds(struct pollfd* fds,int pos)
       {
          fds[pos].fd = DFL_FD;
          fds[pos].events = 0;
          fds[pos].revents = 0;
       }  

       bool SetPollfds(struct pollfd* fds,int num,int sock)
       {
           for(int i = 0 ; i < num ; ++i){
              if(fds[i].fd == DFL_FD){ //该位置没被使用
                  fds[i].fd = sock;
                  fds[i].events |= POLLIN; //添加读事件
                  return true;
              }
           }

           return false; //fds数组已满
       }

};

}

(1)初始化服务器

  • Poll_Server类当中也只需要包含监听套接字和端口号两个成员变量,在poll服务器绑定时直接将IP地址设置为INADDR_ANY尽即可。
  • 在构造Poll_Server对象时,需要指明poll服务器的端口号,当然也可以在初始化poll服务器的时候指明。
  • 在初始化poll服务器的时候调用Sock类当中的函数,依次进行套接字的创建、绑定和监听即可,这里的Sock类和之前实现的一模一样。
  • 在析构函数中可以选择调用close函数将监听套接字进行关闭,但实际也可以不进行该动作,因为服务器运行后一般是不退出的。

(2)Run函数

  • 服务器初始化完毕后就可以开始运行了,而poll服务器要做的就是不断调用poll函数,当事件就绪时对应执行某种动作即可。
  • 首先,在poll服务器开始死循环调用poll函数之前,需要定义一个fds数组,该数组当中的每个位置都是一个struct pollfd结构,后续调用poll函数时会作为参数进行传入。先将fds数组当中每个位置初始化为无效,并将监听套接字添加到fds数组当中,表示服务器刚开始运行时只需要监视监听套接字的读事件,即获得新连接。
  • 此后,poll服务器就不断调用poll函数监视读事件是否就绪。如果poll函数的返回值大于0,则说明poll函数调用成功,此时已经有文件描述符的读事件就绪,接下来就应该对就绪事件进行处理。如果poll函数的返回值等于0,则说明timeout时间耗尽,此时直接准备进行下一次poll调用即可。如果poll函数的返回值为-1,则说明poll调用失败,此时也让服务器准备进行下一次poll调用,但实际应该进一步判断错误码,根据错误码来判断是否应该继续调用poll函数。
     

(3)HandlerEvent函数 

当poll检测到有文件描述符的读事件就绪,就会在其对应的struct pollfd结构中的revents成员中添加读事件并返回,接下来poll服务器就应该对就绪事件进行处理了,事件处理过程如下:

  • 首先遍历fds数组中的每个struct pollfd结构,如果该结构当中的fd有效,且revents当中包含读事件,则说明该文件描述符的读事件就绪,接下来就需要进一步判断该文件描述符是监听套接字还是与客户端建立的套接字。
  • 如果是监听套接字的读事件就绪,则调用accept函数将底层建立好的连接获取上来,并将获取到的套接字添加到fds数组当中,表示下一次调用poll函数时需要监视该套接字的读事件。
  • 如果是与客户端建立的连接对应的读事件就绪,则调用read函数读取客户端发来的数据,并将读取到的数据在服务器端进行打印。
  • 如果在调用read函数时发现客户端将连接关闭或read函数调用失败,则poll服务器也直接关闭对应的连接,并将该连接对应的文件描述符从fds数组当中清除,表示下一次调用poll函数时无需再监视该套接字的读事件。
  • 因为这里将fds数组的大小是固定设置的,因此在将新获取连接对应的文件描述符添加到fds数组时,可能会因为fds数组已满而添加失败,这时poll服务器只能将刚刚获取上来的连接对应的套接字进行关闭。

        

(4)poll_server.cc

#include "poll_server.hpp"
#include <iostream>
#include <cstdlib>

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

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

    unsigned short port = atoi(argv[1]);

    ns_poll::Poll_Server* poll_svr = new ns_poll::Poll_Server(port);
	//server一般运行起来就不停止了,是否delete不影响

    poll_svr->InitPollServer();
    poll_svr->Run();

    return 0;
}

        

  • poll服务器在调用poll函数时,将timeout的值设置成了-1,因此运行服务器后如果没有客户端发来连接请求,那么服务器就会在调用poll函数后进行阻塞等待。
  • 用telnet工具连接poll服务器后,poll服务器调用的poll函数在检测到监听套接字的读事件就绪后就会调用accept获取建立好的连接,并打印输出客户端的IP和端口号,此时客户端发来的数据也能够成功被poll服务器收到并进行打印输出。
  • poll服务器也是一个单进程服务器,但是它也可以同时为多个客户端提供服务。
  • 当服务器端检测到客户端退出后,也会关闭对应的连接,并将对应的套接字从fds数组当中清除。 

        

3.poll的优点

  • struct pollfd结构当中包含了events和revents,相当于将select的输入输出型参数进行分离,因此在每次调用poll之前,不需要像select一样重新对参数进行设置。

  • poll可监控的文件描述符数量没有限制。

  • 当然,poll也可以同时等待多个文件描述符,能够提高IO的效率。

说明:

  • 虽然代码中将fds数组的元素个数定义为1024,但fds数组的大小是可以继续增大的,poll函数能够帮你监视多少个文件描述符是由传入poll函数的第二个参数决定的。
  • 而fd_set类型只有1024个比特位,因此select函数最多只能监视1024个文件描述符。

                

4.poll的缺点

  • 和select函数一样,当poll返回后,需要遍历fds数组来获取就绪的文件描述符。
  • 每次调用poll,都需要把大量的struct pollfd结构从用户态拷贝到内核态,这个开销也会随着poll监视的文件描述符数目的增多而增大。
  • 同时每次调用poll都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大。
     

        

                        

                        

epoll

1.初识epoll

  • epoll系统调用也可以让我们的程序同时监视多个文件描述符上的事件是否就绪,与select和poll的定位是一样的,适用场景也相同。

  • epoll在命名上比poll多了一个e,这个e可以理解成是extend,epoll就是为了同时处理大量文件描述符而改进的poll。

  • epoll在2.5.44内核中被引进,它几乎具备了select和poll的所有优点,被公认为Linux2.6下性能最好的多路I/O就绪通知方法。

                         

2.epoll相关的三个系统调用接口

(1) epoll_create函数

epoll_create函数用于创建一个epoll模型 :

  • int epoll_create(int size);

参数:

  • size:自从Linux2.6.8之后,size参数是被忽略的,但size的值必须设置为大于0的值。

返回值:

  • epoll模型创建成功返回其对应的文件描述符,否则返回-1,同时错误码会被设置。 

注意: 当不再使用时,必须调用close函数关闭epoll模型对应的文件描述符,当所有引用epoll实例的文件描述符都已关闭时,内核将销毁该实例并释放相关资源。

                                 

(2)epoll_ctl函数

epoll_ctl函数用于向指定的epoll模型中注册事件

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

参数:

  • epfd:指定的epoll模型。
  • op:表示具体的动作,用三个宏来表示。
  • fd:需要监视的文件描述符。
  • event:需要监视该文件描述符上的哪些事件。

返回值:

  • 函数调用成功返回0,调用失败返回-1,同时错误码会被设置。

                

①第二个参数op的取值有以下三种:

  • EPOLL_CTL_ADD:注册新的文件描述符到指定的epoll模型中(往红黑树中新增节点)。
  • EPOLL_CTL_MOD:修改已经注册的文件描述符的监听事件(修改红黑树中特定节点的数据)。
  • EPOLL_CTL_DEL:从epoll模型中删除指定的文件描述符(删除红黑树中的节点)。

                 

②第四个参数对应的struct epoll_event结构:

  • struct epoll_event结构中有两个成员,第一个成员events表示的是需要监视的事件,第二个成员data是一个联合体结构,一般选择使用该结构当中的fd,表示需要监听的文件描述符。

                 

③events的常用取值如下:

  • EPOLLIN:表示对应的文件描述符可以读(包括对端SOCKET正常关闭)。
  • EPOLLOUT:表示对应的文件描述符可以写。
  • EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来)。
  • EPOLLERR:表示对应的文件描述符发送错误。
  • EPOLLHUP:表示对应的文件描述符被挂断,即对端将文件描述符关闭了。
  • EPOLLET:将epoll的工作方式设置为边缘触发(Edge Triggered)模式。
  • EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听该文件描述符的话,需要重新将该文件描述符添加到epoll模型中。
     

④events取值实际也是以宏的方式进行定义的,它们的二进制序列当中有且只有一个比特位是1,且为1的比特位是各不相同的。 

                

(3)epoll_wait 函数

epoll_ctl函数用于收集监视的事件中已经就绪的事件:

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

参数:

  • epfd:指定的epoll模型。
  • events:内核会将已经就绪的事件拷贝到events数组当中(events不能是空指针,内核只负责将就绪事件拷贝到该数组中,不会帮我们在用户态中分配内存)。
  • maxevents:events数组中的元素个数,该值不能大于创建epoll模型时传入的size值。
  • timeout:表示epoll_wait函数的超时时间,单位是毫秒(ms)。

参数timeout的取值:

  • -1:epoll_wait调用后进行阻塞等待,直到被监视的某个文件描述符上的某个事件就绪。
  • 0:epoll_wait调用后进行非阻塞等待,无论被监视的文件描述符上的事件是否就绪,epoll_wait检测后都会立即返回。
  • 特定的时间值:epoll_wait调用后在直到的时间内进行阻塞等待,如果被监视的文件描述符上一直没有事件就绪,则在该时间后epoll_wait进行超时返回。

返回值:

  • 如果函数调用成功,则返回有事件就绪的文件描述符个数。
  • 如果timeout时间耗尽,则返回0。
  • 如果函数调用失败,则返回-1,同时错误码会被设置。

epoll_wait调用失败时,错误码可能被设置为:

  • EBADF:传入的epoll模型对应的文件描述符无效。
  • EFAULT:events指向的数组空间无法通过写入权限访问。
  • EINTR:此调用被信号所中断。
  • EINVAL:epfd不是一个epoll模型对应的文件描述符,或传入的maxevents值小于等于0。
     

                 

(4)epoll模型 

 ①所谓的epoll模型是通过进程的方式,指针的方式找到这—套机制。调用epoll_create 返回值是—个fd,就是说我们可以创建很多epoll模型彼此之间是独立的,有不同的fd指向自己的epoll模型

②回调机制:我们在网卡驱动层面注册的一个数据就绪的方法/函数,比如 read就绪,write就绪函数;当网卡当中/OS内部识别到了你有数据或者空间时会自动完成回调对应的方法。

  •  网卡驱动以及网络协议栈当中本身也是会给我们提供一些让我们注册回调的机制 
  • 使用回调机制的好处,将OS解放了
     

③用户通过epoll_ctl填写到底层fd:event,即告诉OS关系哪些fd上面的哪些事件 

  • 1.增加,删除,修改红黑树中的节点(添加,删除回调机制)
  • .创建节点的时候同样在OS底层也注册了该fd所对应的那—套回调机制(当事件就绪了OS帮我们生成一个ready节点,放到就绪队列)
     

④当红黑树中某个节点中的fd事件就绪, 在OS层面使用驱动层的功能完成某些回调功能 ;回调功能执行 :OS在系统层帮我们生成一个新的节点并且链接到我们的就绪队列当中
        

⑤如果这里我们调用epoll_ctl 的时候,我们设置了fd3,4.5没有设置6号fd,回调的时候一旦6号fd就绪会不会通知上层 ?   

  • 不会,所以epoll_ctl除了向红黑树节点中添加你要关心哪些fd上的什么事件,当底层fd就绪的时侯,红黑树就作为了就绪函数在进行就绪事件通知时的依据,凡是在这棵树里面的fd :events我才往就绪队列里面放,不在就不放。即一旦注册了epoll_ctl,红黑树节点就表征了底层回调的时候会向你怎么回调的问题。

                

⑥epoll模型自己去设计可以把它们封装成类,写在一起,这里的epoll模型不就是个对象,这些东西各自一个结构然后用一个统一的结构包含在一起就可以用这个类定义对象,就是一个epoll模型;然后把这个对象放到文件的struct file里面,就可以通过fd找到这个epoll模型

                

⑦ epoll底层采用回调机制,来检测事件就绪;底层事件就绪的时候将 fd:event 拷贝到就绪队列,不断地通过epoll_wait,从队列中取节点。

                

⑧ select,poll这两个函数承担两种职责,而epoll通过接口就将两个职责分开了

                        

⑨当一个数据从网络中来的时候OS怎么知道网卡里面有数据来了? – 中断机制

  • 所有的外设,CPU是有针脚的,外设虽然不能和CPU直接进行数据交互但是可以进行事件交互。相当于我们的外设直接通过针脚,当有数据来时可以通过某种针脚向CPU发送某些中断。中断特别像硬件级别的信号,其实就是电脉1111冲,通过网卡直接打到了CPU的针脚上,CPU一旦识别到有这个信号来的时候他就认为有中断了,而CPU中的每个针湖都是有它的纳号的,当它知道哪个针脚上面有信息的时侯他就立马反应到是编号为几的中断产生了,所以有中断号和中断向量表,中断上下文这样的概念。
  • OS本来是不知道有数据从网卡来的,OS正在运行的时候,网卡里一旦有数据网卡直接向CFU发信号告诉CPU现在网卡里面有数据了,OS就会识别到发送到CPU的信号-从硬件到软件-OS就知道有数据来了,OS就可以直接进行把网卡里面的数据搬到内存当中。这是通过软硬件完成的。

                

                        
3.epoll的工作原理

        

(1)当某一进程调用epoll_create函数时,Linux内核会创建一个eventpoll结构体,也就是我们所说的epoll模型,eventpoll结构体当中的成员rbr和rdlist与epoll的使用方式密切相关。

  • epoll模型当中的红黑树本质就是告诉内核,需要监视哪些文件描述符上的哪些事件,调用epll_ctl函数实际就是在对这颗红黑树进行对应的增删改操作。
  • epoll模型当中的就绪队列本质就是告诉内核,哪些文件描述符上的哪些事件已经就绪了,调用epoll_wait函数实际就是在从就绪队列当中获取已经就绪的事件。
struct eventpoll{
	...
	//红黑树的根节点,这棵树中存储着所有添加到epoll中的需要监视的事件
	struct rb_root rbr;
	//就绪队列中则存放着将要通过epoll_wait返回给用户的满足条件的事件
	struct list_head rdlist;
	...
}

                 

(2)在epoll中,对于每一个事件都会有一个对应的epitem结构体,红黑树和就绪队列当中的节点分别是基于epitem结构中的rbn成员和rdllink成员的,epitem结构当中的成员ffd记录的是指定的文件描述符值,event成员记录的就是该文件描述符对应的事件。 

  • 对于epitem结构当中rbn成员来说,ffd与event的含义是,需要监视ffd上的event事件是否就绪。
  • 对于epitem结构当中的rdlink成员来说,ffd与event的含义是,ffd上的event事件已经就绪了。
struct epitem{
	struct rb_node rbn; //红黑树节点
	struct list_head rdllink; //双向链表节点
	struct epoll_filefd ffd; //事件句柄信息
	struct eventpoll *ep; //指向其所属的eventpoll对象
	struct epoll_event event; //期待发生的事件类型
}

         

 (3)说明

  • 红黑树是一种二叉搜索树,因此必须有键值key,而这里的文件描述符就天然的可以作为红黑树的key值。
  • 调用epoll_ctl向红黑树当中新增节点时,如果设置了EPOLLONESHOT选项,当监听完这次事件后,如果还需要继续监听该文件描述符则需要重新将其添加到epoll模型中。本质就是当设置了EPOLLONESHOT选项的事件就绪时,操作系统会自动将其从红黑树当中删除。
  • 而如果调用epoll_ctl向红黑树当中新增节点时没有设置EPOLLONESHOT,那么该节点插入红黑树后就会一直存在,除非用户调用epoll_ctl将该节点从红黑树当中删除。

                         

(4)回调机制

所有添加到红黑树当中的事件,都会与设备(网卡)驱动程序建立回调方法,这个回调方法在内核中叫ep_poll_callback。

  • 对于select和poll来说,操作系统在监视多个文件描述符上的事件是否就绪时,需要让操作系统主动对这多个文件描述符进行轮询检测,这一定会增加操作系统的负担。
  • 而对于epoll来说,操作系统不需要主动进行事件的检测,当红黑树中监视的事件就绪时,会自动调用对应的回调方法,将就绪的事件添加到就绪队列当中。
  • 当用户调用epoll_wait函数获取就绪事件时,只需要关注底层就绪队列是否为空,如果不为空则将就绪队列当中的就绪事件拷贝给用户即可。
  • 采用回调机制最大的好处,就是不再需要操作系统主动对就绪事件进行检测了,当事件就绪时会自动调用对应的回调函数进行处理。

说明一下:

  • 只有添加到红黑树当中的事件才会与底层建立回调方法,因此只有当红黑树当中对应的事件就绪时,才会执行对应的回调方法将其添加到就绪队列当中。
  • 当不断有监视的事件就绪时,会不断调用回调方法向就绪队列当中插入节点,而上层也会不断调用epoll_wait函数从就绪队列当中获取节点,这是典型的生产者消费者模型。
  • 由于就绪队列可能会被多个执行流同时访问,因此必须要使用互斥锁对其进行保护,eventpoll结构当中的lock和mtx就是用于保护临界资源的,因此epoll本身是线程安全的。
  • eventpoll结构当中的wq(wait queue)就是等待队列,当多个执行流想要同时访问同一个epoll模型时,就需要在该等待队列下进行等待。

                

4.epoll服务器

  • Epoll_Server类
#pragma once 

#include<iostream>
#include<sys/epoll.h>
#include"sock.hpp"

#define BACK_LOG 5
#define SIZE 256
#define MAX_NUM 64

namespace ns_epoll{

class Epoll_Server{
    private:
       int listen_sock; //监听套接字
       int port;  //端口号
       int epfd; //epoll模型
    public:
       Epoll_Server(int _port):port(_port){};
       ~Epoll_Server()
       {
          if(listen_sock >=0 ){
             close(listen_sock);
          }

          if(epfd >=0 ){
             close(epfd);
          }
       }

       void InitEpollServer()
       {
           listen_sock = ns_sock::Sock::Socket();
           ns_sock::Sock::Bind(listen_sock,port);
           ns_sock::Sock::Listen(listen_sock,BACK_LOG);

           //创建epoll模型
           epfd = epoll_create(SIZE);
           if(epfd < 0){
              std::cerr << "epoll_create error" << std::endl;
              exit(5);
           }
       }

       void Run()
       {
         //将监听套接字添加到epoll模型中,并关心读事件
         AddEvent(listen_sock,EPOLLIN);
         for(;;){
            struct epoll_event revs[MAX_NUM];
            int num = epoll_wait(epfd,revs,MAX_NUM,-1);
            if(num < 0){
               std::cerr << "epoll_wait error ..." << std::endl;
               continue;
            }
            else if(num == 0){
               std::cout << "timeout ..." << std::endl;
               continue;
            } 
            else{
               //有事件就绪
               HandlerEvent(revs,num);
            }
            
         }
       }


       void HandlerEvent(struct epoll_event* revs,int num)
       {
          for(int i = 0; i < num ; ++i){
               int fd = revs[i].data.fd;
             
             
             //有效位置
             if(fd == listen_sock && (revs[i].events&EPOLLIN)){ //连接事件就绪
                struct sockaddr_in peer;
                memset(&peer, 0, sizeof(peer));
 				    socklen_t len = sizeof(peer);
                int sock = accept(listen_sock,(struct sockaddr*)&peer,&len);
                if(sock < 0){
                  std::cerr << "accept error" << std::endl;
                  continue;
                }

                std::string _ip = inet_ntoa(peer.sin_addr);
                int _port = ntohs(peer.sin_port);
                std::cout << "get a new link [ " << _ip << " : " << _port << " ]" << std::endl;

                //将获取到的套接字添加到epoll模型中,并关心其读事件
               AddEvent(sock,EPOLLIN);

             }
             else if(revs[i].events&EPOLLIN){ //读事件就绪
               char buffer[64];
               ssize_t size = recv(fd,buffer,sizeof(buffer)-1,0);
               if(size > 0){
                 buffer[size-1] = 0; //清除 /n
                 std::cout << "echo# " << buffer << std::endl;
               }
               else if(size == 0){ //对方关闭
                 std::cout << "client quit" << std::endl;
                 close(fd);
                 DelEvent(fd); //将fd从epoll模型中移出
               }
               else{ //读取错误
                 std::cerr << "recv error" << std::endl;
                 close(fd);
                 DelEvent(fd);//将fd从epoll模型中移出
               }

             }
             else{ //保证代码逻辑
               //TODO
             }
          }
       }
       

    private: //内部使用函数
      void AddEvent(int sock,uint32_t event)
      {
          struct epoll_event ev;
          ev.events = event;
          ev.data.fd = sock;

          epoll_ctl(epfd,EPOLL_CTL_ADD,sock,&ev);
      }
       
      void DelEvent(int sock)
      {
         epoll_ctl(epfd,EPOLL_CTL_DEL,sock,nullptr);
      } 

};

                 

 (1)服务器初始化

Epoll_Server类当中除了需要包含监听套接字和端口号两个成员变量之外,最好将epoll模型对应的文件描述符也作为一个成员变量。

  • 在构造Epoll_Server对象时,需要指明epoll服务器的端口号,当然也可以在初始化epoll服务器的时候指明。
  • 在初始化epoll服务器的时候调用Socket类当中的函数,依次进行套接字的创建、绑定和监听,此外epoll模型的创建可以在服务器初始化的时候进行。
  • 在析构函数中调用close函数,将监听套接字和epoll模型对应的文件描述符进行关闭。

        

(2)Run函数

  • 服务器初始化完毕后就可以开始运行了,而epoll服务器要做的就是不断调用epoll_wait函数,从就绪队列当中获取就绪事件进行处理即可。
  • 首先,在epoll服务器开始死循环调用epoll_wait函数之前,需要先调用epoll_ctl将监听套接字添加到epoll模型当中,表示服务器刚开始运行时只需要监视监听套接字的读事件。
  • 此后,epoll服务器就不断调用epoll_wait函数监视读事件是否就绪。如果epoll_wait函数的返回值大于0,则说明已经有文件描述符的读事件就绪,并且此时的返回值代表的就是有事件就绪的文件描述符个数,接下来就应该对就绪事件进行处理。
  • 如果epoll_wait函数的返回值等于0,则说明timeout时间耗尽,此时直接准备进行下一次epoll_wait调用即可。如果epoll_wait函数的返回值为-1,此时也让服务器准备进行下一次epoll_wait调用,但实际应该进一步判断错误码,根据错误码来判断是否应该继续调用epoll_wait函数。

说明:

  • 认情况下,只要底层有就绪事件没有处理,epoll也会一直通知用户,也就是调用epoll_wait会一直成功返回,并将就绪的事件拷贝到我们传入的数组当中。
  • 需要注意的是,所谓的事件处理并不是调用epoll_wait将底层就绪队列中的就绪事件拷贝到用户层,比如当这里的读事件就绪后,我们应该调用accept获取底层建立好的连接,或调用recv读取客户端发来的数据,这才算是将读事件处理了。
  • 如果我们仅仅是调用epoll_wait将底层就绪队列当中的事件拷贝到应用层,那么这些就绪事件实际并没有被处理掉,底层注册的回调函数会被再次调用,将就绪的事件重新添加到就绪队列当中,本质原因就是我们实际并没有对底层就绪的数据进行读取。

                         

(3)HandlerEvent函数

如果底层就绪队列当中有就绪事件,那么调用epoll_wait函数时就会将底层就绪队列中的事件拷贝到用户提供的revs数组当中,接下来epoll服务器就应该对就绪事件进行处理了,事件处理过程如下:

  • 根据调用epoll_wait时得到的返回值,来判断操作系统向revs数组中拷贝了多少个struct epoll_event结构,进而对这些文件描述符上的事件进行处理。
  • 对于每一个拷贝上来的struct epoll_event结构,如果该结构当中的events当中包含读事件,则说明该文件描述符对应的读事件就绪,但接下来还需要进一步判断该文件描述符是监听套接字还是与客户端建立的套接字。
  • 如果是监听套接字的读事件就绪,则调用accept函数将底层建立好的连接获取上来,并调用epoll_ctl函数将获取到的套接字添加到epoll模型当中,表示下一次调用epoll_wait函数时需要监视该套接字的读事件。
  • 如果是与客户端建立的连接对应的读事件就绪,则调用recv函数读取客户端发来的数据,并将读取到的数据在服务器端进行打印。
  • 如果在调用recv函数时发现客户端将连接关闭或recv函数调用失败,则epoll服务器也直接关闭对应的连接,并调用epoll_ctl函数将该连接对应的文件描述符从epoll模型中删除,表示下一次调用epoll_wait函数时无需再监视该套接字的读事件。

                         

(4)server.cc

#include "epoll_server.hpp"
#include <iostream>
#include <cstdlib>

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

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

    unsigned short port = atoi(argv[1]);

    ns_epoll::Epoll_Server* epoll_svr = new ns_epoll::Epoll_Server(port);
	//server一般运行起来就不停止了,是否delete不影响

    epoll_svr->InitEpollServer();
    epoll_svr->Run();

    return 0;
}

                

  • epoll服务器在调用epoll_wait函数时,我们将timeout的值设置成了-1,因此运行服务器后如果没有客户端发来连接请求,那么服务器就会在调用epoll_wait函数后进行阻塞等待。
  • 用telnet工具连接epoll服务器后,epoll服务器调用的epoll_wait函数在检测到监听套接字的读事件就绪后就会调用accept获取建立好的连接,并打印输出客户端的IP和端口号,此时客户端发来的数据也能够成功被epoll服务器收到并进行打印输出。
  • 编写的是一个单进程的epoll服务器,但是它可以同时为多个客户端提供服务

                

 用ls /proc/PID/fd命令,查看当前epoll服务器的文件描述符的使用情况。

  • 文件描述符0、1、2是默认打开的,分别对应的是标准输入、标准输出和标准错误,
  • 3号文件描述符对应的是监听套接字,
  • 4号文件描述符对应的是服务器创建的epoll模型,
  • 5号和6号文件描述符对应的分别是正在访问服务器的两个客户端。

                        

  •  当服务器端检测到客户端退出后,也会关闭对应的连接,此时epoll服务器对应的5号和6号文件描述符就关闭了。

                

5.epoll的优点

  • 接口使用方便:虽然拆分成了三个函数,但是反而使用起来更方便高效 ;接口增多了说明OS做的更多了,我们使用起来更简单了
  • 数据拷贝轻量:只在新增监视事件的时候调用epoll_ctl将数据从用户拷贝到内核,而select和poll每次都需要重新将需要监视的事件从用户拷贝到内核。此外,调用epoll_wait获取就绪事件时,只会拷贝就绪的事件,不会进行不必要的拷贝操作。
  • 事件回调机制:避免操作系统主动轮询检测事件就绪,而是采用回调函数的方式,将就绪的文件描述符结构加入到就绪队列中。调用epoll_wait时直接访问就绪队列就知道哪些文件描述符已经就绪,检测是否有文件描述符就绪的时间复杂度是O(1) ,因为本质只需要判断就绪队列是否为空即可。
  • 没有数量限制:监视的文件描述符数目无上限,只要内存允许,就可以一直向红黑树当中新增节点。

注意:

  • 有人说epoll中使用了内存映射机制,内核可以直接将底层就绪队列通过mmap的方式映射到用户态,此时用户就可以直接读取到内核中就绪队列当中的数据,避免了内存拷贝的额外性能开销。
  • 这种说法是错误的,实际操作系统并没有做任何映射机制,因为操作系统是不相信任何人的,操作系统不会让用户进程直接访问到内核的数据的,用户只能通过系统调用来获取内核的数据。
  • 因此用户要获取内核当中的数据,势必还是需要将内核的数据拷贝到用户空间。

                                 

select和poll,epoll的不同之处

  • 在使用select和poll时,都需要借助第三方数组来维护历史上的文件描述符以及需要监视的事件,这个第三方数组是由用户自己维护的,对该数组的增删改操作都需要用户自己来进行。
  • 而使用epoll时,不需要用户自己维护所谓的第三方数组,epoll底层的红黑树就充当了这个第三方数组的功能,并且该红黑树的增删改操作都是由内核维护的,用户只需要调用epoll_ctl让内核对该红黑树进行对应的操作即可。
  • 在使用多路转接接口时,数据流都有两个方向,一个是用户告知内核,一个是内核告知用户。select和poll将这两件事情都交给了同一个函数来完成,而epoll在接口层面上就将这两件事进行了分离,epoll通过调用epoll_ctl完成用户告知内核,通过调用epoll_wait完成内核告知用户。

                         

如果有海量的fd需要被关心,大量的fd已经就绪,这个基于epoll的服务器的效率会不会受影响?

  • 1.大量的节点需要关心都放在红黑树中。
  • 2.如果大量fd就绪了我们拿到的永远是有效的,也就意味着我没有做任何一个浪费的过程,我在做数据处理是必须做的,我正在以最高效的方式工作.
  • 3.epoll模型不会随着fd的增加而效率下降
     

                

6.epoll的工作方式 

水平触发(LT,Level Triggered)

 

  • 只要底层有事件就绪,epoll就会一直通知用户。
  • 就像数字电路当中的高电平触发一样,只要一直处于高电平,则会一直触发。
  • epoll默认状态下就是LT工作模式。
  • 由于在LT工作模式下,只要底层有事件就绪就会一直通知用户,因此当epoll检测到底层读事件就绪时,可以不立即进行处理,或者只处理一部分,因为只要底层数据没有处理完,下一次epoll还会通知用户事件就绪。
  • select和poll其实就是工作是LT模式下的。
  • 支持阻塞读写和非阻塞读写。

                 

边缘触发(ET,Edge Triggered)

                                 

  • 只有底层就绪事件数量由无到有或由有到多发生变化的时候,epoll才会通知用户。
  • 就像数字电路当中的上升沿触发一样,只有当电平由低变高的那一瞬间才会触发。
  • 如果要将epoll改为ET工作模式,则需要在添加事件时设置EPOLLET选项。
  • 由于在ET工作模式下,只有底层就绪事件无到有或由有到多发生变化的时候才会通知用户,因此当epoll检测到底层读事件就绪时,必须立即进行处理,而且必须全部处理完毕,因为有可能此后底层再也没有事件就绪,那么epoll就再也不会通知用户进行事件处理,此时没有处理完的数据就相当于丢失了。
  • ET工作模式下epoll通知用户的次数一般比LT少,因此ET的性能一般比LT性能更高,Nginx就是默认采用ET模式使用epoll的。
  • 只支持非阻塞的读写。

                 

(1)epoll是怎么做到的事件如果没有处理一直在通知?

所谓的事件处理并不是你把事件读上去就行了,你要对就绪事件的空间有数据读数据/有空间写数据。底层引起事件就绪本质是OS内部有数据你不读,有空间你不写。
 

(2)ET工作模式下应该如何进行读写

  • 因为在ET工作模式下,只有底层就绪事件无到有或由有到多发生变化的时候才会通知用户,这就倒逼用户当读事件就绪时必须一次性将数据全部读取完毕,当写事件就绪时必须一次性将发送缓冲区写满,否则可能再也没有机会进行读写了。
  • 因此读数据时必须循环调用recv函数进行读取,写数据时必须循环调用send函数进行写入。
  • 当底层读事件就绪时,循环调用recv函数进行读取,直到某次调用recv读取时,实际读取到的字节数小于期望读取的字节数,则说明本次底层数据已经读取完毕了。
  • 但有可能最后一次调用recv读取时,刚好实际读取的字节数和期望读取的字节数相等,但此时底层数据也恰好读取完毕了,如果我们再调用recv函数进行读取,那么recv就会因为底层没有数据而被阻塞住。
  • 这里的阻塞是非常严重的,就比如我们这里写的服务器都是单进程的服务器,如果recv被阻塞住,并且此后该数据再也不就绪,进程被挂起,服务器无法响应任何外部事件,那么就相当于我们的服务器挂掉了,因此在ET工作模式下循环调用recv函数进行读取时,必须将对应的文件描述符设置为非阻塞状态。
  • 调用send函数写数据时也是同样的道理,需要循环调用send函数进行数据的写入,并且必须将对应的文件描述符设置为非阻塞状态。
  • 强调: ET工作模式下,recv和send操作的文件描述符必须设置为非阻塞状态,这是必须的,不是可选的。

                         

(3)总结,为什么ET模式下read/write必须是非阻塞?

  • 本质上一旦事件就绪了,只调用一次recv我们不能确定把这次就绪的所有事件全部读完,必须循环调用recv,循环调用recv可能存在我们已经读完了数据还去读数据的情况,所以我们要设置为非阻塞,没有数据了直接出错返回,不致于导致进程挂起。

                         

(4)ET和LT的对比

  • ET模式更高效,因为它的通知方式没有重复的,OS向上通知的时候没有做重复动作,所做的所有动作都是有效的。
  • 但如果在LT模式下能够做到每次都将就绪的文件描述符立即全部处理,不让操作系统反复通知用户的话,其实LT和ET的性能也是一样的。
  • 此外,ET的编程难度比LT更高。

 

 

                        

  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值