后台核心编程(十三):网络编程-优于select的epoll


 
终于看到了epoll 这个伟大的I/O复用的方法了!!!激动!!!!!!!!!!!!!

1 epoll的理解及应用

select 复用方法由来已久,因此利用该技术后,无论如何优化程序性能也无法同时介入上百个客户端。这种select 方式并不适合以 web 服务器端开发为主流的现代开发环境,所以需要学习 Linux 环境下的 epoll。

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

前面实现了基于 select 的 I/O 复用技术服务端,其中有不合理的设计如下:

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

echo_selectserv.c

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

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

int main(int argc, char *argv[])
{
    int serv_sock, clnt_sock;
    struct sockaddr_in serv_adr, clnt_adr;
    struct timeval timeout;
    fd_set reads, cpy_reads;

    socklen_t adr_sz;
    int fd_max, str_len, fd_num, i;
    char buf[BUF_SIZE];
    if (argc != 2)
    {
        printf("Usage : %s <port>\n", argv[0]);
        exit(1);
    }
    serv_sock = socket(PF_INET, SOCK_STREAM, 0);    //返回文件描述符serv_sock
    printf("服务器socket对应的文件操作符为:%d\n",serv_sock );
    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");

    FD_ZERO(&reads);        //reads结构体清零
    FD_SET(serv_sock, &reads); //注册服务端套接字
    fd_max = serv_sock;

    while (1)
    {
        cpy_reads = reads;
        timeout.tv_sec = 5;
        timeout.tv_usec = 5000;

        if ((fd_num = select(fd_max + 1, &cpy_reads, 0, 0, &timeout)) == -1) //开始监视,每次重新监听
            break;
        if (fd_num == 0)
            puts("Time-out!");

        for (i = 0; i < fd_max + 1; i++)
        {
            printf("当前的描述符为%d\n", i);
            if (FD_ISSET(i, &cpy_reads)) //查找发生变化的套接字文件描述符
            {
                if (i == serv_sock) //如果是服务端套接字时,受理连接请求
                {
                    adr_sz = sizeof(clnt_adr);
                    clnt_sock = accept(serv_sock, (struct sockaddr *)&clnt_adr, &adr_sz);
                     printf("当前接受客户端socket对应的文件操作符为:%d\n",clnt_sock );
                    FD_SET(clnt_sock, &reads); //注册一个clnt_sock
                    if (fd_max < clnt_sock)
                        fd_max = clnt_sock;
                    printf("Connected client: %d \n", clnt_sock);
                }
                else //不是服务端套接字时
                {
                    printf("当前的描述符为%d进来了\n", i);
                    str_len = read(i, buf, BUF_SIZE); //i指的是当前发起请求的客户端
                    if (str_len == 0)
                    {
                        FD_CLR(i, &reads);
                        close(i);
                        printf("closed client: %d \n", i);
                    }
                    else
                    {
                        write(i, buf, str_len);
                    }
                }
            }
        }
    }
    close(serv_sock);
    return 0;
}

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


上述两点可以从 echo_selectserv.c 得到确认,调⽤ select 函数后,并不是把发⽣变化的⽂件描述符单独集中在⼀起,而是通过作为监视对象的 fd_set 变量的变化,找出发⽣变化的⽂件描述符(54,56⾏),因此⽆法避免针对所有监视对象的循环语句。而且,作为监视对象的 fd_set 会发⽣变化,所以调⽤ select 函数前应该复制并保存原有信息,并在每次调⽤ select 函数时传递新的监视对象信息。

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

只看代码的话很容易认为是循环,但相比循环,更大的障碍是每次传递监视对象信息。因为传递监视对象信息的含义为:每次调用select函数时向操作系统传递监视对象信息。应用程序向操作系统传递数据将对程序造成很大负担,而且无法通过优化代码解决,因此成为性能上的致命弱点。

那为何需要把监视对象信息传递给操作系统呢?有些函数不需要操作系统的帮助就能完成的功能,而有些则必须借助操作系统。假设各位定义了四则运算相关函数,此时无需操作系统的帮助。但select函数与文件描述符相关,更准确地说,是监视套接字变化的函数。而套接字是由操作系统管理的,所以select函数绝对需要借助于操作系统才能完成。select函数的这一缺点的弥补方式为:仅仅向操作系统传递一次监视对象,监视范围或内容发生变化时只通知发生变化的事项

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

知道epoll函数后,可能有人对select函数失望,但大家应该掌握select函数,epoll只在Linux下支持,也就是说改进的 I/O 复用模型不具兼容性,而大部分操作系统都支持 select 函数。所以,select具有以下两个优点:

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

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

能够克服 select 函数缺点的 epoll 函数具有如下优点,这些优点正好与之前的select函数缺点相反:

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

1.3 epoll_create、epoll_ctl、epoll_wait

下面介绍epoll服务端实现中需要的三个函数:

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

select方式中为了保存监视对象文件描述符,直接声明了 fd_set 变量。但 epoll 方式下由操作系统负责保存监视对象文件描述符,因此需要向操作系统请求创建保存文件描述符的空间,此时使用的函数就是 epoll_create

此外,为了添加和删除监视对象文件描述符,select 方式中需要 FD_SET、FD_CLR 函数。但在 epoll 方式中,通过 epoll_ctl 函数请求操作系统完成。还有,select 方式中通过 fd_set 变量查看监视对象的状态变化(事件发生与否),而 epoll 中是通过调用 epoll_wait 函数。还有,select 方式中通过 fd_set 变量查看监视对象的状态变化(事件发生与否),而 epoll 方式中通过如下结构体 epoll_event 将发生变化的文件描述符单独集中到一起。

struct epoll_event
{
    __uint32_t events; 
    epoll_data_t data;   
};<br>
typedef union epoll_data
{
    void *ptr;
    int fd;
    __uint32_t u32;
    __uint64_t u64;
} epoll_data_t;

声明足够大的 epoll_event 结构体数组后,传递给 epoll_wait 函数时,发生变化的文件描述符信息将被填入该数组。因此,无需像 select 函数那样针对所有文件描述符进行循环。以上就是 epoll 中需要的函数和结构体,实际上,只要有 select 程序编写的经验,epoll 程序的编写就不难。接下来给出这些函数的详细说明:

epoll是从Linux2.5.44版内核开始引入的,所以使用epoll前需要先检查Linux内核版本。若有人怀疑自己Linux版本过低,可通过如下命令验证:

# cat /proc/sys/kernel/osrelease
3.10.0-693.21.1.el7.x86_64

epoll_create:

#include <sys/epoll.h>
int epoll_create(int size);
//成功时返回epoll文件描述符,失败时返回-1
  • size 表示 epoll 实例的大小

调用 epoll_create 函数时创建的文件描述符保存空间称为 “epoll例程”,但有些情况下名称不同,需要稍加注意。通过参数 size 传递的值决定 epoll 例程的大小,但该值只是向操作系统提的建议,换言之,size 并非决定 epoll 例程的大小,而是仅供操作系统参考

epoll_ctl:

#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
  • epfd:用于注册监视对象的epoll例程的文件描述符
  • op:用于指定监视对象的添加、删除或更改等操作
  • fd:需要注册的监视对象文件描述符
  • event:监视对象的事件类型

与其他 epoll 函数相比,该函数有些复杂,但通过调用语句就很容易理解。假设按照如下形式调用 epoll_ctl 函数:

epoll_ctl(A, EPOLL_CTL_ADD, fd, DO);

第二个参数EPOLL_CTL_ADD意味着“添加”,因此上述语句的含义是:epoll例程A中注册文件描述符 fd,主要目的是监视 fd 的 DO 状态 。

再介绍一个调用语句:

epoll_ctl(A, EPOLL_CTL_DEL, B, NULL);

上述语句中第二个参数EPOLL_CTL_DEL指“删除”,因此该语句的含义为:从epoll例程A中删除文件描述符B。

从上述调用语句中可以看到,从监视对象中删除时,不需要监视类型(事件信息),因此向第四个参数传递NULL。接下来介绍 epoll_ctl 第二个参数传递的常量及含义:

  • EPOLL_CTL_ADD:将文件描述符注册到epoll例程
  • EPOLL_CTL_DEL:从epoll例程中删除文件描述符
  • EPOLL_CTL_MOD:更改注册的文件描述符的关注事件发生情况

如前所示,向 epoll_ctl 的第二个参数传递 EPOLL_CTL_DEL 时,应同时向第四个参数传递 NULL。但Linux2.6.9之前的内核不允许传递NULL。虽然被忽略掉,但也应传递 epoll_event 结构体变量的地址值。其实这是BUG,但也没必要因此怀疑epoll的功能,因为我们使用的标准函数中也存在BUG。

下面讲解 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,再次设置事件

epoll_wait:

#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
//成功时返回发生事件的文件描述符,失败时返回-1
  • 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_events, EPOLL_SIZE, -1);
……

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

接下来给出基于epoll的回声服务端示例:

echo_epollserv.c:

#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 BUF_SIZE 100
#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 <port>\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;
        }
 
        for (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("connected client: %d \n", clnt_sock);
            }
            else
            {
                str_len = read(ep_events[i].data.fd, buf, BUF_SIZE);
                if (str_len == 0) // close request!
                {
                    epoll_ctl(
                        epfd, EPOLL_CTL_DEL, ep_events[i].data.fd, NULL);
                    close(ep_events[i].data.fd);
                    printf("closed client: %d \n", ep_events[i].data.fd);
                }
                else
                {
                    write(ep_events[i].data.fd, buf, str_len); // echo!
                }
            }
        }
    }
    close(serv_sock);
    close(epfd);
    return 0;
}
 
void error_handling(char *buf)
{
    fputs(buf, stderr);
    fputc('\n', stderr);
    exit(1);
}

测试结果:
在这里插入图片描述
我打开了两个客户端与一个服务器相连接,客户端代码在前面找一个回声客户端就可以啦,epoll_wait函数当侦听到变化的描述符就开始执行。

2 条件触发和边缘触发

条件触发的方式中,只要输入缓冲有数据就会一直通知该事件。例如,服务端输入缓冲收到50字节的数据时,服务端操作系统将通知该事件(注册到发生变化的文件描述符)。但服务端读取20字节后还剩30字节的情况下,仍会注册事件。也就是说,条件触发方式中,只要输入缓冲中还剩有数据,就将以事件方式再次注册。而边缘触发中输入缓冲收到数据时仅注册一次事件,即使输入缓冲中还留有数据,也不会再进行注册.

接下来通过代码了解条件触发的事件注册方式:

echo_EPLTserv.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/epoll.h>
 
#define BUF_SIZE 1
#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 <port>\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;
        }
 
        printf("当前状态下有%d个描述符发生了变化\n",event_cnt);
        for (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("connected client: %d \n", clnt_sock);
            }
            else
            {
                str_len = read(ep_events[i].data.fd, buf, BUF_SIZE);
                if (str_len == 0) // close request!
                {
                    epoll_ctl(epfd, EPOLL_CTL_DEL, ep_events[i].data.fd, NULL);
                    close(ep_events[i].data.fd);
                    printf("closed client: %d \n", ep_events[i].data.fd);
                }
                else
                {
                    write(ep_events[i].data.fd, buf, str_len); // echo!
                }
            }
        }
    }
    close(serv_sock);
    close(epfd);
    return 0;
}
 
void error_handling(char *buf)
{
    fputs(buf, stderr);
    fputc('\n', stderr);
    exit(1);
}

上述示例与之前的echo_epollserv.c之间的差异如下:

  • 第10行:将调用read函数时使用的缓冲大小缩减为 1 字节
  • 第58行:插入验证epoll_wait函数调用次数的语句

减少缓冲大小是为了阻止服务端一次性读取接收的数据。换言之,调用read函数后,输入缓冲中仍然有数据需要读取。而且会因此注册新的事件并从epoll_wait函数返回时将循环输出“return epoll_wait”字符串。前提是条件触发的工作方式与之前描述一致。接下来观察运行结果。

在这里插入图片描述

从运行结果可以看出,每当接收到客户端数据时,都会注册该事件,并且多次调用epoll_wait函数

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

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

Linux 套接字相关函数一般通过返回 -1 通知发生错误,虽然知道发生了错误,但仅凭这些内容无法得知产生错误的原因。因此,为了在发生错误时提供额外的信息,Linux声明了全局变量:int errno;为了访问该变量,需要引入error.h头文件,因为此头文件中有上述变量的 extern 声明。另外,每种函数发生错误时,保存到 error 变量的值都不同。

下面讲解将套接字改为非阻塞方式的方法,Linux提供更改或读取文件属性的如下方法:

#include <fcntl.h>
int fcntl(int filedes, int cmd, ...); //成功时返回cmd参数相关值,失败时返回-1

filedes:属性更改目标的文件描述符
cmd:表示函数调用的目的

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

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

通过第一条语句获取之前设置的属性信息,通过第二条语句在此基础上添加非阻塞O_NONBLOCK标志。调用read 和 write 函数时,无论是否存在数据,都会形成非阻塞文件(套接字)。

echo_EPETserv.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/epoll.h>
 
#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 <port>\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 < 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);
                setnonblockingmode(clnt_sock);
                event.events = EPOLLIN | EPOLLET;
                event.data.fd = clnt_sock;
                epoll_ctl(epfd, EPOLL_CTL_ADD, clnt_sock, &event);
                printf("connected client: %d \n", clnt_sock);
            }
            else
            {
                while (1)
                {
                    str_len = read(ep_events[i].data.fd, buf, BUF_SIZE);
                    if (str_len == 0) // close request!
                    {
                        epoll_ctl(epfd, EPOLL_CTL_DEL, ep_events[i].data.fd, NULL);
                        close(ep_events[i].data.fd);
                        printf("closed client: %d \n", ep_events[i].data.fd);
                        break;
                    }
                    else if (str_len < 0)
                    {
                        if (errno == EAGAIN)
                            break;
                    }
                    else
                    {
                        write(ep_events[i].data.fd, buf, str_len); // echo!
                    }
                }
            }
        }
    }
    close(serv_sock);
    close(epfd);
    return 0;
}
 
void setnonblockingmode(int fd)
{
    int flag = fcntl(fd, F_GETFL, 0);
    fcntl(fd, F_SETFL, flag | O_NONBLOCK);
}
 
void error_handling(char *buf)
{
    fputs(buf, stderr);
    fputc('\n', stderr);
    exit(1);
}
  • 第11行:为了验证边缘触发的工作方式,将缓冲设置为4个字节
  • 第61行:为观察事件发生数而添加的输出字符串的语句
  • 第68、69行:第68行将 accept 函数创建的套接字改为非阻塞模式,第69行向EPOLLIN添加EPOLLET标志,将套接字事件注册方式改为边缘触发
  • 第76、78行:之前的条件触发回声服务端中没有while循环,边缘触发方式中,发生事件时需要读取输入缓冲中的所有数据,因此需要循环调用read函数,如第78行所示
  • 第86行:read函数返回-1且 errno 值为 EAGAIN 时,意味着读取了输入缓冲中的全部数据,因此需要通过 break 语句跳出第76行的循环

运行结果如下:
在这里插入图片描述
上述运行结果中需要注意的是,客户端发送消息次数和服务端epoll_wait函数调用次数。客户端从请求连接到断开连接共发送数据5次,服务端也相应产生5个事件

条件触发和边缘触发孰优孰劣

我们从理论和代码的角度充分理解了条件触发和边缘触发,但仅凭这些还无法理解边缘触发相对于条件触发的优点。边缘触发可以做到分离接收数据和处理时间的时间点。虽然比较简单,但非常准确有力地说明了边缘触发的优点,现在我们来看下图:

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

为了完成该过程,若能按如下流程运行程序,服务端的实现并不难

  • 客户端按照A、B、C的顺序连接服务端,并依序向服务端发送数据
  • 需要接收数据的客户端应在客户端A、B、C之前连接到服务端并等待

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

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

  • 客户端A、B、C乱序发送数据

  • 服务端已收到数据,但要接收数据的目标客户端还未连接到服务端

因此,即使输入缓冲收到数据(注册相应事件),服务端也能决定读取和处理这些数据的时间点,这样就给服务端的实现带来巨大的灵活性。

那么,条件触发中无法区分数据接收和处理吗?并非不可能。但在输入缓冲收到数据的情况下,如果不读取(延迟处理),则每次调用epoll_wait函数时都会产生相应事件。而且事件数也会累加,服务端能承受吗?这在现实中是不合理的

条件触发和边缘触发的区别主要应从服务端实现模型的角度讨论,从实现模型来看,边缘触发更有可能带来高性能,但不能简单地认为“只要使用边缘触发就一定能提高速度”。

相关问题:

1:利⽤ select 函数实现服务器端时,代码层⾯存在的两个缺点是?

①调⽤ select 函数后常⻅的针对所有⽂件描述符的循环语句
②每次调⽤ select 函数时都要传递监视对象信息。

2:⽆论是 select ⽅式还是 epoll ⽅式,都需要将监视对象⽂件描述符信息通过函数调⽤传递给操作系统。请解释传递该信息的原因。

⽂件描述符是由操作系统管理的,所以必须要借助操作系统才能完成。

3:select ⽅式和 epoll ⽅式的最⼤差异在于监视对象⽂件描述符传递给操作系统的⽅式。请说明具体差异,并解:释为何存在这种差异。

select 函数每次调⽤都要传递所有的监视对象信息,而 epoll 函数仅向操作系统传递 1 次监视对象,监视范围或内容发⽣变化时只通知发⽣变化的事项。select 采⽤这种⽅法是为了保持兼容性。

4:epoll 是以条件触发和边缘触发⽅式⼯作。⼆者有何差别?从输⼊缓冲的⻆度说明这两种⽅式通知事件的时间点差异。

在条件触发中,只要输⼊缓冲有数据,就会⼀直通知该事件。边缘触发中输⼊缓冲收到数据时仅注册 1 次该事件,即使输⼊缓冲中还留有数据,也不会再进⾏注册。

边缘触发方式可以分离接收数据和处理数据的时间点,给服务端的实现带来很⼤灵活性。

5:select 、poll 和epoll 的区别。

select 、poll 和epoll 都是多路IO 复用的机制。多路IO 复用就通过一种机制,可以监视多个描述符, 一旦某个描述符就绪( 一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select 、poll 和epoll 本质上都是同步IO ,因为它们都需要在读写事件就绪后自己负责进行读写,即是阻塞的,而异步IO 则无须自己负责进行读写,异步I/O 的实现会负责把数据从内核拷贝到用户空间。

下面对这3 种多路IO 复用进行对比。首先还是来看常见的 select () 和 poll() 。对于网络编程来说, 一般认为 poll() 比 select() 要高级一些,这主要源于以下几个原因。
1、poll() 不要求开发者在计算最大文件描述符时进行+1 的操作。
2、poll() 在应付大数目的文件描述符的时候速度更快,因为对于 select() 来说内核需要检查大量描述符对应的“ set 中的每一个比特位,比较费时。
3、select() 可以监控的文件描述符数目是固定的,相对来说也较少( 1024 或2048 ) 。如果需要监控数值比较大的文件描述符,或是分布得很稀疏的较少的描述符,效率也会很低。而对于 poll() 函数来说,就可以创建特定大小的数组来保存监控的描述符,而不受文件描述符值大小的影响,而且 poll() 可以监控的文件数目远大于select() 。’
4、对于 select() 来说,所监控的fd_set 在 select() 返回之后会发生变化,所以在下一次进入 select () 之前都需要重新初始化需要监控的fd_set, poll() 函数将监控的输入和输出事件分开,允许被监控的文件数组被复用而不需要重新初始化。
5、select () 函数的超时参数在返回时也是未定义的,考虑到可移植性,每次在超时之后在下一次进入到 select () 之前都需要重新设置超时参数。

epoll() 的优点:

1、支持个进程打开大数目的socket 描述符( FD ) 。
 
select() 均不能忍受的是一个进程所打开的FD 是有一定限制的,由FD_SETSIZE 的默认值是1024/2048 。对于那些需要支持上万连接数目的IM服务器来说显然太少了。这时候可以选择修改这个宏然后重新编译内核。不过 epoll 则没有这个限制,它所支持的FD 上限是最大可以打开文件的数目,这个数字一般远大于2048 。举个例子,在1 GB 内存的空间中这个数字一般是10 万左右,具体数目可以使用 cat /proc/sys/fs/file-max 查看, 一般来说这个数目和系统内存关系很大。
 
2、IO 效率不随FD 数目增加而线性下降
 
传统的select、poll 另一个致命弱点就是当你拥有一个很大的socket 集合,不过由于网络延迟,任一时间只有部分的socket 是“活跃” 的,但是select/poll 每次调用都会线性扫描全部的集合,导致效率呈现线性下降。但是epoll 不存在这个问题,它只会对“活跃”的 socket 进行操作一这是因为在内核中实现 epoll 是根据每个 fd 上面的 callback 函数实现的。那么,只有“活跃”的socket 才会主动去调用 callback 函数,其他 idle 状态socket 则不会,在这点上, epoll 实现了一个“伪” AIO ,因为这时候推动力由Linux 内核提供。
 
3、使用mmap 加速内核与用户空间的消息传递。
 
这点实际上涉及epoll 的具体实现。无论是select 、poll 还是epoll 都需要内核把“消息通知给用户空间,如何避免不必要的内存拷贝就显得尤为重要。在这点上, epoll 是通过内核与用户空间mmap 处于同一块内存实现的
对于poll 来说需要将用户传入的 pollfd 数组拷贝到内核空间,因为拷贝操作和数组长度相关,时间上来看,这是一个O(n)操作,当事件发生后, poll 将获得的数据传送到用户空间,并执行释放内存和剥离等待队列等工作,向用户空间拷贝数据与剥离等待队列等操作的时间复杂度同样是O(n) 。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值