Linux网络编程 - 基于 I/O 复用的服务器端(epoll 实现)

引言

         实现 I/O 复用的传统方法有 select 函数和 poll 函数。我们介绍了 select 函数的使用方法,但由于各种原因导致这些方法无法得到令人满意的性能。因此有了 Linux 下的 epoll、BSD 的 kqueue、Solaris 的 /dev/poll 和 Windows 的 IOCP 等复用技术。本文将讲解 Linux 的 epoll 技术。

【select 相关博文链接】

I/O多路复用的实现机制 - select 用法总结

Linux网络编程 - 基于 I/O 复用的服务器端(select 实现)

【poll 相关博文链接】

I/O多路复用的实现机制 - poll 用法总结

一  epoll 理解及应用

基于select  函数 实现的 I/O 复用方式并不适合以 Web 服务器端开发为主流的现代开发环境,所以要学习 Linux 平台下的 epoll。

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

我们在之前的博文中实现过基于 select 的 I/O复用服务器端,很容易从代码上分析出其不合理的设计,最主要的两点如下:

  • 调用 select 函数后需要使用循环语句轮询遍历查找已就绪文件描述符。
  • 每次调用 select 函数时都需要向该函数传递监视对象信息。

        调用 select 函数后,并不是把发生变化的文件描述符单独集中到一起,而是通过观察作为监视对象的 fd_set 结构体变量的变化,然后从中找到发生变化的文件描述符,因此无法避免针对所有监视对象的循环语句。而且,作为监视对象的 fd_set 结构体变量会发生变化,所以每次调用 select 函数前应复制并保存原有信息,并在每次调用 select 函数时传递新的监视对象信息。

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

        只看代码的话很容易认为是循环。但相比循环语句,更大的障碍是每次传递监视对象的信息(即 fd_set 结构体变量)。因为传递监视对象信息具有如下含义:

每次调用 select 函数时向操作系统内核传递监视对象信息。

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

那为何需要把监视对象信息传递给操作系统呢?

        这是因为 select 函数与文件描述符有关,更准确地说,是监视套接字变化的函数。而套接字是由操作系统管理的,所以 select 函数必须借助于操作系统才能完成功能。select 函数的这一缺点可以通过如下方式弥补:

仅向操作系统传递一次监视对象信息,监视范围或内容发生变化时,只通知发生变化的事件。

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

  • 关于 select I/O复用技术的缺点的几点补充说明

1、单个进程可监视的文件描述符数量有限。32位机器默认是1024个,64位默认是2048。即select支持的文件描述符数量太小了。

2、select 函数不是线程安全的函数。如果你在线程 1 中将一个描述符加入到 select 的描述符集合中,然后在线程 2 中,却将这个描述符给回收了,即close掉这个描述符,这会导致不可预测的后果。

1.2 select 也有优点

        本文讲解的 epoll 方式只在 Linux 下提供支持,也就是说,改进的 I/O复用模型不具有兼容性。相反,大部分操作系统都支持 select 函数。只要满足或要求如下两个条件,即使在 Linux 平台下也是可以使用 select 方式的。

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

实际上并不存在适用于所有情况的模型。我们应该理解好各种 I/O复用 模型的优缺点,然后根据实际应用场景选择合适的 IO复用 模型。

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

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

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

下面介绍 epoll 服务器端实现中需要的3个函数,我们可以结合 epoll 函数的优点来理解这些函数的功能。

  • epoll_create():创建保存 epoll 文件描述符的内存空间。
  • epoll_ctl():用来控制 epoll 内核事件表中的监视对象,操作类型可以是添加、删除 或 修改。
  • epoll_wiat():与 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事件
   epoll_data_t data;       //用户数据
};

typedef union epoll_data
{
   void        *ptr;
   int          fd;
   uint32_t     u32;
   uint64_t     u64;
} epoll_data_t;
  • epoll_event 结构体成员说明

(1)events:用来描述事件类型。epoll 支持的事件类型和poll基本相同。表示 epoll 事件类型的宏是在poll对应的宏前面加上 'E',比如 epoll 的数据可读事件是 EPOLLIN。但epoll有两个额外的事件类型:EPOLLET 和 EPOLLONESHOT。它们对于epoll的高效运行非常关键,我们将在后面讨论它们。events可以是以下几个宏的集合:
EPOLLIN :表示对应的文件描述符的可读事件(包括对端socket正常关闭);
EPOLLOUT:表示对应的文件描述符的可写事件;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读事件(这里应该表示有带外数据到来);
EPOLLERR:表示对应的文件描述符有发生错误的事件;
EPOLLHUP:表示对应的文件描述符被挂断的事件;
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里。

(2)data:用于存储用户数据,其数据类型为 epoll_data_t ,其为 epoll_data 共用体类型的别名。

  • epoll_data 共用体成员说明

epoll_data 是一个共用体(也称为联合体)类型,其4个成员中使用最多的是fd,它指定了事件所从属的目标文件描述符fd。ptr成员可用来指定与fd相关的用户数据。但由于 epoll_data_t 是一个共用体,我们不能同时使用其 ptr 成员和 fd 成员,因此,如果要将文件描述符和用户数据关联起来,以实现快速的数据访问,只能使用其他手段,比如放弃使用 epoll_data_t 中的 fd 成员,而在 ptr 指向的用户数据中包含 fd。

        声明足够大的 epoll_event 结构体数组后,传递给 epoll_wait 函数时,发生变化的文件描述符将被填入该数组。因此,无须像 select 函数那样针对所有文件描述符进行循环。

        以上就是 epoll 中需要的函数和结构体。接下来给出这些 epoll 函数的详细使用说明。

1.4 epoll_create 函数

        epoll 是从 Linux 的 2.5.44 版内核(操作系统的核心模块)开始引入的,所以使用 epoll 前需要验证 Linux 的内核版本。但现在主流的Linux操作系统发行版使用的Linux内核基本都是2.6以上的版本,所以这部分可以忽略。若想知道自己使用的Linux发行版的内核版本号是多少,可以通过如下 Linux 命令查看:

$ uname -r
3.10.0-1160.49.1.el7.x86_64

$ cat /proc/sys/kernel/osrelease 
3.10.0-1160.49.1.el7.x86_64

$ cat /etc/redhat-release
CentOS Linux release 7.9.2009 (Core)

可以看到,本人使用的Linux发行版是 CentOS 7.9.2009,Linux内核主版本号是: 3.10。截止目前(2022.1月),Linux内核的最新稳定版为:5.16。

  • Linux内核相关网站

The Linux Kernel Archives

Linux内核源码下载

mirrors.kernel.org

  • epoll_create() — 创建保存 epoll 文件描述符的存储空间。
#include 

int epoll_create(int size);
int epoll_create1(int flags);

//参数说明
//size: epoll实例的大小。

//返回值: 成功时返回 epoll 文件描述符,失败时返回-1,并设置 errno。

        调用 epoll_create 函数时创建的文件描述符保存空间称为 “epoll 例程”,是用来标识在内核中创建的事件表。通过参数 size 传递的值决定 epoll 例程的大小,但该值只是向操作系统提的建议。换言之,size 并非用来决定 epoll 例程的大小,而仅供操作系统参考而已。

提示》操作系统将完全忽略传递给 epoll_create 的参数

        Linux 2.6.8 之后的内核将完全忽略传入 epoll_create 函数的 size 参数,因为内核会根据实际情况调整 epoll 例程的大小,但传入的值必须是一个大于 0 的整数值。

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

知识补充epoll_create1 函数

epoll_create1() 函数在 Linux内核 2.6.27 版本中添加到内核中的。glibc 库从2.9版开始提供了支持。

(1)如果传入的参数为 0,epoll_create1 函数实现的功能与 epoll_create 是相同的。

(2)如果传给形参 flags 的值为 EPOLL_CLOEXEC,这是为了在 epoll例程对应的文件描述符中设置 close-on-exec (FD_CLOEXEC) 选项, 设置这个选项的作用是为了解决使用 fork 函数创建子程序后,在子程序中执行 exec 函数族时自动关闭无用的文件描述符。

epoll_create & epoll_create1 函数相关博文链接

epoll_create(2) — Linux manual page

epoll_create和epoll_create1

epoll_create1与epoll_create区别

epoll源码解析(1) epoll_create

1.5 epoll_ctl 函数

生成 epoll 例程后,应在其内部注册监视对象文件描述符,此时使用 epoll_ctl 函数。

  • epoll_ctl() — epoll 描述符的控制接口函数。
#include 

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

参数说明

  • epfd:用于注册监视对象的epoll例程的文件描述符。
  • op:用于指定监视对象的添加、删除、或更改操作。
  • fd:需要关注的监视对象文件描述符。
  • event:监视对象的事件类型。

返回值】成功时返回0,失败时返回-1,并设置 errno。

  • epoll_ctl 函数第三个参数 op

epoll_ctl 函数第三个参数 op 指定监视对象的操作类型。具体的操作类型有如下三种:

  • EPOLL_CTL_ADD:将文件描述符注册到epoll例程,也就是添加到epoll内核事件表中。
  • EPOLL_CLT_DEL:从epoll例程中删除文件描述符,也就是从epoll内核事件表中删除。
  • EPOLL_CTL_MOD:更改已注册的文件描述符的关注事件类型。

epoll_ctl 函数的调用形式如下:

epoll_ctl(A, EPOLL_CTL_ADD, B, C);

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

再介绍一个调用语句:

epoll_ctl(A, EPOLL_CTL_DEL, B, NULL);

上述语句的含义是:从epoll例程A中删除文件描述符B

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

  • epoll_ctl 函数第四个参数 event

接下来讲解 epoll_ctl 函数的第四个参数,其类型是上文中讲过的 epoll_event 结构体指针。

上文中讲过,epoll_event 结构体用于保存发生事件的文件描述符集合,但也可以在epoll例程中注册文件描述符时,注册用户关注的事件类型。

我们可以通过下面的调用语句来说明 epoll_event 结构体在 epoll_ctl 函数中的应用。

struct epoll_event event;  //声明一个epoll_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_ctl 函数相关内容博文链接

epoll_ctl(2) — Linux manual page

epoll源码解析(2) epoll_ctl

1.6 epoll_wait 函数

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

  • epoll_wait() — 等待注册事件的发生。
#include 

int epoll_wait(int epfd, struct epoll_event *events,
               int maxevents, int timeout);
int epoll_pwait(int epfd, struct epoll_event *events,
                int maxevents, int timeout,
                const sigset_t *sigmask);

参数说明

  • epfd:表示用于注册监视对象的epoll例程的文件描述符。
  • events:保存发生事件的文件描述符集合的结构体数组地址值。
  • maxevents:第二个参数中可以保存的最大事件数。
  • timeout:以毫秒为单位的超时等待时间,传递 -1 时,一直等待直到发生事件,函数才返回。

返回值】成功时返回发生事件对应的文件描述符数,失败时返回 -1,并设置 errno 值。

该函数的调用方式如下。需要注意的是,第二个参数所指向的内存空间需要动态分配或者使用结构体数组提前分配好。

#define EPOLL_SIZE 50
. . . . .
int event_cnt;
struct epoll_event *ep_events;  //声明结构体指针
. . . . .
ep_events = malloc(sizeof(struct epoll_event) * EPOLL_SIZE);
. . . . .
event_cnt = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);
. . . . .

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

epoll_wait 函数相关内容博文链接

epoll_wait(2) — Linux manual page

epoll源码解析(3) epoll_wait

1.7 实现基于 epoll 的回声服务器端

        以上就是基于epoll技术实现服务器端的所有知识点说明,接下来给出基于epoll的回声服务器端示例。通过更改之前的博文 “基于select实现的回声服务器端” echo_selectserv.c 程序,可以比较二者之间的实现代码来理解 select 与 epoll 的差异。

        为了验证结果,可以使用 echo_client.c 客户端程序与回声服务器端配合运行。

  • 获取 echo_selectserv.c 程序代码,请参见如下博文链接(第2.6节)

Linux网络编程 - 基于 I/O 复用的服务器端(select 实现)

  • 获取 echo_client.c 客户端程序代码,请参见下面的博文链接(第3.2节内容)

Linux网络编程 - 基于TCP的服务器端/客户端(1)

  • 回声服务器端:echo_epollserv.c
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

#define TRUE       1
#define FALSE      0
#define BUF_SIZE   1024
#define EPOLL_SIZE 50    //设置最大的文件描述符数

typedef struct epoll_event EPOLL_EVENT_T;

void error_handling(char *message);

int main(int argc, char *argv[])
{
    int serv_sock, clnt_sock;
    struct sockaddr_in serv_adr;    //服务器端地址信息变量
    struct sockaddr_in clnt_adr;    //客户端地址信息变量
    struct timeval timeout;
    fd_set reads, cpy_reads;
    
    socklen_t clnt_adr_sz;
    int str_len, i, fd;
    char buf[BUF_SIZE];
    
    //epoll相关变量
    int epfd, event_cnt;
    EPOLL_EVENT_T *ep_events;  //指向保存监测对象集合的内存空间
    EPOLL_EVENT_T event;       //事件
    
    if(argc!=2) {
        printf("Usage: %s \n", argv[0]);
        exit(1);
    }
    
    serv_sock=socket(PF_INET, SOCK_STREAM, 0);
    if(serv_sock==-1)
        error_handling("socket() error");

    //为serv_sock套接字文件描述符设置SO_REUSEADDR可选项
    int option = TRUE;
    int optlen = sizeof(option);
    setsockopt(serv_sock, SOL_SOCKET, SO_REUSEADDR, (void*)&option, optlen);
    
    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);  //创建epoll例程并返回文件描述符
    ep_events = malloc(sizeof(EPOLL_EVENT_T) * EPOLL_SIZE);  //动态分配存储空间
    
    //注册服务器端套接字文件描述符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)
        {
            error_handling("epoll_wait() error!");
            break;
        }
        for(i=0; i
  • 代码说明
  • 第62、63行:第62行调用 epoll_create 函数创建一个epoll例程,并返回该epoll例程的文件描述符。第63行动态分配内存空间,用来存放监视对象的,是用epoll_event 结构体来描述的。
  • 第66~68行:注册服务器端套接字文件描述符serv_sock,并注册该描述符上的读事件。
  • 第72行:调用 epoll_wait 函数,等待被监视对象上的注册事件的发生。
  • 第91~93行:注册与客户端进行数据交互的套接字文件描述符clnt_sock,并注册该描述符上的读事件。
  • 第103行:当服务器端接收到EOF时,从epoll例程中删除 clnt_sock 文件描述符。
  • 运行结果
  • 回声服务器端:echo_epollserv.c

$ gcc echo_epollserv.c -o epollserv
$ ./epollserv 9190
New client connected from address[127.0.0.1:54402], conn_fd=5
New client connected from address[127.0.0.1:54404], conn_fd=6
closed client, conn_fd=5
closed client, conn_fd=6

  • 回声客户端1:echo_client.c

$ ./client 127.0.0.1 9190
Connected...........
Input message(Q to quit): 111111~
Message from server: 111111~
Input message(Q to quit): 222222~
Message from server: 222222~
Input message(Q to quit): Q

  • 回声客户端2:echo_client.c

$ ./client 127.0.0.1 9190
Connected...........
Input message(Q to quit): aaaaaa~
Message from server: aaaaaa~
Input message(Q to quit): bbbbbb~
Message from server: bbbbbb~
Input message(Q to quit): Q

        未完待续。。。我们将在下一篇博文中讲解 epoll 对文件描述符操作的两种模式:水平触发(Level Trigger,LT)模式和边缘触发(Edge Trigger,ET)模式。

参考

《TCP-IP网络编程(尹圣雨)》第17章 - 优于select的epoll

《Linux高性能服务器编程》第9章 - I/O复用:第9.3节 - epoll 系列系统调用

《TCP/IP网络编程》课后练习答案第二部分15~18章 尹圣雨

 I/O多路复用的实现机制 - epoll 用法总结

IO多路复用之epoll

你可能感兴趣的:(#,并发编程,#,网络编程,Linux编程,Linux网络编程,socket编程,TCP/IP网络编程,I/O复用,epoll)