IO多路转接技术 | poll/epoll详解

举报
mindtechnist 发表于 2024/08/06 17:23:22 2024/08/06
【摘要】 ​1. poll详解函数原型 int poll(struct pollfd *fd, nfds_t nfds, int timeout);函数参数fd:数组的地址,struct pollfd all[120]; 其中struct pollfd结构体如下 struct pollfd {​ int fd; /* 文件描述符 */​ short e...

​1. poll详解

  • 函数原型

 int poll(struct pollfd *fd, nfds_t nfds, int timeout);
  • 函数参数

    • fd:数组的地址,struct pollfd all[120]; 其中struct pollfd结构体如下

    struct pollfd {
​
    int   fd;         /* 文件描述符 */
​
          short events;     /* 等待的事件 */
​
          short revents;    /* 实际发生的事件 */
​
    };

    结构体红各项含义如下:

      • 文件描述符fd:表示要坚持测的fd,通过 open("a.txt", O_wronly | O_append); 获得。

      • events:要等待的事件

      • revents:实际发生的事件,它是内核给的反馈,在select的时候,会有一个备份来供内核修改并传出。

      • nfds:数组的最大长度, 数组中最后一个使用的元素下标+1

      • 内核会轮询检测fd数组的每个文件描述符

      • timeout:

        • 1:永久阻塞

        • 0:调用完成立即返回

        • \>0:等待的时长毫秒

  •  函数返回值:IO发生变化的文件描述符的个数。


2. epoll详解

(1)API介绍

int epoll_create(int size);
  • 函数功能:生成一个epoll专用的文件描述符,实际上就是生成一个epoll树的根结点。

  • 函数参数:size,epoll树上能挂的最大文件描述符数量。表示我想在这个树节点上挂size个节点,假如实际上的节点大于size的话epoll会自动扩展,所以这个大小可以随便传,不用太在意。但是这个扩展也是有上限的,如果电脑内存是1G,那么扩展的上限是10万(2G就是20万。。。通过加内存可以扩大上限)。

  • 函数返回值:函数返回值是树的根节点,在后面用到epft参数的时候,都是指这个返回值,也就是树的根节点。


int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
  • 函数功能:用于控制某个epoll文件描述符事件,可以注册、修改、删除。

  • 函数参数:

    • epfd:epoll_create()函数生成的专用文件描述符。

    • op:

      • EPOLL_CTL_ADD       ——  注册  

      • EPOLL_CTL_MOD      ——  修改 

      • EPOLL_CTL_DEL        ——  删除

    • fd:关联的文件描述符

    • event:告诉内核要监听什么事件

      • EPOLLIN     —— 读

      • EPOLLOUT —— 写

      • EPOLLERR  —— 异常

struct epoll_event { /* 该结构体主要存放和fd有关的信息 */
              uint32_t     events;                          
              epoll_data_t data; 
      };
      
      typedef union epoll_data {
              void         *ptr;
               int          fd;
               uint32_t     u32;
               uint64_t     u64;
      } epoll_data_t;

epoll_data_t是一个联合体union,四个成员共用同一块内存,也就是说四个成员我们只能用一个,一般情况下我们用fd,这个fd实际上就是epoll_ctl()函数的第三个参数fd。

如果我们想在epoll树上挂载更多信息,而不仅仅是fd文件描述符的话,我们可以把更多信息封装在结构体中,并把该结构体传给epoll_data_t结构体的ptr指针,这样就可以在epoll树上挂载和fd有关的更多信息。

       struct sockInfo
        {
          int         fd;
          struct sockaddr_in   addr;
        };

比如说,要获取发生变化的fd对应的client的IP和port,就可以利用指针ptr,这样的话联合epoll_data_t中的fd就不能用了,我们把文件描述符传给sockInfo的fd即可完成fd信息的挂载。

int epoll_wait(
      int epfd,
      struct epoll_event* events,  /* 结构体数组 */
      int maxevents,
      int timeout
);
  • 函数功能:等待IO事件发生(可以设置阻塞),epoll_wait()函数相当于前面讲的select()或poll()函数,表示委托内核去进行检测。epoll_event通过返回值和传出参数events来实现把哪几个fd发生变化告诉server进程的目的。首先,每当有fd变化,就把这个fd对应的树节点拷贝到events数组中,最后,有几个fd变化,就返回几。这样只要根据返回值和参数events就可以遍历出所有变化的fd以及相关信息。

  • 函数参数:

    • epfd:要检测的句柄

    • events:用于回传待处理事件的数组。它是一个传出参数,需要提前分配内存,哪个fd发生变化了,就把哪个fd的树节点(struct epoll_event)拷贝一份放到这个数组中。这样epoll就能返回是哪个fd发生了变化。

    • maxevents:告诉内核events的大小,因为内核要把发生变化的fd对应的树节点拷贝到数组中,所以要知道数组大小。

    • timeout:为超时时间

      • 1:永久阻塞

      •  0:立即返回

      • >0

  • 函数返回值:有多少个fd发生了变化就返回几(变化的fd信息存在events数组中)。

(2)epoll树

(3)epoll模型

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <ctype.h>
#include <sys/epoll.h>
​
int main(int argc, const char* argv[])
{
    if(argc < 2)
    {
        printf("eg: ./a.out port\n");
        exit(1);
    }
    struct sockaddr_in serv_addr;
    socklen_t serv_len = sizeof(serv_addr);
    int port = atoi(argv[1]); //字符串转整形值
​
    // 创建套接字
    int lfd = socket(AF_INET, SOCK_STREAM, 0);
    // 初始化服务器 sockaddr_in 
    memset(&serv_addr, 0, serv_len);
    serv_addr.sin_family = AF_INET; // 地址族 
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 监听本机所有的IP
    serv_addr.sin_port = htons(port); // 设置端口 
    // 绑定IP和端口
    bind(lfd, (struct sockaddr*)&serv_addr, serv_len);
​
    // 设置同时监听的最大个数
    listen(lfd, 36);
    printf("Start accept ......\n");
​
    struct sockaddr_in client_addr;
    socklen_t cli_len = sizeof(client_addr);
​
    // 创建epoll树根节点
    int epfd = epoll_create(2000);
    // 初始化epoll树
    struct epoll_event ev;
    ev.events = EPOLLIN;
    ev.data.fd = lfd;
    epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev);
        //存放发生变化的fd对应的树节点
    struct epoll_event all[2000];
    while(1)
    {
        // 使用epoll通知内核fd 文件IO检测
        int ret = epoll_wait(epfd, all, sizeof(all)/sizeof(all[0]), -1);
​
        // 遍历all数组中的前ret个元素 //ret表示有几个变化的fd,变化的fd都存在all数组中
        for(int i=0; i<ret; ++i)
        {
            int fd = all[i].data.fd;
            // 判断是否有新连接
            if(fd == lfd)
            {
                // 接受连接请求 // accept不阻塞,因为已经有连接
                int cfd = accept(lfd, (struct sockaddr*)&client_addr, &cli_len);
                if(cfd == -1)
                {
                    perror("accept error");
                    exit(1);
                }
                // 将新得到的cfd挂到树上
                struct epoll_event temp;
                temp.events = EPOLLIN; //检测cfd对应的读缓冲区,是否有数据传入
                temp.data.fd = cfd;
                epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &temp);
                
                // 打印客户端信息
                char ip[64] = {0};
                printf("New Client IP: %s, Port: %d\n",
                inet_ntop(AF_INET, &client_addr.sin_addr.s_addr, ip, sizeof(ip)),
                        ntohs(client_addr.sin_port));
                
            }
            else
            {
                // 处理已经连接的客户端发送过来的数据
                if(!all[i].events & EPOLLIN) //只处理读事件
                {
                    continue;
                }
                /*
                假如说client发送过了100个数据,也就是serve的read缓冲区有100个数据,
                但是调用recv函数的时候只能读50个数据,而本次循环只调用了一次recv,
                那么只能下次循环再读剩余的50个数据,所以下次循环检测的时候,
                epoll_wait还是会返回,因为缓冲区还是剩余数据。这就是水平触发模式。
                这样的话虽然client只发了1次,但是epoll_wait会通知两次server去读数据。
        */
​
                // 读数据
                char buf[1024] = {0};
                int len = recv(fd, buf, sizeof(buf), 0);
                if(len == -1)
                {
                    perror("recv error");
                    exit(1);
                }
                else if(len == 0)
                {
                    printf("client disconnected ....\n");
                    //close(fd);
                    // fd从epoll树上删除
                    ret = epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
                    // 挂树的时候需要ev,把ev挂在树上删除写NULL就行了
              if(ret == -1)
              {
                    perror("epoll_ctl del error");
                      exit(1);
              }
               close(fd);
                }
                else
                {
                    printf(" recv buf: %s\n", buf);
                    write(fd, buf, len);
                }
            }
        }
    }
​
    close(lfd);
    return 0;
}

epoll维护的红黑树是存在一个共享内存中,内核和用户都可以通过操作这个共享内存来操作树,不需要内核态和用户态的切换,也不需要两种状态之间的数据拷贝,所以效率更高。

(4)epoll的三种工作模式

  • 水平触发模式   - (根据读来解释)  

    • 只要fd对应的缓冲区有数据,epoll_wait就会返回

    • 返回的次数与发送数据的次数没有关系

    • epoll默认的工作模式

  •  边沿触发模式 - ET

    • fd - 默认阻塞属性

    • 客户端给server发数据:

      • 发一次数据server 的 epoll_wait就返回一次

      • 不在乎数据是否读完

      • 如果读不完,如何把数据全部读出来?

      •  while(recv());

        • 数据读完之后recv会阻塞

        • 解决阻塞问题 —— 设置非阻塞fd

对于epoll_wait()来说,epoll_wait 调用次数越多, 系统的开销越大。


水平触发模式会多次返回,只要server的read缓冲区有数据,epoll_wait就返回,也就会通知server去读数据,那么在循环检测的时候,只要server的read缓冲区有数据,epoll_wait就会多次调用,多次返回,并通知server去读数据;假如说client发送过了100个数据,也就是serve的read缓冲区有100个数据,但是调用recv函数的时候只能读50个数据,而本次循环只调用了一次recv,那么只能下次循环再读剩余的50个数据,所以下次循环检测的时候,epoll_wait还是会返回,因为缓冲区还是剩余数据。这就是水平触发模式。这样的话虽然client只发了1次,但是epoll_wait会通知两次server去读数据。—— (printf函数是标准C库函数,C库函数都有一个默认缓冲区,printf的大小是8K。printf函数是行缓冲,使用printf函数的时候,如果不加 \n 会默认等到写满的时候才打印内容,加 \n 会强制把缓冲区的内容打印出来。另外 \0 表示结束,不加 \0 就会一直输出直到遇到 \0,用write(STDOUT_FILENO)替代printf函数就可以解决这些问题。)

边沿触发模式,client发一次数据epoll_wait只返回一次,也就只读一次,这样的话server的read缓冲区可能会有很多数据堆积,server读数据的时候可能读到的是上一次剩余的数据,并且只有client发的时候,epoll_wait才会通知server去读数据,边沿触发模式尽可能减少了epoll_wait的调用次数,缺点是数据有可能读不完导致堆积;

    • 边沿非阻塞触发

      • 效率最高

      • 如何设置非阻塞

        • open()

          • 设置flags

          • 必须 O_WDRW | O_NONBLOCK

          • 终端文件: /dev/tty


      • fcntl

        • int flag = fcntl(fd, F_GETFL);

          • flag |= O_NONBLOCK;

          • fcntl(fd, F_SETFL, flag);

          • 如何将缓冲区的全部数据都读出?

    while(recv() > 0)
         {
          printf();
         }
          • 当缓冲区数据读完之后, 返回值是否为0?

            • 阻塞状态

              • 数据读完之后,recv阻塞

            • 非阻塞状态

              • 强行读了一个没有数据的缓冲区(fd),数据已经被读完了,因为是非阻塞,所以在while循环中recv还要继续读,导致返回-1

              • 判断 errno == EAGAIN

    示例

    #include <stdio.h>
    #include <unistd.h>
    #include <stdlib.h>
    #include <sys/types.h>
    #include <string.h>
    #include <sys/socket.h>
    #include <arpa/inet.h>
    #include <ctype.h>
    #include <sys/epoll.h>
    #include <fcntl.h>
    #include <errno.h>
    ​
    int main(int argc, const char* argv[])
    {
        if(argc < 2)
        {
            printf("eg: ./a.out port\n");
            exit(1);
        }
        struct sockaddr_in serv_addr;
        socklen_t serv_len = sizeof(serv_addr);
        int port = atoi(argv[1]);
    ​
        // 创建套接字
        int lfd = socket(AF_INET, SOCK_STREAM, 0);
        // 初始化服务器 sockaddr_in 
        memset(&serv_addr, 0, serv_len);
        serv_addr.sin_family = AF_INET;                   // 地址族 
        serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);    // 监听本机所有的IP
        serv_addr.sin_port = htons(port);            // 设置端口 
        // 绑定IP和端口
        bind(lfd, (struct sockaddr*)&serv_addr, serv_len);
    ​
        // 设置同时监听的最大个数
        listen(lfd, 36);
        printf("Start accept ......\n");
    ​
        struct sockaddr_in client_addr;
        socklen_t cli_len = sizeof(client_addr);
    ​
        // 创建epoll树根节点
        int epfd = epoll_create(2000);
        // 初始化epoll树
        struct epoll_event ev;
    ​
        // 设置边沿触发
        ev.events = EPOLLIN; //监听的文件描述符没必要边沿触发,主要是通信的cfd
        ev.data.fd = lfd;
        epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev);
    ​
        struct epoll_event all[2000];
        while(1)
        {
            // 使用epoll通知内核fd 文件IO检测
            int ret = epoll_wait(epfd, all, sizeof(all)/sizeof(all[0]), -1);
            printf("================== epoll_wait =============\n");
    ​
            // 遍历all数组中的前ret个元素
            for(int i=0; i<ret; ++i)
            {
                int fd = all[i].data.fd;
                // 判断是否有新连接
                if(fd == lfd)
                {
                    // 接受连接请求
                    int cfd = accept(lfd, (struct sockaddr*)&client_addr, &cli_len);
                    if(cfd == -1)
                    {
                        perror("accept error");
                        exit(1);
                    }
                    // 设置文件cfd为非阻塞模式
                    int flag = fcntl(cfd, F_GETFL);
                    flag |= O_NONBLOCK;
                    fcntl(cfd, F_SETFL, flag);
    ​
                    // 将新得到的cfd挂到树上
                    struct epoll_event temp;
                    // 设置边沿触发
                    temp.events = EPOLLIN | EPOLLET;
                    temp.data.fd = cfd;
                    epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &temp);
                    
                    // 打印客户端信息
                    char ip[64] = {0};
                    printf("New Client IP: %s, Port: %d\n",
                    inet_ntop(AF_INET, &client_addr.sin_addr.s_addr, ip, sizeof(ip)),
                    ntohs(client_addr.sin_port));
                    
                }
                else
                {
                    // 处理已经连接的客户端发送过来的数据
                    if(!all[i].events & EPOLLIN) 
                    {
                        continue;
                    }
    ​
                    // 读数据
                    char buf[5] = {0};
                    int len;
                    // 循环读数据
                    while( (len = recv(fd, buf, sizeof(buf), 0)) > 0 )
                    {
                        // 数据打印到终端
                  //不要用printf,因为printf如果找不到 \0 \n 字符会出现乱码,打印不出来等问题
                        write(STDOUT_FILENO, buf, len);
                        // 发送给客户端
                        send(fd, buf, len, 0);
                    }
                    if(len == 0)
                    {
                        printf("客户端断开了连接\n");
                        ret = epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
                        if(ret == -1)
                        {
                            perror("epoll_ctl - del error");
                            exit(1);
                        }
                        close(fd);
                    }
                    else if(len == -1)
                    {
                  //数据已经被读完了,因为是非阻塞,所以在while循环中recv还要继续读,导致返回-1
                        if(errno == EAGAIN)
                        {
                            printf("缓冲区数据已经读完\n");
                        }
                        else
                        {
                    //这才是真正的recv错误
                            printf("recv error----\n");
                            exit(1);
                        }
                    }
    #if 0
                    if(len == -1)
                    {
                        perror("recv error");
                        exit(1);
                    }
                    else if(len == 0)
                    {
                        printf("client disconnected ....\n");
                        // fd从epoll树上删除
                        ret = epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
                        if(ret == -1)
                        {
                            perror("epoll_ctl - del error");
                            exit(1);
                        }
                        close(fd);
                        
                    }
                    else
                    {
                        // printf(" recv buf: %s\n", buf);
                        write(STDOUT_FILENO, buf, len);
                        write(fd, buf, len);
                    }
    #endif
                }
            }
        }
    ​
        close(lfd);
        return 0;
    }

    5)文件描述符1024限制

    对于select来说,无法突破文件描述符1024上限,因为select是通过数组实现的。poll和epoll可以突破1024限制,poll是内部链表实现,而epoll是红黑树实现。

    查看受计算机硬件限制的文件描述符上限可以通过下面命令

    cat /proc/sys/fs/file-max

    同样,我们也可以通过修改配置文件来修改这个上限,但是,我们在程序中设置的时候不能超过硬件限制的上限

    vim /etc/security/limits.conf

    - soft  nofile    8000      —— 也可以通过命令ulimit -n 2000来修改为2000

    - hard  nofile   8000      —— 硬件资源限制

    修改后重启系统即可起效。

    【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
    • 点赞
    • 收藏
    • 关注作者

    评论(0

    0/1000
    抱歉,系统识别当前为高风险访问,暂不支持该操作

    全部回复

    上滑加载中

    设置昵称

    在此一键设置昵称,即可参与社区互动!

    *长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

    *长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。