Epoll 原理及应用 && ET模式与LT模式

Posted 狱典司

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Epoll 原理及应用 && ET模式与LT模式相关的知识,希望对你有一定的参考价值。

[Ⅰ] Epoll 原理及应用 && ET模式与LT模式

[Ⅱ] Epoll 反应堆模型 核心原理 && 代码讲解

第二部分文章链接Epoll 反应堆模型 核心原理 && 代码讲解

[Ⅰ] Epoll 原理及应用 && ET模式与LT模式

epoll是Linux下多路复用IO接口select/poll的增强版本,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率,因为它会复用文件描述符集合来传递结果而不用迫使开发者每次等待事件之前都必须重新准备要被侦听的文件描述符集合,另一点原因就是获取事件的时候,它无须遍历整个被侦听的描述符集,只要遍历那些被内核IO事件异步唤醒而加入Ready队列的描述符集合就行了。

目前epell是linux大规模并发网络程序中的热门首选模型。

epoll除了提供select/poll那种IO事件的电平触发(Level Triggered)外,还提供了边沿触发(Edge Triggered),这就使得用户空间程序有可能缓存IO状态,减少epoll_wait/epoll_pwait的调用,提高应用程序效率。

一、突破1024文件描述符限制

1.1 查看文件描述符限制

  1. 可以使用cat命令查看当前计算机(或虚拟机)能够打开的最大文件个数,受硬件配置影响。
cat /proc/sys/fs/file-max
  1. 可以用ulimit -a查看,open file标识了当前用户下进程默认最多能打开的文件描述符个数,缺省为1024。

上图的open files值是改过的。

1.2 修改文件描述符限制

可以通过修改配置文件的方式修改该上限值。

sudo vi /etc/security/limits.conf

在文件尾部写入以下配置,soft 软限制,hard 硬限制。如下图所示:

*   	soft nofile 65536
*	    hard nofile 100000

soft值可以通过命令修改,但不能超过hard值:

ulimit -n [修改的soft值]

改完之后要注销用户,使其生效。

二、Epoll基础API

epoll 的特征是一颗平衡二叉树(左右子树高度差不超过1),更严格的说是一颗红黑树;

该监听树由内核创建,并提供可供用户访问的API来增删节查节点。

2.1 epoll_create()

创建一个epoll句柄,参数size用来告诉内核监听的文件描述符的个数,跟内存大小有关。

#include <sys/epoll.h>
int epoll_create(int size) 
  • 参数:

    • size —— 监听文描述符数目(参考值,即大小并不是限定死的,可以动态扩容)
  • 返回值:

    • 成功 —— 返回新创建的监听红黑树的根节点epfd
    • 失败 —— 返回 -1 并置errno

2.2 epoll_ctl()

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

#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
  • 参数:

    • int epfd —— epoll_create()的返回值,即epoll监听红黑树根节点

    • int op —— 对该监听红黑树的具体操作,由宏定义:

      1. EPOLL_CTL_ADD : 添加 fd 到epoll监听红黑树上

      2. EPOLL_CTL_MOD:修改 fd 在epoll监听红黑树上的监听事件

      3. EPOLL_CTL_DEL:将一个 fd 从epoll监听红黑树上摘下(取消监听)

    • int fd —— 待操作的文件描述符

    • struct epoll_event *event —— 结构体指针(地址),是传入传出参数

      struct epoll_event 
      	__uint32_t events; /* Epoll events */
      	epoll_data_t data; /* User data variable */
      ;
      
      • struct epoll_event结构体中的events代表监听事件,可取值:

        EPOLLIN : 表示对应的文件描述符可以读(包括对端SOCKET正常关闭)
        EPOLLOUT: 表示对应的文件描述符可以写
        EPOLLPRI: 表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来)
        EPOLLERR: 表示对应的文件描述符发生错误
        EPOLLHUP: 表示对应的文件描述符被挂断;
        EPOLLET:  将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)而言的
        EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
        
      • struct epoll_event结构体中的联合体参数epoll_data_t data是一个传出参数:

        typedef union epoll_data 
        	void *ptr;
        	int fd;				// 该fd就是传入epoll_ctl()的对应监听事件的fd
        	uint32_t u32;
        	uint64_t u64;
         epoll_data_t;
        
  • 返回值

    • 成功 —— 返回 0
    • 失败 —— 返回 -1 并置errno

2.3 epoll_wait()

等待所监控文件描述符上有事件的产生,类似于select()调用。

#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)
  • 参数:

    • int epfd —— epoll_create()的返回值,即epoll监听红黑树根节点

    • struct epoll_event *events—— 注意这里和用来存内核得到事件的集合

      注意这里和 epoll_ctl() 函数的 struct epoll_event *event参数(一个结构体的指针,传入传出参数)不一样,这里的events 是一个传出参数,且是一个数组,用来存储满足事件的集合。

    • int maxevents —— 告知内核这个events有多大

      比如定义了传出参数 struct epoll_event ret[1024],那么 maxevents的值就填1024,相当于buffer_size;

      但要注意这个maxevents的值不能大于创建epoll_create()时的size(一般取一样的值)。

    • int timeout —— 设置超时时间

      -1: 阻塞

      0: 立即返回,非阻塞

      > 0: 指定毫秒

  • 返回值

    • 成功 —— 返回有多少文件描述符就绪,若设置定时阻塞或非阻塞则超时返回0
    • 出错 —— 返回 -1

三、Epoll socket 基础用例

3.1 Server

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>
#include <sys/epoll.h>
#include <errno.h>
#include <ctype.h>

#define MAXLINE 8192
#define SERV_PORT 8000

#define OPEN_MAX 5000

int main(int argc, char *argv[])

    int i, listenfd, connfd, sockfd;
    int  n, num = 0;
    ssize_t nready, efd, res;
    char buf[MAXLINE], str[INET_ADDRSTRLEN];
    socklen_t clilen;

    struct sockaddr_in cliaddr, servaddr;


    listenfd = socket(AF_INET, SOCK_STREAM, 0);
    int opt = 1;
    setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));      //端口复用
    bzero(&servaddr, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    servaddr.sin_port = htons(SERV_PORT);
    bind(listenfd, (struct sockaddr *) &servaddr, sizeof(servaddr));
    listen(listenfd, 20);

    efd = epoll_create(OPEN_MAX);               //创建epoll模型, efd指向红黑树根节点
    if (efd == -1)
        perr_exit("epoll_create error");

    struct epoll_event tep, ep[OPEN_MAX];       //tep: epoll_ctl参数 //ep[] : epoll_wait参数

    tep.events = EPOLLIN; 
    tep.data.fd = listenfd;           //指定lfd的监听时间为"读"

    res = epoll_ctl(efd, EPOLL_CTL_ADD, listenfd, &tep);    //将lfd及对应的结构体设置到树上,efd可找到该树
    if (res == -1)
        perr_exit("epoll_ctl error");

    for ( ; ; ) 
        /*epoll为server阻塞监听事件, ep为struct epoll_event类型数组, OPEN_MAX为数组容量, -1表永久阻塞*/
        nready = epoll_wait(efd, ep, OPEN_MAX, -1); 
        if (nready == -1)
            perror("epoll_wait error");

        for (i = 0; i < nready; i++) 
            if (!(ep[i].events & EPOLLIN))      //如果不是"读"事件, 继续循环
                continue;

            if (ep[i].data.fd == listenfd)     //判断满足事件的fd是不是lfd            
                clilen = sizeof(cliaddr);
                connfd = accept(listenfd, (struct sockaddr *)&cliaddr, &clilen);    //接受链接

                printf("received from %s at PORT %d\\n", 
                        inet_ntop(AF_INET, &cliaddr.sin_addr, str, sizeof(str)), 
                        ntohs(cliaddr.sin_port));
                printf("cfd %d---client %d\\n", connfd, ++num);

                tep.events = EPOLLIN; tep.data.fd = connfd;
                res = epoll_ctl(efd, EPOLL_CTL_ADD, connfd, &tep);      //加入红黑树
                if (res == -1)
                    perror("epoll_ctl error");

             else                                                     //不是lfd, 
                sockfd = ep[i].data.fd;
                n = read(sockfd, buf, MAXLINE);

                if (n == 0)                                            //读到0,说明客户端关闭链接
                    res = epoll_ctl(efd, EPOLL_CTL_DEL, sockfd, NULL);  //将该文件描述符从红黑树摘除
                    if (res == -1)
                        perror("epoll_ctl error");
                    close(sockfd);                                      //关闭与该客户端的链接
                    printf("client[%d] closed connection\\n", sockfd);

                 else if (n < 0)                                      //出错
                    perror("read n < 0 error: ");
                    res = epoll_ctl(efd, EPOLL_CTL_DEL, sockfd, NULL);  //摘除节点
                    close(sockfd);

                 else                                                 //实际读到了字节数
                    for (i = 0; i < n; i++)
                        buf[i] = toupper(buf[i]);                       //转大写,写回给客户端

                    	write(STDOUT_FILENO, buf, n);
                
            
        
    
    close(listenfd);
    close(efd);

    return 0;



3.2 Client

#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#define MAXLINE 8192
#define SERV_PORT 8000

int main(int argc, char *argv[])

    struct sockaddr_in servaddr;
    char buf[MAXLINE];
    int sockfd, n;

    sockfd = socket(AF_INET, SOCK_STREAM, 0);

    bzero(&servaddr, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    inet_pton(AF_INET, "127.0.0.1", &servaddr.sin_addr);
    servaddr.sin_port = htons(SERV_PORT);

    connect(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr));

    while (fgets(buf, MAXLINE, stdin) != NULL) 
        write(sockfd, buf, strlen(buf));
        n = read(sockfd, buf, MAXLINE);
        if (n == 0) 
            printf("the other side has been closed.\\n");
            break;
        
        else
            write(STDOUT_FILENO, buf, n);
    
    close(sockfd);

    return 0;


四、ET和LT事件模型

EPOLL事件有两种模型:

  • Edge Triggered (ET) 边缘触发 —— 只有数据到来才触发,不管缓存区中是否还有数据。

  • Level Triggered (LT) 水平触发 —— 只要有数据都会触发。

思考如下步骤:

  1. 假定我们已经把一个用来从管道中读取数据的文件描述符(RFD)添加到epoll描述符。

  2. 管道的另一端写入了2KB的数据

  3. 调用epoll_wait,并且它会返回RFD,说明它已经准备好读取操作

  4. 读取1KB的数据

  5. 调用epoll_wait……

在这个过程中,有两种工作模式:

4.1 ET模式

ET模式即Edge Triggered工作模式。

如果我们在第1步将RFD添加到epoll描述符的时候使用了EPOLLET标志,那么在第5步调用epoll_wait之后将有可能会挂起,因为剩余的数据还存在于文件的输入缓冲区内,而且数据发出端还在等待一个针对已经发出数据的反馈信息。只有在监视的文件句柄上发生了某个事件的时候 ET 工作模式才会汇报事件。因此在第5步的时候,调用者可能会放弃等待仍在存在于文件输入缓冲区内的剩余数据。epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。最好以下面的方式调用ET模式的epoll接口,在后面会介绍避免可能的缺陷。

  1. 基于非阻塞文件句柄

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

4.2 LT模式

LT模式即Level Triggered工作模式。

与ET模式不同的是,以LT方式调用epoll接口的时候,它就相当于一个速度比较快的poll,无论后面的数据是否被使用。

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

ET(edge-triggered):ET是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知。请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once).

4.3 ET模式的示例

在下面的示例代码中,父子进程共享一个匿名管道,子进程每5s写入10字节数据;

父进程通过epoll监听该匿名管道,每次监听事件就绪父进程读取5字节数据。

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

#define MAXLINE 10

int main(int argc, char *argv[])

    int efd, i;
    int pfd[2];
    pid_t pid;
    char buf[MAXLINE], ch = 'a';

    pipe(pfd);                  // 创建匿名管道
    pid = fork();               // 父子进程共享管道

    if (pid == 0)              //子 写
        close(pfd[0]);
        while (1) 
            //aaaa\\n
            for (i = 0; i < MAXLINE/2; i++)
                buf[i] = ch;
            buf[i-1] = '\\n';
            ch++;
            //bbbb\\n
            for (; i < MAXLINE; i++)
                buf[i] = ch;
            buf[i-1] = '\\n';
            ch++;
            //aaaa\\nbbbb\\n
            write(pfd[1], buf, sizeof(buf));
            sleep(5);
        
        close(pfd[1]);

     else if (pid > 0)        //父 读
        struct epoll_event event;
        struct epoll_event resevent[10];        //epoll_wait就绪返回event
        int nready, len;

        close(pfd[1]);
        efd = epoll_create(10);

        event.events = EPOLLIN | EPOLLET;     // ET 边沿触发
        //event.events = EPOLLIN;                 // LT 水平触发 (默认)
        event.data.fd = pfd[0];
        epoll_ctl(efd, EPOLL_CTL_ADD, pfd[0], &event);

        while (1) 
            nready = epoll_wait(efd, resevent, 10, -1);
            printf("res %d\\n", nready);
            if (resevent[0].data.fd == pfd[0]) 
                len = read(pfd[0], buf, MAXLINE/2);
                write(STDOUT_FILENO, buf, len);
            
        

        close(pfd[0]);
        close(efd);

     else 
        perror("fork");
        exit(-1);
    

    return 0;

示例的代码逻辑如下:

  • 如果在采用了ET边沿触发模式,即在使用epoll_ctl()将fd挂到监听树上时设置了event.events = EPOLLIN | EPOLLET; ,那么epoll_wait()则只会捕捉上升沿的事件,简而言之就是epoll_wait()只在子进程每次写入数据的时候返回(不论缓冲区里是否还有数据);

  • 与之相比,如果采用的是默认的LT平沿触发模式(event.events = EPOLLIN; // LT 水平触发 (默认)),那么只要缓冲区里还剩余数据,都会认为是读事件就绪,epoll_wait()将返回。

示例代码的子进程每次向管道写入10字节数据,父进程每次从管道中读取5字节数据,但不论是ET模式还是LT模式,父进程读出的数据都是按照子进程写数据的顺序读出的;

在该例子中,ET和LT模式区别具体在于子进程每写一次,ET模式下epoll_wait()只返回一次,LT模式下epoll_wait()会返回两次


【ET模式的详细例子】

  1. 子进程写入 aaaa\\nbbbb\\n;
  2. 父进程epoll_wait()返回,父进程读出aaaa\\n,此时的管道中还剩下bbbb\\n共五字节的数据没有被读取;
  3. 子进程休眠5秒后苏醒,继续向管道写入cccc\\ndddd\\n;
  4. 父进程epoll_wait()返回,注意此时父进程读出的并不是cccc\\n,而是bbbb\\n;此时的管道中还剩下cccc\\ndddd\\n共十字节的数据没有被读取;

**注意:**管道不存在缓冲区溢出的问题,如果缓冲区写满了write会阻塞。

4.4 网络socket中的ET / LT模式

下面的代码逻辑和4.3的示例代码基本一致,区别在于通信双端从父子进程改成了server端和client端,通信介质(文件描述符)从Linux本地系统的管道变成了网络的socket。

但真正使用的epoll ET的时候采用的是非阻塞模式,这里做一个简单的过度。

4.4.1 Server
#include <stdio.h>
#include <string.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <signal.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <sys/epoll.h>
#include <unistd.h>

#define MAXLINE 10
#define SERV_PORT 9000

int main(void)
以上是关于Epoll 原理及应用 && ET模式与LT模式的主要内容,如果未能解决你的问题,请参考以下文章

Epoll 反应堆模型核心原理及代码讲解

Epoll 反应堆模型核心原理及代码讲解

Linux & IO多路转接——epoll详解

Linux & IO多路转接——epoll详解

Linux下select&poll&epoll的实现原理

(转)彻底学会使用epoll——ET模式实现分析