十一:优于select的epoll(最重要)

1 epoll理解及应用

  select复用方法由来已久,由于无法同时接入上百个客户端,所以并不适合以Web服务端开发为主流的现代开发环境,所以需要linux平台下的epoll。

1.1 基于select的I/O复用技术速度慢的原因

主要有两点原因:

- 调用select函数后常见的针对所有文件描述符的循环语句
-  每次调用select函数时都需要向该函数传递监视对象信息

前面,调用select函数后并不是把发生变化的文件描述符单独集中到一起,而是通过观察作为监视对象的fd_set变量的变化,找出发生变化的文件描述符,因此无法避免针对所有监视对象的循环语句。而且,作为监视对象的fd_set变量会发生变化,所以调用select函数之前应复制并保存原有信息,并且每次调用select时传递新的监视对象
  尤其是后者导致的性能问题无法避免,因为每次调用select函数时要向操作系统传递监视对象信息,会经历用户空间到内核空间的复制(套接字是操作系统管理的),循环遍历时,还要将整个变量从内核控件复制到用户空间遍历。
  如果只复制一次给操作系统,操作系统监视,然后通知发送变化的事项就好了,所以Linux有了epoll。

1.2 select优点

大部分操作系统都支持select函数,而epoll仅仅在Linux。所以当有如下情况是,请考虑select。

  • 服务器端接入者少
  • 程序应具有兼容性

1.3 实现epoll时必要的函数和结构体

能够克服select函数缺点的epoll函数具有以下优点,恰好与之前的select函数缺点相反。

  • 无需编写以监视状态变化为目的的针对所有文件描述符的循环语句。
  • 调用对应于select函数的epoll_wait函数时无需每次传递监视对象信息。

下面时实现epoll服务端需要的3个函数,结合epoll的优点理解这些函数的功能。

  • epoll_create:创建保存epoll文件描述符的空间。
  • epoll_ctl:向空间注册并注销文件描述符。
  • epoll_wait:与select函数类似,等待文件描述符发生变化。

  select方式中为了保存监视对象文件描述符,直接声明了fd_set变量。但epoll方式下由操作系统负责保存监视对象文件描述符,因此需要向操作系统请求创建保存文件描述的空间,此时使用的函数就是epoll_create
  此外,为了添加和删除文件描述符,直接声明了fd_set变量。但epoll方式下,通过epoll_ctl函数请求操作系统完成。最后,select方式下调用select函数等待文件描述符的变化,而epoll中调用epoll_wait函数。还有,select方式中通过fd_set查看监视对象的状态变化(事件发生与否),而epoll方式下通过如下结构体epoll_event将发生变化的文件描述符单独集中到一起。

struct epoll_event
{
	__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_even幽构体数组后,传递给epoll_wait函数时,发生变化的文件描述符信息将被填入该数组。 因此,无需像select函数那样针对所有文件描述符进行循环。

1.4 epoll_create

  epoll是从Linux的2.5.44版本内核引入的,可以使用cat /proc/sys/kernel/osrelease查看版本,不过基本不用担心,ubuntu18、20、22肯定是满足的。

#include <sys/epoll.h>

//成功时返回epoll文件描述符,失败返回-1
//2.6.8之后这个size参数没用,例程的大小由操作系统自行决定
int epoll_create(int size);

  epoll_ create函数创建的资源与套接字相同,也由操作系统管理。 因此,该函数和创建套接字的情况相同,也会返回文件描述符。 也就是说,该函数返回的文件描述符主要用与于区分epoll例程。 需要终止时与其他文件描述符相同,也要调用close函数。

1.5 epoll_ctl

  生成epoll的例程后,接下来就是在其内部注册监视对象文件描述符。

#include <sys/epoll.h>
//成功返回0,失败返回-1
//epfd:用于注册监视对象的epoll例程的文件描述符
//op:用于指定监视对象的添加、删除或更改等操作
//fd:需要注册的监视对象文件描述符
//event:监视对象的事件类型
int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event);
  • EPOLL_CTL_ADD:将文件描述符添加到epoll例程
  • EPOLL_CTL_DEL:删除
  • EPOLL_CTL_MOD:更改注册的文件描述符的关注事件发生情况。
struct epoll_event event;
....
event.events = EPOLLIN;
event.data.fd = sockfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &event);
- EPOLLIN:需要读取数据的情况
- EPOLLOUT:输出缓冲为空,可以立即发送数据的情况
- EPOLLPRI:收到OOB数据的情况
- EPOLLRDHUB:断开连接或者半关闭的情况,这在边缘触发方式下非常有用
- EPOLLERR:发生错误的情况
- EPOLLET:以边缘触发方式得到事件通知
- EPOLLONESHOT:发生一次事件后,相应文件描述符不再收到事件通知。因此需要向epoll_ctl函数的第二个参数传递EPOLL_CTL_MOD,再设置事件。

1.6 epoll_wait

#include <sys/epoll.h>
//成功是返回发生事件的文件描述符数,失败返回-1
//epfd:表示事件发生监视范围的epoll例程的文件描述符
//events:保存发生事件的文件描述符集合的结构体地址
//maxevents:第二个参数中可以保存的最大事件数
//timeout:以1/1000秒为单位的等待时间,传递-1时,一直等待到发生事件
int epoll_wait(int dpfd, struct epoll_event* events, int maxevents, int timeout);
int event_cnt;
struct epoll_event* ep_event;
....
ep_event = malloc(sizeif(struct epoll_event)*EPOLL_SIZE);//EPOLL_SIZE是宏常量
....
event_cnt = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);

1.7 基于epoll的回声服务端

通过修改select示例说明二者差异:
客户端使用任意的回声客户端即可
在这里插入图片描述
在这里插入图片描述

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

#define BUFF_SIZE 100
#define EPOLL_SIZE 100

void ErrorHandling(char* msg) {
    fputs(msg, stderr);
    fputc('\n', stderr);
    exit(1);
}

int main(int argc, char* argv[]) {
    if (argc != 2) {
        printf("Usage : %s <port> \n", argv[0]);
        exit(1);
    }

    int serv_sock = socket(PF_INET, SOCK_STREAM, 0);
    struct sockaddr_in serv_adr;
    memset(&serv_adr, 0, sizeof(serv_adr));
    serv_adr.sin_family = AF_INET;
    serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
    serv_adr.sin_port = htons(atoi(argv[1]));

    if (bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1) {
        ErrorHandling("bind error");
    }
    if (listen(serv_sock, 5) == -1) {
        ErrorHandling("listen error");
    }

    struct epoll_event* ep_events;
    struct epoll_event event;
    int epfd, event_cnt;
    epfd = epoll_create(EPOLL_SIZE);
    ep_events = malloc(sizeof(struct epoll_event) * EPOLL_SIZE);
    event.events = EPOLLIN;
    event.data.fd = serv_sock;
    epoll_ctl(epfd, EPOLL_CTL_ADD, serv_sock, &event);

    int clnt_sock;
    struct sockaddr_in clnt_adr;
    socklen_t adr_sz;
    char buf[BUFF_SIZE];
    for (;;) {
        event_cnt = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);
        if (event_cnt == -1) {
            fputs("epoll_wait() error", stderr);
            break;
        }
        for (int i = 0; i < event_cnt; i++) {
            if (ep_events[i].data.fd == serv_sock) {
                adr_sz = sizeof(clnt_adr);
                clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);
                event.events = EPOLLIN;
                event.data.fd = clnt_sock;
                epoll_ctl(epfd, EPOLL_CTL_ADD, clnt_sock, &event);
                printf("connect client: %d \n", clnt_sock);
            } else {
                int str_len = read(ep_events[i].data.fd, buf, BUFF_SIZE);
                if (str_len == 0) {
                    //连接关闭
                    epoll_ctl(epfd, EPOLL_CTL_DEL, ep_events[i].data.fd, NULL);
                    close(ep_events[i].data.fd);
                    printf("close client: %d\n", ep_events[i].data.fd);
                } else {
                    write(ep_events[i].data.fd, buf, str_len);
                }
            }
        }
    }
    close(serv_sock);
    close(epfd);
    return 0;
}

2 条件触发和边缘触发

2.1 条件触发和边缘触发的区别在于发生事件的时间点

水平触发
儿子:妈妈,我收到了500元的压岁钱。
妈妈:嗯,省着点花。
儿子:妈妈,我今天花了200元买了个变形金刚。
妈妈:以后不要乱花钱。
儿子:妈妈,我今天买了好多好吃的,还剩下100元。
妈妈:用完了这些钱,我可不会再给你钱了。
儿子:妈妈,那100元我没花,我攒起来了
妈妈:这才是明智的做法!
儿子:妈妈,那100元我还没花,我还有钱的。
妈妈:嗯,继续保持。
儿子:妈妈,我还有100元钱。
妈妈:…
接下来的情形就是没完没了了:只要儿子一直有钱,他就一直会向他的妈妈汇报。LT模式下,只要内核缓冲区中还有未读数据,就会一直返回描述符的就绪状态,即不断地唤醒应用进程。在上面的例子中,儿子是缓冲区,钱是数据,妈妈则是应用进程了解儿子的压岁钱状况(读操作)。

边缘触发
儿子:妈妈,我收到了500元的压岁钱。
妈妈:嗯,省着点花。
(儿子使用压岁钱购买了变形金刚和零食。)
儿子:
妈妈:儿子你倒是说话啊?压岁钱呢?
这个就是ET模式,儿子只在第一次收到压岁钱时通知妈妈,接下来儿子怎么把压岁钱花掉并没有通知妈妈。即儿子从没钱变成有钱,需要通知妈妈,接下来钱变少了,则不会再通知妈妈了。在ET模式下,缓冲区从不可读变成可读,会唤醒应用进程,缓冲区数据变少的情况,则不会再唤醒应用进程

2.2 掌握条件触发的事件特性

在这里插入图片描述
在这里插入图片描述
可以看到一句话触发了四次事件才读完

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

//减小缓冲大小,防止一次性读完
#define BUFF_SIZE 4
#define EPOLL_SIZE 50

void ErrorHandling(char* msg) {
    fputs(msg, stderr);
    fputc('\n', stderr);
    exit(1);
}

int main(int argc, char* argv[]) {
    if (argc != 2) {
        printf("Usage : %s <port> \n", argv[0]);
        exit(1);
    }

    int serv_sock = socket(PF_INET, SOCK_STREAM, 0);
    struct sockaddr_in serv_adr;
    memset(&serv_adr, 0, sizeof(serv_adr));
    serv_adr.sin_family = AF_INET;
    serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
    serv_adr.sin_port = htons(atoi(argv[1]));

    if (bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1) {
        ErrorHandling("bind error");
    }
    if (listen(serv_sock, 5) == -1) {
        ErrorHandling("listen error");
    }

    struct epoll_event* ep_events;
    struct epoll_event event;
    int epfd, event_cnt;
    epfd = epoll_create(EPOLL_SIZE);
    ep_events = malloc(sizeof(struct epoll_event) * EPOLL_SIZE);
    event.events = EPOLLIN;
    event.data.fd = serv_sock;
    epoll_ctl(epfd, EPOLL_CTL_ADD, serv_sock, &event);

    int clnt_sock;
    struct sockaddr_in clnt_adr;
    socklen_t adr_sz;
    char buf[BUFF_SIZE];
    for (;;) {
        event_cnt = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);
        if (event_cnt == -1) {
            fputs("epoll_wait() error", stderr);
            break;
        }
        puts("epoll_wait");
        for (int i = 0; i < event_cnt; i++) {
            if (ep_events[i].data.fd == serv_sock) {
                adr_sz = sizeof(clnt_adr);
                clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);
                event.events = EPOLLIN;
                event.data.fd = clnt_sock;
                epoll_ctl(epfd, EPOLL_CTL_ADD, clnt_sock, &event);
                printf("connect client: %d \n", clnt_sock);
            } else {
                int str_len = read(ep_events[i].data.fd, buf, BUFF_SIZE);
                if (str_len == 0) {
                    //请求关闭
                    epoll_ctl(epfd, EPOLL_CTL_DEL, ep_events[i].data.fd, NULL);
                    close(ep_events[i].data.fd);
                    printf("close client: %d\n", ep_events[i].data.fd);
                } else {
                    write(ep_events[i].data.fd, buf, str_len);
                }
            }
        }
    }
    close(serv_sock);
    close(epfd);
    return 0;
}

通过修改

clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);
event.events = EPOLLIN | EPOLLET;

就可以改成边缘触发,可以验证效果,发现epoll_wait只会输出一次。
但是,结果可能会有一点出乎意料,即没有回声,因为只读一次不满足str_len==0,无法进入else。
即边缘触发只会发送一次信号,如果不读完,那么上次剩余的数据会导致本次读取发生错误。

2.3 边缘触发的服务端的实现中必知的两点

  • 通过errno变量验证错误
  • 为了完成非阻塞I/O,更换套接字特性

  套接字相关函数失败时一般会返回-1,但是无法得知失败的具体原因。因此,为了提供错误发生时的额外信息,Linux声明了errno全局变量。
  为了使用errno,需要包含头文件error.h,这里有errno的extern声明。
  Linux提供了更改或读取文件属性的如下方法:

#include <fcntl.h>

int fcntl(int filedes, int cmd, ...);

修改为非阻塞模式,需要如下2条语句:

int flag = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flag | O_NONBLOCK);

2.4 实现边缘触发的回声服务端

ET要搭配while使用才可以保证在一次触发的前提下一次性读完/写完一个事件的数据;若现在这个事件的数据有9个字节,buffer有5个字节,ET是使用的阻塞I/O,则在while中不停地调用read时,第三次read会阻塞住,然后程序就卡在那里,直到有新数据进入buffer;若ET使用的是非阻塞I/O,则会在第三次read的时候返回error,然后跳出while。
在这里插入图片描述
在这里插入图片描述

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

//减小缓冲大小,防止一次性读完
#define BUFF_SIZE 4
#define EPOLL_SIZE 50

void setNonBlockMode(int fd) {
   int flag = fcntl(fd, F_GETFL, 0);
   fcntl(fd, F_SETFL, flag | O_NONBLOCK);
}

void ErrorHandling(char* msg) {
   fputs(msg, stderr);
   fputc('\n', stderr);
   exit(1);
}

int main(int argc, char* argv[]) {
   if (argc != 2) {
       printf("Usage : %s <port> \n", argv[0]);
       exit(1);
   }

   int serv_sock = socket(PF_INET, SOCK_STREAM, 0);
   int opt = 1;
   setsockopt(serv_sock, SOL_SOCKET, SO_REUSEADDR, (const void*)&opt, sizeof(opt));
   setNonBlockMode(serv_sock);
   struct sockaddr_in serv_adr;
   memset(&serv_adr, 0, sizeof(serv_adr));
   serv_adr.sin_family = AF_INET;
   serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
   serv_adr.sin_port = htons(atoi(argv[1]));

   if (bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1) {
       ErrorHandling("bind error");
   }
   if (listen(serv_sock, 5) == -1) {
       ErrorHandling("listen error");
   }

   struct epoll_event* ep_events;
   struct epoll_event event;
   int epfd, event_cnt;
   epfd = epoll_create(EPOLL_SIZE);
   ep_events = malloc(sizeof(struct epoll_event) * EPOLL_SIZE);
   event.events = EPOLLIN;
   event.data.fd = serv_sock;
   epoll_ctl(epfd, EPOLL_CTL_ADD, serv_sock, &event);

   int clnt_sock;
   struct sockaddr_in clnt_adr;
   socklen_t adr_sz;
   char buf[BUFF_SIZE];
   for (;;) {
       event_cnt = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);
       if (event_cnt == -1) {
           fputs("epoll_wait() error", stderr);
           break;
       }
       puts("epoll_wait");
       for (int i = 0; i < event_cnt; i++) {
           if (ep_events[i].data.fd == serv_sock) {
               adr_sz = sizeof(clnt_adr);
               clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);
               setNonBlockMode(clnt_sock);
               event.events = EPOLLIN | EPOLLET;
               event.data.fd = clnt_sock;
               epoll_ctl(epfd, EPOLL_CTL_ADD, clnt_sock, &event);
               printf("connect client: %d \n", clnt_sock);
           } else {
               for (;;) {
                   int str_len = read(ep_events[i].data.fd, buf, BUFF_SIZE);
                   if (str_len == 0) {
                       //连接关闭,对于socket来说,EOF就是关闭连接
                       epoll_ctl(epfd, EPOLL_CTL_DEL, ep_events[i].data.fd, NULL);
                       close(ep_events[i].data.fd);
                       printf("close client: %d\n", ep_events[i].data.fd);
                       break;
                   } else if (str_len < 0){
                       if (errno == EAGAIN) {
                           printf("读取了全部数据了\n");
                           break; //缓冲区为空
                       }
                   } else {
                       write(ep_events[i].data.fd, buf, str_len);
                   }
               }
           }
       }
   }
   close(serv_sock);
   close(epfd);
   return 0;
}

2.5 LT和ET的优劣

边缘触发优点:可以分离接收数据和处理数据的时间点。
试想如果数据量很大,不能一次读取完,则需要产生很多次事件,服务端压力会很大。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值