EPOLL模型详解


参考自: http://blog.chinaunix.net/uid/28541347/cid-191916-list-4.html

一 epoll综述

1 selected 缺陷

首先,在Linux内核中,select所用到的FD_SET是有限的,即内核中有个参数__FD_SETSIZE定义了每个FD_SET的句柄个数,在我用的2.6.15-25-386内核中,该值是1024,搜索内核源代码得到:
include/linux/posix_types.h:
#define __FD_SETSIZE 1024
也就是说,如果想要同时检测1025个句柄的可读状态是不可能用select实现的。或者同时检测1025个句柄的可写状态也是不可能的。其次,内核中实现 select是用轮询方法,即每次检测都会遍历所有FD_SET中的句柄,显然,select函数执行时间与FD_SET中的句柄个数有一个比例关系,即 select要检测的句柄数越多就会越费时。当然,在前文中我并没有提及poll方法,事实上用select的朋友一定也试过poll,我个人觉得 select和poll大同小异,个人偏好于用select而已。

2 epoll的优点

(1) 支持一个进程打开大数目的socket描述符(FD)

select 最不能忍受的是一个进程所打开的FD是有一定限制的,由FD_SETSIZE设置,默认值是2048。对于那些需要支持的上万连接数目的IM服务器来说显 然太少了。这时候你一是可以选择修改这个宏然后重新编译内核,不过资料也同时指出这样会带来网络效率的下降,二是可以选择多进程的解决方案(传统的 Apache方案),不过虽然linux上面创建进程的代价比较小,但仍旧是不可忽视的,加上进程间数据同步远比不上线程间同步的高效,所以也不是一种完 美的方案。不过 epoll则没有这个限制,它所支持的FD上限是 最大可以打开文件的数目 ,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左 右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。

(2) IO 效率不随FD数目增加而线性下降
传统的select/poll另一个致命弱点就是当你拥有一个很大的socket集合,不过由于网络延时,任一时间只有部分的socket是"活跃"的, 但是select/poll每次调用都会线性扫描全部的集合,导致效率呈现线性下降。但是epoll不存在这个问题,它只会对"活跃"的socket进行 操作------这是因为在内核实现中epoll是根据每个fd上面的callback函数实现的。那么,只有"活跃"的socket才会主动的去调用 callback函数,其他idle状态socket则不会,在这点上,epoll实现了一个"伪"AIO,因为这时候推动力在os内核。在一些 benchmark中,如果所有的socket基本上都是活跃的—比如一个高速LAN环境,epoll并不比select/poll有什么效率,相反,如果过多使用epoll_ctl,效率相比还有稍微的下降。但是一旦使用idle connections模拟WAN环境,epoll的效率就远在select/poll之上了。

(3)使用mmap加速内核与用户空间的消息传递

这点实际上涉及到epoll的具体实现了。无论是select,poll还是epoll都需要内核把FD消息通知给用户空间,如何避免不必要的内存拷贝就 很重要,在这点上,epoll是通过内核与用户空间mmap同一块内存实现的。

3 epoll的工作模式

令人高兴的是,2.6内核的epoll比其2.5开发版本的/dev/epoll简洁了许多,所以,大部分情况下,强大的东西往往是简单的。唯一有点麻烦 是epoll有2种工作方式:LT和ET。

LT(level triggered)是缺省(默认的)的工作方式:并且同时支持block和no-block socket. 在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的,所以,这种模式编程出错误可能性要小一点。传统的select/poll都是这种模型的代表.

ET (edge-triggered)是高速工作方式:只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致 了一个EWOULDBLOCK错误)。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)。

epoll只有epoll_create,epoll_ctl,epoll_wait 3个系统调用,具体用法请参考,在也有一个完整的例子,大家一看就知道如何使用了Leader/follower模式线程 pool实现,以及和epoll的配合。

4 epoll的使用方法

epoll的接口非常简单,一共就三个函数:

(1) int epoll_create(int size);
创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大。这个参数不同于select()中的第一个参数,给出最大监听的fd+1的值。需要注意的是,当创建好epoll句柄后,它就是会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。
(2) int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epoll的事件注册函数,它不同与select()是在监听事件时(epoll使用epoll_wait监听)告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型。第一个参数是epoll_create()的返回值,第二个参数表示动作,用三个宏来表示:

EPOLL_CTL_ADD:注册新的fd到epfd中;

EPOLL_CTL_MOD:修改已经注册的fd的监听事件;

EPOLL_CTL_DEL:从epfd中删除一个fd;

第三个参数是需要监听的fd,第四个参数是告诉内核需要监听什么事,struct 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队列里

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

等待事件的产生,类似于select()调用。参数events用来从内核得到事件的集合,maxevents告之内核这个events有多大,这个 maxevents的值不能大于创建epoll_create()时的size,参数timeout是超时时间(毫秒,0会立即返回,-1是永久阻塞)。该函数返回需要处理的事件数目,如返回0表示已超时。

在这里插入图片描述

5. 应用实例

服务端

#include <iostream>
#include <sys/socket.h>
#include <sys/epoll.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <errno.h>
using namespace std;
#define MAXLINE 5
#define OPEN_MAX 100
#define LISTENQ 20
#define SERV_PORT 5000
#define INFTIM 1000
void setnonblocking(int sock)//将套接字设置为非阻塞
{
    int opts;
    opts=fcntl(sock,F_GETFL);
    if(opts<0)
    {
        perror("fcntl(sock,GETFL)");
        exit(1);
    }
    opts = opts|O_NONBLOCK;
    if(fcntl(sock,F_SETFL,opts)<0)
    {
        perror("fcntl(sock,SETFL,opts)");
        exit(1);
    }
}
int main(int argc, char* argv[])
{
    int i, maxi, listenfd, connfd, sockfd,epfd,nfds, portnumber;
    ssize_t n;
    char line[MAXLINE];
    socklen_t clilen;
    if ( 2 == argc )
    {
        if( (portnumber = atoi(argv[1])) < 0 )
        {
            fprintf(stderr,"Usage:%s portnumber/a/n",argv[0]);
            return 1;
        }
    }
    else
    {
        fprintf(stderr,"Usage:%s portnumber/a/n",argv[0]);
        return 1;
    }
    struct epoll_event ev,events[20]; //声明epoll_event结构体的变量,ev用于注册事件,数组用于回传要处理的事件
    epfd=epoll_create(256); //生成用于处理accept的epoll专用的文件描述符
    struct sockaddr_in clientaddr;
    struct sockaddr_in serveraddr;
    listenfd = socket(AF_INET, SOCK_STREAM, 0);
    setnonblocking(listenfd); //把监听socket设置为非阻塞方式
    ev.data.fd=listenfd; //设置与要处理的事件相关的文件描述符
    ev.events=EPOLLIN|EPOLLET;  //设置要处理的事件类型    

    epoll_ctl(epfd,EPOLL_CTL_ADD,listenfd,&ev); //注册epoll事件
    bzero(&serveraddr, sizeof(serveraddr));
    serveraddr.sin_family = AF_INET;
    char *local_addr="127.0.0.1";
    inet_aton(local_addr,&(serveraddr.sin_addr)); 
    serveraddr.sin_port=htons(portnumber);
    bind(listenfd,(sockaddr *)&serveraddr, sizeof(serveraddr));
    listen(listenfd, LISTENQ);
    maxi = 0;
    for ( ; ; ) {
         nfds=epoll_wait(epfd,events,20,500); //等待epoll事件的发生
        for(i=0;i<nfds;++i) //处理所发生的所有事件
        {
            if(events[i].data.fd==listenfd)//如果新监测到一个SOCKET用户连接到了绑定的SOCKET端口,建立新的连接。
            {
                connfd = accept(listenfd,(sockaddr *)&clientaddr, &clilen);
                if(connfd<0){
                    perror("connfd<0");
                    exit(1);
                }
                char *str = inet_ntoa(clientaddr.sin_addr);
                cout << "accapt a connection from " << str << endl;
                ev.data.fd=connfd; //设置用于读操作的文件描述符
                ev.events=EPOLLIN|EPOLLET; //设置用于注测的读操作事件
                epoll_ctl(epfd,EPOLL_CTL_ADD,connfd,&ev); //注册ev
            }
            else if(events[i].events&EPOLLIN)//如果是已经连接的用户,并且收到数据,那么进行读入。
            {
                cout << "EPOLLIN" << endl;
                if ( (sockfd = events[i].data.fd) < 0)
                    continue;
                if ( (n = read(sockfd, line, MAXLINE)) < 0) {
                    if (errno == ECONNRESET) {
                        close(sockfd);
                        events[i].data.fd = -1;
                    } else
                        std::cout<<"readline error"<<std::endl;
                } else if (n == 0) {
                    close(sockfd);
                    events[i].data.fd = -1;
                }
                line[n] = '/0';
                cout << "read " << line << endl;
                ev.data.fd=sockfd;  //设置用于写操作的文件描述符
                ev.events=EPOLLOUT|EPOLLET; //设置用于注测的写操作事件
                epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev); //修改sockfd上要处理的事件为EPOLLOUT
            }
            else if(events[i].events&EPOLLOUT) // 如果有数据发送
            {
                sockfd = events[i].data.fd;
                write(sockfd, line, n);
                ev.data.fd=sockfd; //设置用于读操作的文件描述符
                ev.events=EPOLLIN|EPOLLET; //设置用于注测的读操作事件
                epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev);  //修改sockfd上要处理的事件为EPOLIN
            }
        }
    }
    return 0;
}

6 epoll的底层实现

详见
http://blog.chinaunix.net/uid-28541347-id-4273856.html
http://blog.chinaunix.net/uid-28541347-id-4238524.html
http://blog.chinaunix.net/uid-28541347-id-4236779.html

二 从select的一个死循环谈epoll的ET模式

对于下列程序:

#include <stdio.h>
#include <sys/epoll.h>
#include <unistd.h>

int main(int argc, char *argv[])
{
	int maxfdp1;

	char buf[256];

	fd_set rset;

  maxfdp1=STDIN_FILENO+1;

for(;;)

{ 

	FD_ZERO(&rset);

	FD_SET(STDIN_FILENO,&rset);

	select(maxfdp1,&rset,NULL,NULL,NULL);

	if(FD_ISSET(STDIN_FILENO,&rset))

    {

			printf("hello world!\n");

    }

}

	return 0;

}

运行结果:
在这里插入图片描述
只输入了一次回车后,会陷入死循环。这是为什么?
也就是当我们输入任意字符后,select每次都判断标准输入的描述符就绪。造成这种情况的原因要从select的机制说起。如下图所示:
在这里插入图片描述
在这里插入图片描述
每一个文件描述符(fd)与一个缓冲关联,select对fd的监听其实就是监听fd的缓冲!!当缓冲中有数据要读的时候,select就认为该fd可读就绪,当缓冲中可以写入数据的时候,select就认为该fd可写就绪

下面我们在分析一下我们的程序一,当输入任意字符,比如:“abc”,则“abc”被放在标准输入的缓冲当中,此时缓冲中有数据(abc)待读,所以select返回STDIN_FILENO就绪,程序输出“hello World!”。紧接着进入下一次循环,select重新将STDIN_FILENO加入监听的描述符集,由于刚刚的“abc”并没有被读出,所以仍在缓冲中,此时STDIN_FILENO的缓冲中仍有数据等待读,所以select又返回STDIN_FILENO可读就绪,又一次输出“hello world!”。

之后循环情况类似,由于缓冲的的“待读”数据始终还在,所以每次select都直接返回STDIN_FILENO就绪,每次都输出“hello world!”,这就是造成程序死循环的原因。那么让我们如何解决呢?

方法一:将缓冲区中的“待读”数据读出,程序修改如下所示:


if(FD_ISSET(STDIN_FILENO,&rset))
{

read(STDIN_FILENO,buf,sizeof(buf));//将缓冲区的数据读出(读入buf数组)

printf("hello world!\n");

}

修改后程序正常运行(任意输入后,输出“hello world!”)


我们在做如下实验——输入多个字符,只读出部分字符。修改程序做如下修改:

char buf[2];//将buf长度改为2,每次从缓冲区读入两个字符

程序运行结果如下:
在这里插入图片描述
分析:
(1) 输入一个字符’s’,s被放入缓冲区,同时放入缓冲区中的还有换行符’\n’,缓冲区中有待读数据,select返回读就绪,read将’s\n’读出,缓冲区清空,select再次阻塞。

(2) 输入两个”dd”,缓冲区中的数据变为”dd\n”,select返回读就绪,read读出两个字符——”ss”,输出hello world!此时缓冲区中还有’\n’,所以下一次select依然返回读就绪,之后read将’\n’读出,输出hello world!,缓冲区清空,select阻塞。

(3) 之后输入三个字符,四个字符的情况类似,不在分析。

最后,需要注意的是,以上程序一的现象正是LT模式的一个典型实例,也是LT模式的一个缺陷。我们知道select和poll都是采用LT模式,并且只有这一种模式。所以,使用select或者poll要想解决程序一的问题只能采用方法一。

方法二:epoll的ET模式
不再赘述

三 EPOLL的ET和LT触发方式

1 ET

再次强调的一点是,IO复用对fd的监听,实际上是对fd的缓冲区的监听!!

ET模式下被唤醒(返回就绪)的条件为:

对于读取操作:
(1) 当buffer由不可读状态变为可读的时候,即由空变为不空的时候。
(2) 当有新数据到达时,即buffer中的待读内容变多的时候。
另外补充一点:
(3) 当buffer中有数据可读(即buffer不空)且用户对相应fd进行epoll_mod IN事件时(具体见下节内容)。

对于情况(1)(2)分别对应下图中的(a),(b)。
在这里插入图片描述

对于写操作:
(1) 当buffer由不可写变为可写的时候,即由满状态变为不满状态的时候。
(2) 当有旧数据被发送走时,即buffer中待写的内容变少得时候。
另外补充一点:
(3) 当buffer中有可写空间(即buffer不满)且用户对相应fd进行epoll_mod OUT事件时(具体见下节内容)

对于情况(1)(2)分别对应下图(a),(b)
在这里插入图片描述

2 LT

LT模式下进程被唤醒(描述符就绪)的条件就简单多了,它包含ET模式的所有条件,也就是上述列出的六中读写被唤醒的条件都是用于LT模式。此外,还有更普通的情况LT可以被唤醒,而ET则不理会,这也是我们需要注意的情况。

对于读操作:

  • 当buffer中有数据,且数据被读出一部分后buffer还不空的时候,即buffer中的内容减少的时候,LT模式返回读就绪。如下图所示。
    在这里插入图片描述
    对于写操作
  • 当buffer不满,又写了一部分数据后扔然不满的的时候,即由于写操作的速度大于发送速度造成buffer中的内容增多的时候,LT模式会返回就绪。如下图所示。
    在这里插入图片描述

注:poll和select都是LT模式。

四 ET的读操作实例分析

程序一

    #include <unistd.h>
    #include <iostream>
    #include <sys/epoll.h>
    using namespace std;
    int main(void)
    {
        int epfd,nfds;
        struct epoll_event ev,events[5];//ev用于注册事件,数组用于返回要处理的事件
        epfd=epoll_create(1);//只需要监听一个描述符——标准输入
        ev.data.fd=STDIN_FILENO;
        ev.events=EPOLLIN|EPOLLET;//监听读状态同时设置ET模式
        epoll_ctl(epfd,EPOLL_CTL_ADD,STDIN_FILENO,&ev);//注册epoll事件
        for(;;)
       {
         nfds=epoll_wait(epfd,events,5,-1);
         for(int i=0;i<nfds;i++)
         {
            if(events[i].data.fd==STDIN_FILENO)
               cout<<"hello world!"<<endl;
         }
       }
    }

运行结果:
在这里插入图片描述

程序一中对标准输入的监听使用ET模式,结果实现了我们想要的功能。那么实际原理是如何呢,我们将过程分析一下:

(1) 当用户输入一组字符,这组字符被送入buffer,字符停留在buffer中,又因为buffer由空变为不空,所以ET返回读就绪,输出”hello world!”。

(2) 之后程序再次执行epoll_wait,此时虽然buffer中有内容可读,但是根据我们上节的分析,ET并不返回就绪,导致epoll_wait阻塞。(底层原因是ET下就绪fd的epitem只被放入rdlist一次)。

(3) 用户再次输入一组字符,导致buffer中的内容增多,根据我们上节的分析这将导致fd状态的改变,是对应的epitem再次加入rdlist,从而使epoll_wait返回读就绪,再次输出“hello world!”。

我们在看看LT的情况如何,将程序一以下修改:

ev.events=EPOLLIN;//默认使用LT模式

运行结果:
在这里插入图片描述

结果正如我们所料,程序出现死循环,因为用户输入任意数据后,数据被送入buffer且没有被读出,所以LT模式下每次epoll_wait都认为buffer可读返回读就绪。导致每次都会输出”hello world!”。

下面再看程序二。

程序二



    #include <unistd.h>
    #include <iostream>
    #include <sys/epoll.h>
    using namespace std;
    int main(void)
    {
        int epfd,nfds;
        char buf[256];
        struct epoll_event ev,events[5];//ev用于注册事件,数组用于返回要处理的事件
        epfd=epoll_create(1);//只需要监听一个描述符——标准输入
        ev.data.fd=STDIN_FILENO;
        ev.events=EPOLLIN;//使用默认LT模式
        epoll_ctl(epfd,EPOLL_CTL_ADD,STDIN_FILENO,&ev);//注册epoll事件
        for(;;)
       {
         nfds=epoll_wait(epfd,events,5,-1);
         for(int i=0;i<nfds;i++)
         {
           if(events[i].data.fd==STDIN_FILENO)
           {
              read(STDIN_FILENO,buf,sizeof(buf));//将缓冲中的内容读出
              cout<<"hello world!"<<endl;
           }
        }
      }
    }

运行结果:
在这里插入图片描述
程序二依然使用LT模式,但是每次epoll_wait返回读就绪的时候我们都将buffer(缓冲)中的内容read出来,所以导致buffer再次清空,下次调用epoll_wait就会阻塞。所以能够实现我们所想要的功能——当用户从控制台有任何输入操作时,输出”hello world!”。

我们再来看看程序三。
程序三



    int main(void)
    {
        int epfd,nfds;
        struct epoll_event ev,events[5];//ev用于注册事件,数组用于返回要处理的事件
        epfd=epoll_create(1);//只需要监听一个描述符——标准输入
        ev.data.fd=STDIN_FILENO;
        ev.events=EPOLLIN|EPOLLET;
        epoll_ctl(epfd,EPOLL_CTL_ADD,STDIN_FILENO,&ev);//注册epoll事件
        for(;;)
       {
         nfds=epoll_wait(epfd,events,5,-1);
         for(int i=0;i<nfds;i++)
         {
           if(events[i].data.fd==STDIN_FILENO)
            {
              cout<<"hello world!"<<endl;
              ev.data.fd=STDIN_FILENO;
              ev.events=EPOLLIN|EPOLLET;
              epoll_ctl(epfd,EPOLL_CTL_MOD,STDIN_FILENO,&ev);//重新MOD事件(ADD无效)
            }
         }
       }
    }

在这里插入图片描述
程序三依然使用ET,但是每次读就绪后都主动的再次MOD IN事件,我们发现程序再次出现死循环,也就是每次返回读就绪。这就验证了上一节讨论ET读就绪的第三种情况。但是注意,如果我们将MOD改为ADD,将不会产生任何影响。别忘了每次ADD一个描述符都会在epitem组成的红黑树中添加一个项,我们之前已经ADD过一次,再次ADD将阻止添加,所以在次调用ADD IN事件不会有任何影响。

五 ET的写操作实例分析

程序四

#include <unistd.h>
#include <iostream>
#include <sys/epoll.h>
using namespace std;
int main(void)
{
    int epfd,nfds;
    struct epoll_event ev,events[5];//ev用于注册事件,数组用于返回要处理的事件
    epfd=epoll_create(1);//只需要监听一个描述符——标准输出
    ev.data.fd=STDOUT_FILENO;
    ev.events=EPOLLOUT|EPOLLET;//监听读状态同时设置ET模式
    epoll_ctl(epfd,EPOLL_CTL_ADD,STDOUT_FILENO,&ev);//注册epoll事件
    for(;;)
   {
      nfds=epoll_wait(epfd,events,5,-1);
      for(int i=0;i<nfds;i++)
     {
         if(events[i].data.fd==STDOUT_FILENO)
             cout<<"hello world!"<<endl;
     }
   }

这个程序的功能是只要标准输出写就绪,就输出“hello world!”。

什么时候标准输出写就绪呢,按照之前的说法,只要标准输出的缓冲区达到一定的要求即可。

运行结果:
在这里插入图片描述

我们发现这将是一个死循环。下面具体分析一下这个程序的执行过程:

(1) 首先初始buffer为空,buffer中有空间可写,这时无论是ET还是LT都会将对应的epitem加入rdlist(对应第一节图中的红线),导致epoll_wait就返回写就绪。

(2) 程序想标准输出输出”hello world!”和换行符,因为标准输出为控制台的时候缓冲是“行缓冲”,所以换行符导致buffer中的内容清空,这就对应第二节中ET模式下写就绪的第二种情况——当有旧数据被发送走时,即buffer中待写的内容变少得时候会触发fd状态的改变。所以下次epoll_wait会返回写就绪。之后重复这个过程一直循环下去。

我们再看程序五。
程序五相对程序四这里仅仅去掉了输出的换行操作。即:

cout<<“hello world!”;

运行结果如下:
我们看到程序成挂起状态。因为第一次epoll_wait返回写就绪后,程序向标准输出的buffer中写入“hello world!”,但是因为没有输出换行,所以buffer中的内容一直存在(也不会显示在终端上),下次epoll_wait的时候,虽然有写空间但是ET模式下不再返回写就绪。回忆第一节关于ET的实现,这种情况原因就是第一次buffer为空,导致epitem加入rdlist,返回一次就绪后移除此epitem,之后虽然buffer仍然可写,但是由于对应epitem已经不再rdlist中,就不会对其就绪fd的events的在检测了。

程序六:

    int main(void)
    {
        int epfd,nfds;
        struct epoll_event ev,events[5];//ev用于注册事件,数组用于返回要处理的事件
        epfd=epoll_create(1);//只需要监听一个描述符——标准输出
        ev.data.fd=STDOUT_FILENO;
        ev.events=EPOLLOUT;//使用默认LT模式
        epoll_ctl(epfd,EPOLL_CTL_ADD,STDOUT_FILENO,&ev);//注册epoll事件
        for(;;)
       {
         nfds=epoll_wait(epfd,events,5,-1);
         for(int i=0;i<nfds;i++)
        {
          if(events[i].data.fd==STDOUT_FILENO)
             cout<<"hello world!";
        }
       }
    };

在这里插入图片描述
程序六相对程序五仅仅是修改ET模式为默认的LT模式,我们发现程序再次死循环。这时候原因已经很清楚了,因为当向buffer写入”hello world!”后,虽然buffer没有输出清空,但是LT模式下只有buffer有写空间就返回写就绪,所以会一直输出”hello world!”,当buffer满的时候,buffer会自动刷清输出,同样会造成epoll_wait返回写就绪。

程序七:



    int main(void)
    {
        int epfd,nfds;
        struct epoll_event ev,events[5];//ev用于注册事件,数组用于返回要处理的事件
        epfd=epoll_create(1);//只需要监听一个描述符——标准输出
        ev.data.fd=STDOUT_FILENO;
        ev.events=EPOLLOUT|EPOLLET;//监听读状态同时设置ET模式
        epoll_ctl(epfd,EPOLL_CTL_ADD,STDOUT_FILENO,&ev);//注册epoll事件
        for(;;)
       {
         nfds=epoll_wait(epfd,events,5,-1);
         for(int i=0;i<nfds;i++)
        {
           if(events[i].data.fd==STDOUT_FILENO)
               cout<<"hello world!";
           ev.data.fd=STDOUT_FILENO;
           ev.events=EPOLLOUT|EPOLLET;
           epoll_ctl(epfd,EPOLL_CTL_MOD,STDOUT_FILENO,&ev); //重新MOD事件(ADD无效)
       }
     }
    };

在这里插入图片描述

程序七相对于程序五在每次向标准输出的buffer输出”hello world!”后,重新MOD OUT事件。所以相当于每次重新进行第一节中红线描述的途径返回就绪,导致程序循环输出。

六 ET模式下的注意事项

1 ET模式下的读写

ET模式下的两个大问题

经过前面几节分析,我们可以知道,当epoll工作在ET模式下时,对于读操作,如果read一次没有读尽buffer中的数据,那么下次将得不到读就绪的通知,造成buffer中已有的数据无机会读出,除非有新的数据再次到达对于写操作,主要是因为ET模式下fd通常为非阻塞造成的一个问题——如何保证将用户要求写的数据写完

要解决上述两个ET模式下的读写问题,我们必须实现:

  • a. 对于读,只要buffer中还有数据就一直读;
  • b. 对于写,只要buffer还有空间且用户请求写的数据还未写完,就一直写。

要实现上述a、b两个效果,我们有两种方法解决。

方法一:

(1) 每次读入操作后(read,recv),用户主动epoll_mod IN事件,此时只要该fd的缓冲还有数据可以读,则epoll_wait会返回读就绪。

(2) 每次输出操作后(write,send),用户主动epoll_mod OUT事件,此时只要该该fd的缓冲可以发送数据(发送buffer不满),则epoll_wait就会返回写就绪(有时候采用该机制通知epoll_wai醒过来)。

这个方法的原理我们在之前讨论过:当buffer中有数据可读(即buffer不空)且用户对相应fd进行epoll_mod IN事件时ET模式返回读就绪,当buffer中有可写空间(即buffer不满)且用户对相应fd进行epoll_mod OUT事件时返回写就绪。

所以得到如下解决方式:

if(events[i].events&EPOLLIN)//如果收到数据,那么进行读入

{

    cout << "EPOLLIN" << endl;

    sockfd = events[i].data.fd;

    if ( (n = read(sockfd, line, MAXLINE))>0) 

	{

		line[n] = '/0';

        cout << "read " << line << endl;

		if(n==MAXLINE)

		{

			ev.data.fd=sockfd;

			ev.events=EPOLLIN|EPOLLET;

			epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev); //数据还没读完,重新MOD IN事件

		}

		else

		{

			ev.data.fd=sockfd;

			ev.events=EPOLLIN|EPOLLET;

			epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev); //buffer中的数据已经读取完毕MOD OUT事件

		}

	}

 	else if (n == 0) 

	{

		close(sockfd);

   }

}

else if(events[i].events&EPOLLOUT) // 如果有数据发送

{

    sockfd = events[i].data.fd;

    write(sockfd, line, n);

    ev.data.fd=sockfd; //设置用于读操作的文件描述符

    ev.events=EPOLLIN|EPOLLET; //设置用于注册的读操作事件

    epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev);  //修改sockfd上要处理的事件为EPOLIN

}

注:对于write操作,由于sockfd是工作在阻塞模式下的,所以没有必要进行特殊处理,和LT使用一样。

分析:这种方法存在几个问题:

(1) 对于read操作后的判断——if(n==MAXLINE),不能说明这种情况buffer就一定还有没有读完的数据,试想万一buffer中一共就有MAXLINE字节数据呢?这样继续 MOD IN就不再得到通知,而也就没有机会对相应sockfd MOD OUT。

(2) 那么如果服务端用其他方式能够在适当时机对相应的sockfd MOD OUT,是否这种方法就可取呢?我们首先思考一下为什么要用ET模式,因为ET模式能够减少epoll_wait等系统调用,而我们在这里每次read后都要MOD IN,之后又要epoll_wait,势必造成效率降低,这不是适得其反吗?

综上,此方式不应该使用。

方法二:

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

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

if (events[i].events & EPOLLIN) 

{

	n = 0;

	while ((nread = read(fd, buf + n, BUFSIZ-1)) > 0) 

	{

		n += nread;//n现在是读完缓冲区后得到的字节数

	}

	if (nread == -1 && errno != EAGAIN) 

	{

 		perror("read error");

	}

	ev.data.fd = fd;

	ev.events = events[i].events | EPOLLOUT;

	epoll_ctl(epfd, EPOLL_CTL_MOD, fd, &ev);

}

if (events[i].events & EPOLLOUT) 

{ 

	int nwrite, data_size = strlen(buf);

	n = data_size;

	while (n > 0) 

	{

		nwrite = write(fd, buf + data_size - n, n);

		if (nwrite < n) 

		{

			if (nwrite == -1 && errno != EAGAIN) 

			{

				perror("write error");
			}

			break;

		}

		n -= nwrite;

}

ev.data.fd=fd; 

ev.events=EPOLLIN|EPOLLET; 

epoll_ctl(epfd,EPOLL_CTL_MOD,fd,&ev);  //修改sockfd上要处理的事件为EPOLIN
}

注:使用这种方式一定要使每个连接的套接字工作于非阻塞模式,因为读写需要一直读或写直到出错(对于读,当读到的实际字节数小于请求字节数时就可以停止),而如果你的文件描述符如果不是非阻塞的,那这个一直读或一直写势必会在最后一次阻塞。这样就不能在阻塞在epoll_wait上了,造成其他文件描述符的任务饿死。

综上:方法一不适合使用,我们只能使用方法二,所以也就常说“ET需要工作在非阻塞模式”,当然这并不能说明ET不能工作在阻塞模式,而是工作在阻塞模式可能在运行中会出现一些问题。

方法三:

仔细分析方法二的写操作,我们发现这种方式并不很完美,因为写操作返回EAGAIN就终止写,但是返回EAGAIN只能说名当前buffer已满不可写,并不能保证用户(或服务端)要求写的数据已经写完。那么如何保证对非阻塞的套接字写够请求的字节数才返回呢(阻塞的套接字直到将请求写的字节数写完才返回)?

我们需要封装socket_write()的函数用来处理这种情况,该函数会尽量将数据写完再返回,返回-1表示出错。在socket_write()内部,当写缓冲已满(send()返回-1,且errno为EAGAIN),那么会等待后再重试.

ssize_t socket_write(int sockfd, const char* buffer, size_t buflen)
{

	ssize_t tmp;
	size_t total = buflen;

	const char* p = buffer;

	while(1)

	{
		tmp = write(sockfd, p, total);

		if(tmp < 0)

		{

		      // 当send收到信号时,可以继续写,但这里返回-1.

			if(errno == EINTR)

			return -1;

      // 当socket是非阻塞时,如返回此错误,表示写缓冲队列已满,

      // 在这里做延时后再重试.

			if(errno == EAGAIN)
			{

		        usleep(1000);

		        continue;

			}

			return -1;

		}

		if((size_t)tmp == total)

			return buflen;

		total -= tmp;

		p += tmp;

	}
	
	return tmp;//返回已写字节数

}

分析:这种方式也存在问题,因为在理论上可能会长时间的阻塞在socket_write()内部(buffer中的数据得不到发送,一直返回EAGAIN),但暂没有更好的办法。

不过看到这种方式时,我在想在socket_write中将sockfd改为阻塞模式应该一样可行,等再次epoll_wait之前再将其改为非阻塞。

2 ET模式下的accept

考虑这种情况:多个连接同时到达,服务器的 TCP 就绪队列瞬间积累多个就绪连接,由于是边缘触发模式,epoll 只会通知一次,accept 只处理一个连接,导致 TCP 就绪队列中剩下的连接都得不到处理。

解决办法是用 while 循环抱住 accept 调用,处理完 TCP 就绪队列中的所有连接后再退出循环。如何知道是否处理完就绪队列中的所有连接呢? accept 返回 -1 并且 errno 设置为 EAGAIN 就表示所有连接都处理完。

accept的正确使用方式为:

while ((conn_sock = accept(listenfd,(struct sockaddr *) &remote, (size_t *)&addrlen)) > 0) 
{   

    handle_client(conn_sock);   

}   

if (conn_sock == -1) 
{   

	if (errno != EAGAIN && errno != ECONNABORTED && errno != EPROTO && errno != EINTR)    

		perror("accept");   

} 

扩展:服务端使用多路转接技术(select,poll,epoll等)时,accept应工作在非阻塞模式!!!也就是说将监听文件描述设置为非阻塞

原因:如果accept工作在阻塞模式,考虑这种情况: TCP 连接被客户端夭折,即在服务器调用 accept 之前(此时select等已经返回连接到达读就绪),客户端主动发送 RST 终止连接,导致刚刚建立的连接从就绪队列中移出,如果套接口被设置成阻塞模式,服务器就会一直阻塞在 accept 调用上,直到其他某个客户建立一个新的连接为止。但是在此期间,服务器单纯地阻塞在accept 调用上(实际应该阻塞在select上),就绪队列中的其他描述符都得不到处理。

解决办法是把监听套接口设置为非阻塞, 当客户在服务器调用 accept 之前中止某个连接时,accept 调用可以立即返回 -1, 这时源自 Berkeley 的实现会在内核中处理该事件,并不会将该事件通知给 epoll,而其他实现把 errno 设置为 ECONNABORTED 或者 EPROTO 错误,我们应该忽略这两个错误。(具体可参看UNP v1 p363)

七 关于ET的若干问题

1 ET模式为什么要设置在非阻塞模式下工作?

服务器使用ET模式下的EPOLL时,监听文件描述符一般要设置成非阻塞的,原因见上一节。连接文件描述符一般也设置为非阻塞的,因为ET模式下的读写需要一直读或写直到出错(对于读,当读到的实际字节数小于请求字节数时就可以停止),而如果你的文件描述符如果不是非阻塞的,那这个一直读或一直写势必会在最后一次阻塞。这样就不能在阻塞在epoll_wait上了,造成其他文件描述符的任务饿死。上一小节也有。

另一篇文章中对ET模式下连接文件描述符一般设置为非阻塞的解释:

  • 阻塞 connfd 的ET模式下,如果不一次性读取一个事件上的数据,会干扰下一个事件,所以必须在读取数据的外部套一层循环,这样才能完整的处理数据。但是外层套循环之后会导致另外一个问题:处理完数据之后,程序会一直卡在 recv() 函数上,因为是阻塞 IO,如果没数据可读,它会一直等在那里,直到有数据可读。但是这个时候,如果用另一个客户端去连接服务器,服务器就不能受理这个新的客户端了。
  • 非阻塞 connfd 的ET模式下,和阻塞版本一样,必须在读取数据的外部套一层循环,这样才能完整的处理数据。因为非阻塞 IO 如果没有数据可读时,会立即返回,并设置 errno。这里我们根据 EAGAIN 和 EWOULDBLOCK 来判断数据是否全部读取完毕了,如果读取完毕,就会正常退出循环了。

2 使用ET和LT的区别

LT:水平触发,效率会低于ET触发,尤其在大并发,大流量的情况下。但是LT对代码编写要求比较低,不容易出现问题。LT模式服务编写上的表现是:只要有数据没有被获取,内核就不断通知你,因此不用担心事件丢失的情况。

ET:边缘触发,效率非常高,在并发,大流量的情况下,会比LT少很多epoll的系统调用,因此效率高。但是对编程要求高,需要细致的处理每个请求,否则容易发生丢失事件的情况。

下面举一个列子来说明LT和ET的区别(都是非阻塞模式,阻塞就不说了,效率太低):

采用LT模式下,如果accept调用有返回就可以马上建立当前这个连接了,再epoll_wait等待下次通知,和select一样。

但是对于ET而言,如果accpet调用有返回,除了建立当前这个连接外,不能马上就epoll_wait还需要继续循环accpet,直到返回-1,且errno==EAGAIN,

从本质上讲:与LT相比,ET模型是通过减少系统调用来达到提高并行效率的。

3 一道腾讯后台开发的面试题

使用Linux epoll模型,水平(LT)触发模式,当socket可写时,会不停的触发socket可写的事件,如何处理?

第一种最普遍的方式:
需要向socket写数据的时候才把socket加入epoll,等待可写事件。接受到可写事件后,调用write或者send发送数据。当所有数据都写完后,把socket移出epoll。

这种方式的缺点是,即使发送很少的数据,也要把socket加入epoll,写完后在移出epoll,有一定操作代价。

一种改进的方式:
开始不把socket加入epoll,需要向socket写数据的时候,直接调用write或者send发送数据。如果返回EAGAIN,把socket加入epoll,在epoll的驱动下写数据,全部数据发送完毕后,再移出epoll。

这种方式的优点是:数据不多的时候可以避免epoll的事件处理,提高效率。

4什么情况下用ET

很简单,当你想提高程序效率的时候。

5 一个epoll实例

    #include <sys/socket.h>
    #include <sys/wait.h>
    #include <netinet/in.h>
    #include <netinet/tcp.h>
    #include <sys/epoll.h>
    #include <sys/sendfile.h>
    #include <sys/stat.h>
    #include <unistd.h>
    #include <stdio.h>
    #include <stdlib.h>
    #include <string.h>
    #include <strings.h>
    #include <fcntl.h>
    #include <errno.h>

    #define MAX_EVENTS 10
    #define PORT 8080

    //设置socket连接为非阻塞模式
    void setnonblocking(int sockfd) {
        int opts;
        opts = fcntl(sockfd, F_GETFL);
        if(opts < 0) {
            perror("fcntl(F_GETFL)\n");
            exit(1);
        }
        opts = (opts | O_NONBLOCK);
        if(fcntl(sockfd, F_SETFL, opts) < 0) {
            perror("fcntl(F_SETFL)\n");
            exit(1);
        }
    }

    int main(){
        struct epoll_event ev, events[MAX_EVENTS]; //ev负责添加事件,events接收返回事件
        int addrlen, listenfd, conn_sock, nfds, epfd, fd, i, nread, n;
        struct sockaddr_in local, remote;
        char buf[BUFSIZ];

        //创建listen socket
        if( (listenfd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
            perror("sockfd\n");
            exit(1);
        }
        setnonblocking(listenfd);//listenfd设置为非阻塞[1]
        bzero(&local, sizeof(local));
        local.sin_family = AF_INET;
        local.sin_addr.s_addr = htonl(INADDR_ANY);;
        local.sin_port = htons(PORT);
        if( bind(listenfd, (struct sockaddr *) &local, sizeof(local)) < 0) {
            perror("bind\n");
            exit(1);
        }
        listen(listenfd, 20);

        epfd = epoll_create(MAX_EVENTS);
        if (epfd == -1) {
            perror("epoll_create");
            exit(EXIT_FAILURE);
        }

        ev.events = EPOLLIN;//这里应该还有ET模式,下面的代码是符合accept的ET模式的,可能作者是忘记了
        ev.data.fd = listenfd;
        if (epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev) == -1) {//监听listenfd
            perror("epoll_ctl: listen_sock");
            exit(EXIT_FAILURE);
        }

        for (;;) {
            nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
            if (nfds == -1) {
                perror("epoll_pwait");
                exit(EXIT_FAILURE);
            }

            for (i = 0; i < nfds; ++i) {
                fd = events[i].data.fd;
                if (fd == listenfd) {
                    while ((conn_sock = accept(listenfd,(struct sockaddr *) &remote,
                                    (size_t *)&addrlen)) > 0) {
                        setnonblocking(conn_sock);//下面设置ET模式,所以要设置非阻塞
                        ev.events = EPOLLIN | EPOLLET;
                        ev.data.fd = conn_sock;
                        if (epoll_ctl(epfd, EPOLL_CTL_ADD, conn_sock, &ev) == -1) {//读监听
                            perror("epoll_ctl: add"); //连接套接字
                            exit(EXIT_FAILURE);
                        }
                    }
                    if (conn_sock == -1) {
                        if (errno != EAGAIN && errno != ECONNABORTED
                                && errno != EPROTO && errno != EINTR)
                            perror("accept");
                    }
                    continue;
                }
                if (events[i].events & EPOLLIN) {
                    n = 0;
                    while ((nread = read(fd, buf + n, BUFSIZ-1)) > 0) {//ET下可以读就一直读
                        n += nread;
                    }
                    if (nread == -1 && errno != EAGAIN) {
                        perror("read error");
                    }
                    ev.data.fd = fd;
                    ev.events = events[i].events | EPOLLOUT; //MOD OUT
                    if (epoll_ctl(epfd, EPOLL_CTL_MOD, fd, &ev) == -1) {
                        perror("epoll_ctl: mod");
                    }
                }
                if (events[i].events & EPOLLOUT) {
                  sprintf(buf, "HTTP/1.1 200 OK\r\nContent-Length: %d\r\n\r\nHello World", 11);
                    int nwrite, data_size = strlen(buf);
                    n = data_size;
                    while (n > 0) {
                        nwrite = write(fd, buf + data_size - n, n);//ET下一直将要写数据写完
                        if (nwrite < n) {
                            if (nwrite == -1 && errno != EAGAIN) {
                                perror("write error");
                            }
                            break;
                        }
                        n -= nwrite;
                    }
                    close(fd);
                }
            }
        }
        return 0;
    }

6 特别注意写已经就绪,写缓冲区就绪

  • 1
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值