epoll全面讲解:从实现到应用

epoll全面讲解:从实现到应用

什么是epoll?或者说,它和select有什么区别?

什么是select?

有的朋友可能对select也不是很了解啊,我这里稍微科普一下:网络连接,服务器也是通过文件描述符来管理这些连接上来的客户端,既然是供连接的服务器,那就免不了要接收来自客户端的消息。那么多台客户端(不出意外,本文的客户端指的是万能并发),消息那么的多,要是漏了一条两条重要消息,那也不要用TCP了,那怎么办?

前辈们就是有办法,轮询,轮询每个客户端文件描述符,查看他们是否带着消息,如果带着,那就处理一下;如果没带着,那就一边等着去。这就是select,轮询,颇有点领导下基层的那种感觉哈。

但是这个select的轮询呐,会有个问题,明眼人一下就能想到,那即是耗费资源啊,耗费什么资源,时间呐,慢呐(其实也挺快了,不过相对epoll来说就是慢)。
再认真想一下,还浪费什么资源,系统资源。有的客户端呐,占着那啥玩意儿不干那啥事儿,这种客户端呐,还不少。这也怪不得人家,哪儿有客户端时时刻刻在发消息,要是有,那就要小心是不是恶意攻击了。那把这么一堆偶尔动一下的客户端的文件描述符一直攥手里,累不累?能一次攥多少个?就像一个老板,要是一直心系员工,一直想着下去巡视,那他可以去当车间组长了哈哈哈。

所以,select的默认上限一般是1024(FD_SETSIZE),当然我们可以手动去改,但是人家给个1024自然有人家的道理,改太大的话系统在这一块的负载就大了。
那句话怎么说的来着,你每次对系统的索取,其实都早已明码标价!哈哈哈。。。

所以,我们选用epoll模型。

什么是epoll?

epoll接口是为解决Linux内核处理大量文件描述符而提出的方案。该接口属于Linux下多路I/O复用接口中select/poll的增强。其经常应用于Linux下高并发服务型程序,特别是在大量并发连接中只有少部分连接处于活跃下的情况 (通常是这种情况),在该情况下能显著的提高程序的CPU利用率。

前面说,select就像亲自下基层视察的老板,那么epoll这个老板就要显得精明的多了。他可不亲自下基层,他找了个美女秘书,他只要盯着他的秘书看就行了,呸,他只需要听取他的秘书的汇报就行了。汇报啥呢?基层有任何消息,跟秘书说,秘书汇总之后一次性交给老板来处理。这样老板的时间不就大大的提高了嘛。

如果你学过设计模式,这就是典型的“命令模式”,非常符合“依赖倒置原则”,这是一个非常美妙的模式,这个原则也是我最喜欢的一个原则,将高层实现与低层实现解耦合,从而可以各自开发,只要接口一致便可,这个接口,就是秘书。

select/poll/epoll差别

  1. poll返回的时候用户态需要轮询判断每个描述符的状态,即使只有一个描述符就绪,也要遍历整个集合。如果集合中活跃的描述符很少,遍历过程的开销就会变得很大,而如果集合中大部分的描述符都是活跃的,遍历过程的开销又可以忽略。epoll的实现中每次只遍历活跃的描述符,在活跃描述符较少的情况下就会很有优势,在代码的分析过程中可以看到epoll的实现过于复杂并且其为实现线程安全需要同步处理(锁),如果大部分描述符都是活跃的,遍历这点区别相对于加锁来说已经微不足道了,此时epoll的效率可能不如select或poll。

  2. 传参方式不同 支持的最大描述符不同,根本原因是内核管理每个文件句柄的数据结构不同,select能够处理的最大fd无法超出FDSETSIZE,因为调用select传入的参数fd_set是一个位数组,数组大小就是FDSETSIZE默认为1024,所以调用方式限制了并发量。Poll是利用一个数组传入的参数,没有最大限制。Epoll不需要每次都传入,因为会调用epoll_ctl添加。使用方式不同,select调用每次都由于内核会对数组进行在线修改,应用程序下次调用select前不得不重置这三个fdset,而poll比他聪明点,将句柄与事件绑定在一起通过一个struct pollfd实现,返回时是通过其revets实现,所以不需要重置该结构,直接传递就行,epoll不需要传递。

  3. poll每次需要从用户态将所有的句柄复制到内核态,如果以万计的句柄会导致每次都要copy几十几百KB的内存到内核态,非常低效。使用epoll时你只需要调用epoll_ctl事先添加到对应红黑树,真正用epoll_wait时不用传递socket句柄给内核,节省了拷贝开销。

  4. 内核实现上:select/poll 轮流调用所有fd对应的poll(把current挂到各个fd对应的设备等待队列上),等到有事件发生的时候会通知他,在调用结束后,又把进程从各个等待队列中删除。在 epoll_wait时,把current轮流的加入fd对应的设备等待队列,在设备等待队列醒来时调用一个回调函数(当然,这就需要“唤醒回调”机制),把产生事件的fd归入一个链表,然后返回这个链表上的fd。

  5. Select 不是线程安全的,epoll是线程安全的,内部提供了锁的保护,就算一个线程在epoll_wait的时候另一个线程epoll_ctl也没问题。

  6. 内核使用了slab机制,为epoll提供了快速的数据结构。

  7. Select和poll相当于epoll的LT模式,不支持ET模式,epoll支持更为该高效的ET模式 (ET和LT差别见下文)

epoll设计思路简介

  • (1)epoll在Linux内核中构建了一个文件系统,该文件系统采用红黑树来构建,红黑树在增加和删除上面的效率极高,因此是epoll高效的原因之一。有兴趣可以百度红黑树了解,但在这里你只需知道其算法效率超高即可。
  • (2)epoll提供了两种触发模式,水平触发(LT)和边沿触发(ET)。当然,涉及到I/O操做也必然会有阻塞和非阻塞两种方案。目前效率相对较高的是 epoll+ET+非阻塞I/O 模型,在具体情况下应该合理选用当前情形中最优的搭配方案。
  • (3)不过epoll则没有这个限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于1024,举个例子,在1GB内存的机器上大约是10万左右,具体数目可以下面语句查看,一般来说这个数目和系统内存关系很大。
系统最大打开文件描述符数
cat /proc/sys/fs/file-max
进程最大打开文件描述符数
ulimit -n

epoll相关API

int epoll_create(int size);

创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大。这个参数不同于select()中的第一个参数,给出最大监听的fd+1的值。需要注意的是,当创建好epoll句柄后,它就是会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。

注意:size参数只是告诉内核这个 epoll对象会处理的事件大致数目,而不是能够处理的事件的最大个数。在 Linux最新的一些内核版本的实现中,这个 size参数没有任何意义。

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

控制某个epoll监控的文件描述符上的事件:注册,修改,删除

参数释义:
epfd:为epoll的句柄
op:表示动作,用3个宏来表示
··· EPOLL_CTL_ADD(注册新的 fd 到epfd)
··· EPOLL_CTL_DEL(从 epfd 中删除一个 fd)
··· EPOLL_CTL_MOD(修改已经注册的 fd 监听事件)

它不同与select()是在监听事件时告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型。

typedef union epoll_data
{
    void* ptr;
    int fd;
    __uint32_t u32;
    __uint64_t u64;
} epoll_data_t;  /* 保存触发事件的某个文件描述符相关的数据 */
 
struct epoll_event
{
    __uint32_t events;  /* epoll event */
    epoll_data_t data;  /* User data variable */
};
/* epoll_event.events:
  EPOLLIN  表示对应的文件描述符可以读
  EPOLLOUT 表示对应的文件描述符可以写
  EPOLLPRI 表示对应的文件描述符有紧急的数据可读
  EPOLLERR 表示对应的文件描述符发生错误
  EPOLLHUP 表示对应的文件描述符被挂断
  EPOLLET  表示对应的文件描述符有事件发生
*/

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

等待所监控文件描述符上有事件的产生

参数释义:
events:用来从内核得到事件的集合
maxevent:用于告诉内核这个event有多大,这个maxevent不能大于创建句柄时的size
timeout:超时时间

成功返回有多少个文件描述符准备就绪,时间到返回0,出错返回-1.

边缘触发与水平触发,阻塞I/O与非阻塞I/O

阻塞I/O与非阻塞I/O

为了方便理解后面的内容,我们先看几张图,关于阻塞与非阻塞I/O的。

阻塞式文件I/O

在这里插入图片描述

非阻塞式文件I/O

在这里插入图片描述

多路复用I/O

在这里插入图片描述

ET V/S LT

EPOLL 事件有两种模型:
Edge Triggered (ET) 边缘触发 只有数据到来,才触发,不管缓存区中是否还有数据。
Level Triggered (LT) 水平触发 只要有数据都会触发。

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

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

epoll 工作在 ET 模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读 / 阻塞写操作把处理多个文件描述符的任务饿死。最好以下面的方式调用 ET 模式的 epoll 接口,在后面会介绍避免可能的缺陷。

  • 基于非阻塞文件句柄

  • 只有当 read(2) 或者 write(2) 返回 EAGAIN 时才需要挂起,等待。但这并不是说每次 read() 时都需要循环读,直到读到产生一个 EAGAIN 才认为此次事件处理完成,当 read() 返回的读到的数据长度小于请求的数据长度时,就可以确定此时缓冲中已没有数据了,也就可以认为此时读事件已处理完成。

内核实现

只是在从rdlist中返回的时候有区别,内核首先会将rdlist拷贝到一个临时链表txlist, 然后如果是LT事件并且事件就绪的话fd被重新放回了rdllist。那么下次epoll_wait当然会又把rdllist里的fd拿来拷给用户了。举个例子。假设一个socket,只是connect,还没有收发数据,那么它的poll事件掩码总是有POLLOUT的,每次调用epoll_wait总是返回POLLOUT事件,因为它的fd就总是被放回rdllist;假如此时有人往这个socket里写了一大堆数据,造成socket塞住,fd不会放回rdllist,epoll_wait将不会再返回用户POLLOUT事件。如果我们给这个socket加上EPOLLET,然后connect,没有收发数据,epoll_wait只会返回一次POLLOUT通知给用户(因为此fd不会再回到rdllist了),接下来的epoll_wait都不会有任何事件通知了。

注意上面LT fd拷贝回rdlist并不是向用户处理完之后发生的,而是向用户拷贝完之后直接复制到rdlist中,那么如果用户消费这个事件使事件不就绪了怎么办,比如说本来是可读的,返回给用户,用户读到不可读为止,继续调用epoll_wait 返回rdlist,则发现不可读,事实上每次返回之前会以NULL继续调用poll,判断事件是否变化,平时调用poll会传递给poll_table变量,就进行添加到等待队列中,而此时不需要添加,只是判断一下状态,如果rdlist中状态变化了,就不会给用户返回了。

触发方式:

根据对两种加入rdlist途径的分析,可以得出ET模式下被唤醒(返回就绪)的条件为:

对于读取操作:

(1) 当buffer由不可读状态变为可读的时候,即由空变为不空的时候。

(2) 当有新数据到达时,即buffer中的待读内容变多的时候。

(3) 当buffer中有数据可读(即buffer不空)且用户对相应fd进行epoll_mod IN事件时

对于写操作:

(1) 当buffer由不可写变为可写的时候,即由满状态变为不满状态的时候。

(2) 当有旧数据被发送走时,即buffer中待写的内容变少得时候。

(3) 当buffer中有可写空间(即buffer不满)且用户对相应fd进行epoll_mod OUT事件时

对于LT模式则简单多了,除了上述操作为读了一条事件就绪就一直通知。

ET比LT高效的原因:

经过上面的分析,可得到LT每次都需要处理rdlist,无疑向用户拷贝的数据变多,且epoll_wait循环也变多,性能自然下降了。

另外一方面从用户角度考虑,使用ET模式,它可以便捷的处理EPOLLOUT事件,省去打开与关闭EPOLLOUT的epoll_ctl(EPOLL_CTL_MOD)调用。从而有可能让你的性能得到一定的提升。例如你需要写出1M的数据,写出到socket 256k时,返回了EAGAIN,ET模式下,当再次epoll返回EPOLLOUT事件时,继续写出待写出的数据,当没有数据需要写出时,不处理直接略过即可。而LT模式则需要先打开EPOLLOUT,当没有数据需要写出时,再关闭EPOLLOUT(否则会一直返回EPOLLOUT事件),而调用epoll_ctl是系统调用,要陷入内核并且需要操作加锁红黑树,总体来说,ET处理EPOLLOUT方便高效些,LT不容易遗漏事件、不易产生bug,如果server的响应通常较小,不会触发EPOLLOUT,那么适合使用LT,例如redis等,这种情况下甚至不需要关注EPOLLOUT,流量足够小的时候直接发送,如果发送不完在进行关注EPOLLOUT,发送完取消关注就行了,可以进行稍微的优化。而nginx作为高性能的通用服务器,网络流量可以跑满达到1G,这种情况下很容易触发EPOLLOUT,则使用ET。

实际应用:

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

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

a. 对于读,只要buffer中还有数据就一直读;

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

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

所以也就常说“ET需要工作在非阻塞模式”,当然这并不能说明ET不能工作在阻塞模式,而是工作在阻塞模式可能在运行中会出现一些问题。

ET模式下的accept

考虑这种情况:多个连接同时到达,服务器的 TCP 就绪队列瞬间积累多个就绪

连接,由于是边缘触发模式,epoll 只会通知一次,accept 只处理一个连接,导致 TCP 就绪队列中剩下的连接都得不到处理。

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

的正确使用方式为:

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)

Server

#include <sys/socket.h>
#include <sys/epoll.h>
#include <arpa/inet.h>
#include <fcntl.h>
#include <errno.h>
#include <string.h>
#include <unistd.h>
#include <stdio.h>


#define MAX_LINE     5
#define MAX_EVENTS   500
#define MAX_LISTENFD 5

int createAndListen() {
    int on = 1;
    int listenfd = socket(AF_INET, SOCK_STREAM, 0);
    struct sockaddr_in servaddr;
    fcntl(listenfd, F_SETFL, O_NONBLOCK);
    setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    servaddr.sin_port = htons(5859);

    if (-1 == bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr)))  {
        printf("bind errno, errno : %d \n", errno);
    }

    if (-1 == listen(listenfd, MAX_LISTENFD))  {
        printf("listen error, errno : %d \n", errno);
    }
    printf("listen in port 5859 !!!\n");
    return listenfd;
}


int main(int argc, char const *argv[])
{
    struct epoll_event ev, events[MAX_EVENTS];
    int epollfd = epoll_create(MAX_EVENTS);     //这个参数已经被忽略,但是仍然要大于
    if (epollfd < 0)  {
        printf("epoll_create errno, errno : %d\n", errno);
    }
    int listenfd = createAndListen();
    ev.data.fd = listenfd;
    ev.events = EPOLLIN;
    epoll_ctl(epollfd, EPOLL_CTL_ADD, listenfd, &ev);

    for ( ;; )
    {
        int fds = epoll_wait(epollfd, events, MAX_EVENTS, -1);   //时间参数为0表示立即返回,为-1表示无限等待
        if (fds == -1)  {
            printf("epoll_wait error, errno : %d \n", errno);
            break;
        }
        else {
            printf("trig %d !!!\n", fds);
        }

        for (int i = 0; i < fds; i++)
        {
            if (events[i].data.fd == listenfd)
            {
                struct sockaddr_in cliaddr;
                socklen_t clilen = sizeof(struct sockaddr_in);
                int connfd = accept(listenfd, (sockaddr*)&cliaddr, (socklen_t*)&clilen);
                if (connfd > 0)  {
                    printf("new connection from %s : %d, accept socket fd: %d \n", inet_ntoa(cliaddr.sin_addr), ntohs(cliaddr.sin_port), connfd);
                }
                else  {
                    printf("accept error, connfd : %d, errno : %d \n", connfd, errno);
                }
                fcntl(connfd, F_SETFL, O_NONBLOCK);
                ev.data.fd = connfd;
                //ev.events = EPOLLIN | EPOLLET;
                ev.events = EPOLLIN | EPOLLOUT;

                if (-1 == epoll_ctl(epollfd, EPOLL_CTL_ADD, connfd, &ev))  {
                    printf("epoll_ctl error, errno : %d \n", errno);
                }
            }
            else if (events[i].events & EPOLLIN)
            {
                int sockfd;
                if ((sockfd =events[i].data.fd) < 0)  {
                    printf("EPOLLIN socket fd < 0 error \n");
                    continue;
                }
                char szLine[MAX_LINE + 1] ;
                int readLen = 0;
                bzero(szLine, MAX_LINE + 1);
                if ((readLen = read(sockfd, szLine, MAX_LINE)) < 0)
                {
                    printf("readLen is %d, errno is %d \n", readLen, errno);
                    if (errno == ECONNRESET)
                    {
                        printf("ECONNRESET closed socket fd : %d \n", events[i].data.fd);
                        close(sockfd);
                    }
                }
                else if (readLen == 0)  {
                    printf("read 0 closed socket fd : %d \n", events[i].data.fd);
                    //epoll_ctl(epollfd, EPOLL_CTL_DEL, sockfd , NULL);
                    //close(sockfd);
                }
                else  {
                    printf("read %d content is %s \n", readLen, szLine);
                }

//                bzero(szLine, MAX_LINE + 1);
//                if ((readLen = read(sockfd, szLine, MAX_LINE)) < 0)
//                {
//                    printf("readLen2 is %d, errno is %d , ECONNRESET is %d \n", readLen, errno, ECONNRESET);
//                    if (errno == ECONNRESET)  {
//                        printf("ECONNRESET2 closed socket fd : %d \n", events[i].data.fd);
//                        close(sockfd);
//                    }
//                }
//                else if (readLen == 0)  {
//                    printf("read2 0 closed socket fd : %d \n", events[i].data.fd);
//                }
//                else  {
//                    printf("read2 %d content is %s \n", readLen, szLine);
//                }
            }
        }

    }
    return 0;
}

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值