TCP/IP网络编程 第十七章:优于select的epoll

epoll理解及应用

select复用方法其实由来已久,因此,利用该技术后,无论如何优化程序性能也无法同时接入上百个客户端(当然,硬件性能不同,差别也很大)。这种select方式并不适合以Web服务器端开发为主流的现代开发环境,所以要学习Linux平台下的epoll。

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

第12章曾经实现过基于select的IO复用服务器端,很容易从代码上分析出不合理的设计,最
主要的两点如下。
□调用select函数后常见的针对所有文件描述符的循环语句。
□每次调用select函数时都需要向该函数传递监视对象信息。
上述两点可以从第12章回声服务器示例的第45,49行及第54行代码得到确认。调用select函数后,并不是把发生变化的文件描述符单独集中到一起,而是通过观察作为监视对象的fd_set变量的变化,找出发生变化的文件描述符(示例的第54、56行),因此无法避免针对所有监视对象的循环语句。而且,作为监视对象的fd_set变量会发生变化,所以调用select函数前应复制并保存原有信息(参考示例的第45行),并在每次调用select函数时传递新的监视对象信息。


各位认为哪些因素是提高性能的更大障碍?是调用select函数后常见的针对所有文件描述符对象的循环语句?还是每次需要传递的监视对象信息?

在代码层面上思考,很容易认为是循环。但相比于循环语句,更大的障碍是每次传递监视对象信息。因为传递对象信息具有如下含义:"每次调用select函数时向操作系统传递监视对象信息。"

应用程序向操作系统传递数据将对程序造成很大负担,而且无法通过优化代码解决,因此将成为性能上的致命缺点。

select函数的这一缺点可以通过如下方式弥补:“仅向操作系统传递1次监视对象,监视范围或内容发生变化时只通知发生变化的事项。”

这样就无需每次调用select函数时都向操作系统传递监视对象信息,但前提是操作系统支持这种处理方式(每种操作系统支持的程度和方式存在差异)。Linux的支持方式是epoll,Windows的支持方式是IOCP。

select也有优点

知道这些内容后,有些人可能对select函数感到失望,但大家应当掌握select函数。本章的epoll方式只在Limux下提供支持,也就是说,改进的IO复用模型不具有兼容性。相反,大部分操作系统都支持select函数。只要满足或要求如下两个条件,即使在Linux平台也不应拘泥于epoll。
□服务器端接入者少
□程序应具有兼容性
实际并不存在适用于所有情况的模型。各位应理解好各种模型的优缺点。

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

能够克服select函数缺点的epoll函数具有如下优点,这些优点正好与之前的select函数缺点相反。
□无需编写以监视状态变化为目的的针对所有文件描述符的循环语句。
□调用对应于select函数的epoll_wait函数时无需每次传递监视对象信息。

下面介绍epoll服务器端实现中需要的三个函数,希望各位结合epoll函数的优点理解这些函数的功能。

□epoll_create:创建保存epoll文件描述符的空间。

□epoll_ctl:像空间注册或注销文件描述符。

□epoll_wait:于select函数类似,等待文件描述符发生变化。

select方式中为了保存监视对象文件描述符,直接声明了fd_set变量。但epoll方式下由操作系统负责保存监视对象文件描述符,因此需要向操作系统请求创建保存文件描述符的空间,此时使用的函数就是epoll_create。
此外,为了添加和删除监视对象文件描述符,select方式中需要FD_SET,FD_CLR函数。但在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_event结构体数组后,传递给epoll_wait函数时,发生变化的文件描述符信息被填入该数组。因此无需向select函数那样针对所有文件描述符进行循环。

epoll_create

epoll是从Linux2.5.44版内核开始引入的,所以使用epoll前需要验证Linux内核版本。下面仔细观察epoll_create函数。

#include
int epoll_create(int size);//成功时返回epoll文件描述符,失败时返回-1
    size   //epoll示例的大小

调用epoll_create函数时创建的文件描述符保存空间称为"epoll例程"。还有一个注意点,这个传入的size大小实际上并非用来决定epoll例程的大小,而仅供操作系统参考。

epoll_create函数创建的资源与套接字相同,也由操作系统管理。因此,该函数和创建套接字的情况相同,也会返回文件描述符。需要终止时,与其他文件描述符相同,也要调用close函数。

epoll_ctl

#include
int epoll_ctl(int epfd,int op,int fd,struct epoll_event* event);
//成功时返回0,失败时返回-1
     epfd    //用于注册监视对象epoll例程的文件描述符
     op      //用于指定监视对象的添加,删除或更改等操作 
     fd      //需要注册监视对象文件描述符
     event   //监视对象的事件类型

接下来是两个简单示例

epoll_ctl(A,EPOLL_CTL_ADD,B,C);
epoll_ctl(A,EPOLL_CTL_DEL,B,NULL);

第一句含义是:"epoll例程A中注册文件描述符B,主要目的是监视参数C中的事件"

第二句含义是:"从epoll例程A中删除文件描述符B"

从上述调用语句中可以看到,从监视对象中删除时,不需要监视类型(事件信息),因此向第四个参数传递NULL。

接下来介绍可以向epoll_ctl第二个参数传递的常量及含义。
□ EPOLL_CTL_ADD:将文件描述符注册到epoll例程。

□ EPOLL_CTL DEL:从epoll例程中删除文件描述符。

□ EPOLL _CTL_MOD:更改注册的文件描述符的关注事件发生情况。

下面讲解各位不太熟悉的epoll_ctl函数的第四个参数,其类型是之前讲过的epoll_event结构体指针。如前所述,epoll_event结构体用于保存发生事件的文件描述符集合。但也可以在epoll例程中注册文件描述符时,用于注册关注的事件!(有两个功能)函数中epoll_event结构体的定义并不显眼,因此通过调用语句说明该结构体在epoll_ctl函数中的应用。

struct epoll_event event;
....
event.events=EPOLLIN;//事件种类
event.data.fd=sockfd;
epoll_ctl(epfd,EPOLL_CTL_ADD,sockfd,&event);
....

上述代码将sockfd注册到epoll例程epfd中,并在需要读取数据的情况下产生相应事件。接下来给出epoll_event的成员events中可以保存的常量及所指的事件类型。
□ EPOLLIN:需要读取数据的情况。
□ EPOLLOUT:输出缓冲为空,可以立即发送数据的情况。
□ EPOLLPRI:收到OOB数据的情况。
□ EPOLLRDHUP:断开连接或半关闭的情况,这在边缘触发方式下非常有用。
□ EPOLLERR:发生错误的情况。
□ EPOLLET:以边缘触发的方式得到事件通知。
□ EPOLLONESHOT:发生一次事件后,相应文件描述符不再收到事件通知。因此需要向
epoll_ctl函数的第二个参数传递EPOLL_CTL_MOD,再次设置事件。

可以通过位或运算同时传递多个上述参数。关于“边缘触发”稍后将单独讲解,目前只需记住EPOLLIN即可。

epoll_wait

最后介绍与select函数对应的epoll_wait函数,epoll相关函数中默认最后调用该函数。

#include
int epoll_wait(int epfd,struct epoll_event*events,int maxevent,int timeout);
    epfd      //表示事件发生监视范围的epoll例程的文件描述符
    events    //保存发生事件的文件描述符集合的结构体地址
    maxevents //表示第二个参数中可以保存的最大事件数
    timeout   //以1/1000秒为单位的等待时间,传递-1时,一直等待直到发生事件

该函数的调用方式如下。需要注意的是,第二个参数所指缓冲需要动态分配。

int event_cnt;
struct epoll_event* ep_events;
....
ep_events=malloc(sizeof(struct epoll_event)*EPOLL_SIZE);//EPOLL_SIZE是宏常量
....
event_cnt=epoll_wait(epfd,ep_event,EPOLL_SIZE,-1);
....

调用函数后,返回发生事件的文件描述符数,同时在第二个参数指向的缓冲中保存发生事件的文件描述符集合。因此,无需像select那样插入针对所有文件描述符的循环。

基于epoll的回声服务端

#include
#include
#include
#include
#include
#include
#include

#define BUF_SIZE 100
#define EPOLL_SIZE 50
void error_handling(char *message);

int main(int argc,char *argv[]){
    int serv_sock,clnt_sock;
    struct sockaddr_in serv_addr,clnt_addr;
    socklen_t addr_sz;
    int str_len,i;
    char buf[BUF_SIZE];

    struct epoll_event *ep_events;
    struct epoll_event event;
    in epfd,event_cnt;

    if(argc!=2){
        printf("Usage : %s \n",argv[0]);
        exit(1);
    }

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

    if(bind(serv_sock,(struct sockaddr*)&serv_addr,sizeof(serv_addr))==-1)
          error_handling("bind() error");
    if(listen(serv_sock,5)==-1)
          error_handling("listen() error");
  
    epfd=epoll_create(EPOLL_SIZE);
    ep_events=malloc(sizeof(struct epoll_event)*EOLL_SIZE);

    event.events=EPOLLIN;
    event.data.fd=serv_sock;
    epoll_ctl(epfd,EPOLL_CTL_ADD,serv_sock,&event);

    while(1){
        event_cnt=epoll_wait(epfd,ep_events,EPOLL_SIZE,-1);
        if(event_cnt==-1){
             puts("epoll_wait() error");
             break;
        }
        
        for(i=0;i

上述代码的演示和之前章节中的select函数代码相似,可以结合之前章节中的代码思路进行理解。

条件触发和边缘触发

有些人学习epoll时往往无法正确区分条件触发(Level Trigger)和边缘触发(Edge Trigger),
但只有理解了二者区别才算完整掌握epoll。

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

首先给出示例帮助各位理解条件触发和边缘触发。观察如下对话,可以通过对话内容理解条件触发事件的特点。

儿子:"妈妈,这次期末考试我全部都是A。"

妈妈:"好棒!"

儿子:"我的总分排全班第一。"

妈妈:"做的好!"

儿子:"但我的总分在年级中只排第十名"

妈妈:"不要灰心,以及很好了!"

从上述对话可以看出,儿子从说期末考开始一直向妈妈报告,这就是条件触发的原理。我将其整理如下:“条件触发方式中,只要输入缓冲有数据就会一直通知该事。"

例如,服务器端输入缓冲收到50字节的数据时,服务器端操作系统将通知该事件(注册到发生变化的文件描述符)。但服务器端读取20字节后还剩30字节的情况下,仍会注册事件。也就是说,条件触发方式中,只要输入缓冲中还剩有数据,就将以事件方式再次注册。接下来通过如下对话介绍边缘触发的事件特性。

儿子:"妈妈,我期末考了。"

妈妈:“考的怎么样。”

儿子:"........."

妈妈:"说话啊!是考差了吗?"

从上述对话可以看出,边缘触发中输人缓冲收到数据时仅注册1次该事件。即使输入缓冲中
还留有数据,也不会再进行注册。

掌握条件触发的事件特性

接下来通过代码来了解条件触发的事件注册方式。epoll默认是以条件触发的方式工作,因此可以通过该实例验证条件触发的特性。

#include <"与之前示例的头文件声明一致,故省略。">
#define BUF_SIZE 4
#define EPOLL_SIZE 50
void error_handling(char *buf);

int main(int argc, char *argv[]){
    int serv_sock, clnt_sock;
    struct sockaddr_in serv_adr, clnt_adr;
    socklen_t adr_sz;
    int str_len, i;
    char buf[BUF_SIZE];

    struct epoll_event *ep_events;
    struct epoll_event event;
    int epfd, event_cnt;
    if(argc!=2){
       printf("Usage : %s \n", argv[0]);
       exit(1);
    }

    serv_sock=socket(PF_INET, SOCK_STREAM, 0);
    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)
       error_handling("bind() error");
    if(listen(serv_sock,5)==-1)
       error_handling("listen() error");

    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);

    while(1){
        event_cnt=epoll_wait(epfd,ep_events,EPOLL_SIZE,-1);
        if(event_cnt==-1){
             puts("epoll_wait() error");
             break;
        }

        puts("return epoll_wait");
        for(i=0; i

上述示例与之前的差异如下。

□将调用read函数时使用的缓冲大小缩减为4个字节(第2行)

□插入验证epoll_wait函数调用次数的语句(第50行)
减少缓冲大小是为了阻止服务器端一次性读取接收的数据。换言之,调用read函数后,输入缓冲中仍有数据需要读取。而且会因此注册新的事件并从epoll_wait函数返回时将循环输出”return epoll_wait”字符串。

从运行结果中可以看出,每当收到客户端数据时,都会注册该事件,并因此多次调用epoll_ wait函数。下面将上述示例改成边缘触发方式,需要做一些额外的工作。但我希望通过最小的改动验证边缘触发模型的事件注册方式。将上述示例的第57行改成如下形式运行服务器端和客户端:

event.events = EPOLLIN|EPOLLET;


更改后可以验证如下事实:“从客户端接收数据时,仅输出1次'return epoll_wait'字符串,这意味着仅注册1次事件。”虽然可以验证上述事实,但客户端运行时将发生错误。大家是否遇到了这种问题?能否自行分析原因?虽然目前不必对此感到困惑,但如果理解了边缘触发的特性,应该可以分析出错误原因。

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

如下两点是实现边缘触发的必知内容。

□通过errno变量验证错误原因。
□为了完成非阻塞(Non-blocking)I/O,更改套接字特性。

Linux的套接字相关函数一般通过返回-1通知发生了错误。虽然知道发生了错误,但仅凭这
些内容无法得知产生错误的原因。因此,为了在发生错误时提供额外的信息,Linux声明了如下
全局变量:

int errno;

为了访问该变量,需要引入error.h头文件。另外每种函数发生错误时,保存到errno变量中的值都不同,没必要记住所有可能的值。学习每种函数的过程中逐一掌握,并能在必要时参考即可。本节只介绍如下类型的错误:"read函数发现输入缓冲中没有数据可读时返回-1,同时在errno中保存EAGAIN常量。”

稍后通过示例给出errno的使用方法。下面讲解将套接字改为非阻塞方式的方法。Linux提供更改或读取文件属性的如下方法(曾在第13章使用过)。

#include
int fcntl(int filedes,int cmd,...);
//成功是返回cmd参数相关值,失败时返回-1
    filedes  //目标的文件描述符。
    cmd      //表示函数调用的目的。

从上述声明中可以看到,fcntl具有可变参数的形式。如果向第二个参数传递F_GETFL,可以获得第一个参数所指的文件描述符属性(int型)。反之,如果传递F_SETFL,可以更改文件描述符属性。若希望将文件(套接字)改为非阻塞模式,需要如下2条语句。

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

通过第一条语句获取之前设置的属性信息,通过第二条语句在此基础上添加非阻塞O_NONBLOCK标志。调用read&write函数时,无论是否存在数据,都会形成非阻塞文件。fcntl函数的适用范围很广,各位既可以在学习系统编程时一次性总结所有适用情况,也可以每次需要时逐一掌握。

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

之所以介绍读取错误原因的方法和非阻塞模式的套接字创建方法,原因在于二者都与边缘触发的服务器端实现有密切联系。

首先说明为何需要通过errno确认错误原因:“边缘触发方式中,接收数据时仅注册1次该事件。”
就因为这种特点,一旦发生输入相关事件,就应该读取输入缓冲中的全部数据。因此需要验
证输入缓冲是否为空。(不然套接字将无法注销)

既然如此,为何还需要将套接字变成非阻塞模式?边缘触发方式下,以阻塞方式工作的read&write函数有可能引起服务器端的长时间停顿(等待数据到来)。因此,边缘触发方式中一定要采用非阻塞read&write函数。接下来给出以边缘触发方式工作的回声服务器端示例。

#include<“添加fcntl.h、errno.h,其他与之前示例的头文件声明一致。“>
#include 
#include 
#define BUF SIZE 4
#define EPOLL_SIZE 50
void setnonblockingmode(int fd);
void error_handling(char *buf);

int main(int argc, char *argv[]){
    int serv_sock,clnt_sock;
    struct sockaddr_in serv_adr, clnt_adr;
    socklen_t adr_sz;
    int str_len, i;
    char buf[BUF_SIZE];

    struct epoll_event *ep_events;
    struct epoll_event event;
    int epfd, event_cnt;
    if(argc!=2){
       printf("Usage : %s \n",argv[0]);
       exit(1);
    }

    serv_sock=socket(PF_INET, SOCK_STREAM, 0);
    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)
        error_handling("bind() error");
    if(listen(serv_sock,5)==-1)
        error_handling("listen()error");

    epfd=epoll_create(EPOLL_SIZE);
    ep_events=malloc(sizeof(struct epoll_event)*EPOLL_SIZE);

    setnonblockingmode(serv_sock);
    event.events=EPOLLIN;
    event.data.fd=serv_sock;
    epoll_ctl(epfd, EPOLL_CTL_ADD, serv_sock, &event);

    while(1){
        event_cnt=epoll_wait(epfd, ep_events, EPOLL_SIZE,-1);
        if(event_cnt==-1){
            puts("epoll_wait() error");
            break;
        }

        puts("return epoll_wait");
        for(i=0; i

条件触发和边缘触发孰强孰弱

我们从理论和代码的角度充分理解了条件触发和边缘触发,但仅凭这些还无法理解边缘触发
相对于条件触发的优点。边缘触发方式下可以做到如下这点:“可以分离接收数据和处理数据的时间点!”

虽然比较简单,但非常准确有力地说明了边缘触发的优点。关于这句话的含义,大家以后开
不同类型的程序时会有更深入的理解。现阶段给出如下情景帮助大家理解:有一个服务器端和三个客户端,这三个客户端分别是A,B,C,它们分别向服务器发送它们对应部分的数据,服务器需要将这些数据组合,以A,B,C的正向顺序排列发送给任意主机。

那么对应服务端的运行过程如下:

□服务器端分别从客户端A、B、C接收数据。
□服务器端按照A、B、C的顺序重新组合收到的数据。
□组合的数据将发送给任意主机。

为了完成该过程,若能按如下流程运行程序,服务器端的实现并不难。
□客户端按照A、B、C的顺序连接服务器端,并依序向服务器端发送数据。

□需要接收数据的客户端应在客户端A、B、C之前连接到服务器端并等待。

但现实中可能频繁出现如下这些情况,换言之,如下情况出现更符合实际。

□客户端C和B正向服务器端发送数据,但A尚未连接到服务器端。

□客户端A、B、C乱序发送数据。
□服务器端已收到数据,但要接收数据的目标客户端还未连接到服务器端。

因此,即使输入缓冲收到数据(注册相应事件),服务器端也能决定读取和处理这些数据的
时间点,这样就给服务器端的实现带来巨大的灵活性。相比于条件触发,如果尝试分离接受数据和处理数据的时间的话,则每次调用epoll_wait函数时都会产生相应事件。而且事件数也会累加,服务端能够接受吗?

你可能感兴趣的:(TCP/IP网络编程,网络,tcp/ip,服务器,网络协议,运维)