IO复用之epoll深度分析

zhyjc6于2020-03-27发布 约5604字·约12分钟 本文总阅读量

前言

前面一篇文章研究了 select() 函数的 io 复用原理,我们发现 select 函数在性能上也存在几个缺点:调用 select 函数后如果集合中有文件描述符发生变化,我们还需要构造循环语句去找出发生变化的具体文件描述符;还有 select 函数调用会更改其传入的监视对象,即 fd_set 集合,所以调用 select 函数之前需要复制一份拷贝,并在每次调用 select 函数时传入该拷贝。

但其实提高 select 函数性能的障碍并不是表面上的循环语句,而是每次调用 select 函数时都需要重新传递监视对象信息。因为传递监视对象信息具有以下含义:

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

应用程序对操作系统传递数据将对程序造成很大负担,而且无法通过优化代码来解决,因此将成为性能上的致命弱点。那为何需要将监视对象信息传递给操作系统呢?select 函数是监视套接字变化的函数,而套接字是由操作系统管理的,所以 select 函数需要借助于操作系统才能完成功能。

而使用 epoll 则具有以下优点(似乎恰好对应于 select 的缺点):

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

epoll函数

综述

使用 epoll 需要知道 3 个函数:

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 将发生变化的文件描述符单独集中到一起。

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

epoll_create函数

#include <sys/epoll.h>

int epoll_create(int size);
/*
返回值:
	成功时返回非负整数的epoll文件描述符,失败时返回-1
参数:
	size:epoll实例的大小
*/

调用epoll_create函数时创建的文件描述符保存空间称为“epoll例程”,通过参数size传递的值决定epoll例程的大小,但该值只是向操作系统提供建议,仅供操作系统参考。

epoll是从Linux的2.5.44版内核开始引入的,而2.6.8之后的Linux内核将完全忽略传入的size参数,因为内核会根据具体情况调整epoll例程的大小。但是size的值还是要填,而且不能为负数。

epoll 把用户关心的文件描述符上的事件放在内核里的一个事件表中,这样就无须像 select 和 poll 那样每次调用都要重复传入文件描述符集或事件集。但 epoll 需要一个额外的文件描述符,用来唯一标识内核中的这个事件表。而这个额外的文件描述符就是由 epoll_create 函数创建。

epoll_create 函数创建的资源与套接字相同,也有操作系统直接管理。因此,该函数和创建套接字的情况相同,也会返回文件描述符。也就是说,该函数返回的文件描述符主要用于区分 epoll 例程。需要终止时,与其它文件描述符相同,也要调用 close 函数。当指向一个实例的所有文件描述符都被 close() 后内核将销毁该 epoll 实例,并释放相关资源,以便再次利用。

epoll_ctl函数

#include <sys/epoll.h>

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

函数的意思是向 epoll 例程epfdop文件描述符fd,主要目的是监视event中的事件。epoll_event 结构体上面已经介绍了,这里只需要再说明一下op参数,该参数可以有以下取值:

例如:

epoll_ctl(A,EPOLL_CTL_ADD,B,C);//向例程A中注册文件描述符B,主要目的是监视参数C中的事件
epoll_ctl(A,EPOLL_CTL_DEL,B,NULL);//从epoll例程中删除文件描述符B

最后我们再详细看一下第四个参数event,我们知道这是一个结构体,其中有两个成员,一个是事件events,一个是 union 联合体,这里取联合体中的int fd,也就是文件描述符。这里在带入epoll_ctl函数之前,我们需要先填充该结构体的两个成员变量,其中的文件描述符是哪个文件呢?其实就是epoll_ctl函数的第3个参数,也就是我们要注册的文件描述符(也不知道结构体中已经带有了为什么还要多传入一个参数)。例如:

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 中可以保存的常量及所指的事件类型:

可以通过位或运算同时传递多个上述参数。

epoll_wait函数

#include <sys/epoll.h>

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
/*
返回值:成功时返回发生事件的文件描述符数(大于等于0),失败时返回-1
参数:
	epfd:表示事件发生监视范围的epoll例程的文件描述符
	events:保存发生事件的文件描述符集合的 结构体数组 地址
	maxevents:第二个参数中可以保存的最大事件数
	timeout:以微妙为单位的等待时间,传递-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那样插入针对所有文件描述符的循环。

具体来说,就是如果检测到事件,就将所有就绪的事件从内核事件表(由 epfd 参数指定)中复制到它的第二个参数 ep_events 指向的数组中。这个数组只用于输出 epoll_wait 检测到的就绪事件,而不像 select、poll 的数组参数那样既用于传入用户注册的事件,又用于输出内核检测到的就绪事件。因此极大地提高了应用程序索引就绪文件描述符的效率。

使用epoll实现简单echo服务器

如果理解了上一篇select的文章,那么看懂上面的服务器代码就很容易了。

水平触发和边缘触发

epoll分为两种工作方式LT和ET。

LT(level triggered) 是默认/缺省的工作方式,同时支持 block和no_block socket。这种工作方式下,内核会通知你一个 fd 是否就绪,然后才可以对这个就绪的 fd 进行 I/O 操作。就算你没有任何操作,系统还是会继续提示 fd 已经就绪,不过这种工作方式出错会比较小,传统的 select/poll 就是这种工作方式的代表。

ET(edge-triggered) 是高速工作方式,仅支持 no_block socket,这种工作方式下,当 fd 从未就绪变为就绪时,内核会通知 fd 已经就绪,并且内核认为你知道该 fd 已经就绪,不会再次通知了,除非因为某些操作导致 fd 就绪状态发生变化。如果一直不对这个 fd 进行I/O操作,导致fd变为未就绪时,内核同样不会发送更多的通知,因为only once。所以这种方式下,出错率比较高,需要增加一些检测程序。

例如服务器输入缓冲区收到 50 字节的数据时,水平触发和边缘触发都会通知该事件。但服务器读取 20 字节还剩下 30 字节时,水平触发仍会通知该事件。也就是说,水平触发是只要缓冲区中有数据,就会再次注册该事件。而边缘触发方式中输入缓冲区收到数据时仅注册一次该事件,之后即使该缓冲区中仍有数据,也不会再次注册。

个人理解

我们可以利用脉冲信号来辅助理解,水平触发就是指信号处于高位的水平时,就会一直触发;而边缘触发只有在上升沿时才会触发。

使用边缘触发

......
int flag = fcntl(fd, F_GEFL, 0);
fcntl(fd, F_SETFL, flag|O_NONBLOCK);//设置socket为非阻塞模式
event.events = EPOLLIN|EPOLLET;  //设置事件为边缘触发
event.data.fd = fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event); //注册事件
......
    
str_len = read(ep_events[i].data.fd, buf, BUF_SIZE);
......
if (str_len < 0) {
	if (errno == EAGAIN) {
    	//缓冲区为空
    }
}
......

select、poll和epoll的区别

系统调用 select poll epoll
事件集合 用户通过3个参数分别传入感兴趣的可读、可写以及异常等事件,内核通过对这些参数的在线修改来反馈其中的就绪事件。这使得用户每次调用select都要重置这3个参数 统一处理所有事件类型,因此只需一个事件集参数。用户通过pollfd.events传入感兴趣的事件,内核通过修改pollfd.events反馈其中就绪的事件 内核通过一个事件表直接管理用户感兴趣的所有事件。因此每次调用epoll_wait时,无须反复传入用户感兴趣的事件。epoll_wait系统调用的参数events仅用来反馈就绪的事件
应用程序索引就绪文件描述符的时间复杂度 O(n) O(n) O(1)
最大支持文件描述符数 一般有最大限制 65535 65535
工作模式 LT LT 支持ET高效模式
内核实现和工作效率 采用轮询方式来检测就绪事件。算法时间复杂度为O(n) 采用轮询方式来检测就绪事件。算法时间复杂度为O(n) 采用回调方式来检测就绪事件。算法时间复杂度为O(1)

关于 epoll 的学习就先到这里吧,虽然学习得不够彻底,但是也还是从完全的不会到学会并理解了。以后有时间再看看 epoll 的源码实现吧!

参考资料