目录
一. epoll的实现原理
二. epoll的相关接口
2.1 epoll_create -- 创建epoll模型
2.2 epoll_ctl -- 对epoll模型进行控制
2.3 epoll_wait -- 等待epoll所关注的事件就绪
2.4 epoll相关接口的使用方法
三. Epoll服务器的模拟实现
3.1 EpollServer类的声明
3.2 EpollServer类的实现
四. 总结
一. epoll的实现原理
在通过select和poll实现IO多路转接时,都需要程序员来维护一个数组,用来随时控制要被关心的事件(fd)。同时,使用select和poll实现IO多路转接,都需要对用户维护的数组进行遍历,遍历操作的时间复杂度为O(N),这会很大程度上消耗计算资源,降低效率。
为了解决poll和select的这些缺陷,epoll被提了出来,相比于select和poll,epoll在有事件就绪时,不需要逐个遍历检查每个被关注的文件描述符是否就绪,希望被关注的事件不需要程序员自己维护数组来控制,这提高了效率,降低了程序员的代码编写成本。
在使用epoll实现IO多路转接之前,必须要先在OS内核中建立epoll模型。如图1.1所示,epoll模型主要有两部分组成:
- 一颗红黑树:用来维护用户所关注的事件(fd),一个红黑树节点对应一个被关注的事件,节点中要记录包括文件描述符,所关注事件的类型(读/写/异常)等。
- 就绪队列:当有事件就绪的时候,会将已经就绪的事件添加到就绪队列中去,从队头拿走就绪事件及其相关的属性信息交给用户层,就能对已经就绪的事件进行响应。
相比于直接遍历数组查找某个节点O(N)的时间复杂度,使用红黑树查找的时间复杂度为O(logN),这样就提高了OS内核管理事件的效率。同时,通过就绪队列维护已经就绪的事件,避免了在wait成功之后在遍历数组的过程中确定具体是哪个事件就绪,这进一步降低了资源的消耗,提高效率。
二. epoll的相关接口
2.1 epoll_create -- 创建epoll模型
函数原型:int epoll_create(size_t size)
头文件:#include <sys/epoll.h>
函数参数:在Linux 2.6.8版本之后参数size就已经被弃用,这里是为了向前兼容,在调用接口时只需要传一个大于0的参数即可。
返回值:如果创建成功,返回新创建的epoll模型的文件描述符epfd,如果创建失败返回-1。
epoll_create函数所执行的工作,就是在操作系统内核中,创建一个如图1.1所示的epoll模型,即:一颗红黑树和一个就绪队列。epoll_create接口返回值表示被创建的epoll模型的对应fd值,可见OS是将epoll模型当做文件来处理的,这符合Linux下一切接文件的观点。
2.2 epoll_ctl -- 对epoll模型进行控制
函数原型:int epoll_create(int epfd, int op, int fd, struct epoll_event *event)
头文件:#include <sys/epoll.h>
函数参数:
- epfd -- 所要进行操作的epoll模型对应的文件描述符。
- op -- 用于指定所要进行的操作。
- fd -- 用于对epoll进行操作的文件描述符,如指明要添加关注的事件。
- event -- 输入型参数,告知OS要关注的事件的属性信息。
返回值:如果函数执行成功返回0,失败返回-1。
在该接口函数的参数中,epfd为通过epoll_create创建epoll模型获取的文件描述符,op用于指定所进行的操作的类型,表2.1为op的可选值及其对应的意义。
op | 意义 |
---|---|
EPOLL_CTL_ADD | 将指定事件添加到epoll模型中进行关注 |
EPOLL_CTL_DEL | 删除epoll模型中对某个事件的关注 |
EPOLL_CTL_MOD | 改变epoll模型中某个事件被关注的状态(event) |
fd用于指定对epoll进行操作的文件描述符,可以为listen文件描述符,也可为普通文件描述符,假设op传EPOLL_CTL_ADD,那么所epoll_ctl所进行的操作就是将fd加入到epoll模型中进行关注。
struct epoll_event 类型数据定义如下,该类型包含两个成员,其中一个为uint32_t类型成员events,用于控制该事件的属性是可读、可写还是异常等。events可以为表示4.2中宏的集合,还有一个为联合自定义类型数据,其中该联合类型可以传四种不同的数据表达不同的意义,但一般使用fd来指定文件描述符。
struct epoll_event类型的定义:
typedef union epoll_data
{
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
struct epoll_event
{
uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
events可选宏 | 含义 |
---|---|
EPOLLIN | 对应文件描述符可读 |
EPOLLOUT | 对应文件描述符可写 |
EPOLLPRI | 对应文件描述符带有紧急数据可读(TCP紧急指针置1的报文) |
EPOLLERR | 对应文件描述符异常 |
EPOLLHUP | 对应文件描述符被挂起 |
EPOLLSHOT | 对应文件描述符只被监视一次,监视完一次后会移出epoll模型 |
2.3 epoll_wait -- 等待epoll所关注的事件就绪
函数原型:int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)
头文件:#include <sys/epoll.h>
函数参数:
- epfd -- 进行等待的epoll模型文件描述符。
- events -- 输出型参数,用于存放已经就绪了的事件相关的属性信息,如文件描述符fd以及就绪事件的类型(可读/可写/...)等。
- maxevents -- 一次wait所能获取到的最大的就绪事件数量。
- timeout -- 最长阻塞等待时间,传-1表示一直阻塞等待,传0表示完全非阻塞。
返回值:如果等待成功,返回已经就绪的事件的数量,返回0表示在设定的阻塞时间内没有事件就绪,返回-1表示等待失败。
相对于select和poll在等待成功后需要遍历整个数组来确定具体哪一个文件描述符就绪,epoll_wait等待到的就绪事件相关属性信息会被放置在events所指向的空间的前n个位置,其中n为就绪事件数量,即epoll_wait的返回值。因此,只需要遍历events[0] ~ events[n-1]即可,events[0] ~ events[n-1]中记录的事件一定是已经就绪了的。
如果已经就绪了的事件多于maxevents会发生什么情况呢?这时会先拿取maxevents个已经就绪的事件,剩余的等到下一轮epoll_wait再进行提取处理,并不会造成任何错误。
2.4 epoll相关接口的使用方法
通过epoll实现IO多路转接,需要按照以下三步操作执行:
- 通过epoll_create创建epoll模型。
- 通过epoll_ctl添加对特定事件的关注。
- 通过epoll_wait等待所关注的事件的一个或多个就绪。
为了方便调用epoll相关的接口函数,代码2.1将epoll的3个相关接口函数进行了封装。在代码2.1中包含了log.hpp头文件,里面是日志打印函数的声明和实现,详见代码2.2。
代码2.1:对epoll接口的封装(Epoll.hpp头文件)
#pragma once
#include "log.hpp"
#include <cstring>
#include <cerrno>
#include <sys/epoll.h>
namespace Epoll
{
static const int default_size = 1024;
// 创建Epoll模型函数
static int EpollCreate(int size = default_size)
{
int epfd = epoll_create(size);
if(epfd < 0)
logMessage(FATAL, "Epoll create fail, %d:%s\n", errno, strerror(errno));
else
logMessage(NORMAL, "Epoll create success, epfd:%d\n", epfd);
return epfd;
}
// 进行Epoll控制函数
static int EpollCtl(int epfd, int op, int fd, uint32_t events)
{
struct epoll_event event;
event.events = EPOLLIN;
event.data.fd = fd;
int ret = epoll_ctl(epfd, op, fd, &event);
if(ret < 0) logMessage(ERROR, "Epoll control fail, %d:%s\n", errno, strerror(errno));
else logMessage(NORMAL, "Epoll control success, ret:%d\n", ret);
return ret;
}
// Epoll等待函数
static int EpollWait(int epfd, struct epoll_event* events, int maxevents, int timeout)
{
return epoll_wait(epfd, events, maxevents, timeout);
}
}
代码2.2:日志打印函数的声明和实现(log.hpp头文件)
#pragma once
#include <iostream>
#include <cstdio>
#include <cstdarg>
#include <ctime>
#define DEBUG 0
#define NORMAL 1
#define WARING 2
#define ERROR 3
#define FATAL 4
static const char* g_levelMap[5] =
{
"DEBUG",
"NORMAL",
"WARING",
"ERROR",
"FATAL"
};
static void logMessage(int level, const char *format, ...)
{
// 1. 输出常规部分
time_t timeStamp = time(nullptr);
struct tm *localTime = localtime(&timeStamp);
printf("[%s] %d-%d-%d, %02d:%02d:%02d\n", g_levelMap[level], localTime->tm_year, localTime->tm_mon, \
localTime->tm_mday, localTime->tm_hour, localTime->tm_min, localTime->tm_sec);
// 2. 输出用户自定义部分
va_list args;
va_start(args, format);
vprintf(format, args);
va_end(args);
}
三. Epoll服务器的模拟实现
3.1 EpollServer类的声明
在EpollServer中,要包含以下成员变量:epoll模型文件描述符、listen套接字、端口号、ip地址以及指向存放就绪事件相关属性信息的空间的指针。
要有以下成员函数:构造函数和析构函数、服务器运行函数Start、就绪事件处理函数Handler、接收客户端连接请求函数Accepter、对端数据读取函数Reciever。
#pragma once
#include "Sock.hpp"
#include "Epoll.hpp"
#include <unistd.h>
static const int maxevents = 64;
class EpollServer
{
public:
EpollServer(uint16_t port = 8080, const std::string& ip = ""); // 构造函数
void Start(); // 启动运行函数
~EpollServer(); // 析构函数
private:
void Handler(int n); // 就绪事件处理函数
void Accepter(int listenSock); // 连接接收函数
void Reciever(int fd); // 信息读取函数
int _listenSock; // 监听套接字
int _epfd; // epoll套接字
uint16_t _port; // 服务进程端口号
std::string _ip; // 服务器ip
struct epoll_event* _ptr_events; // 指向就绪队列的指针
};
3.2 EpollServer类的实现
- 构造函数:首先要进行关于tcp通信的常规准备工作,获取listen套接字、绑定端口号、设置监听状态,之后要创建epoll模型、开辟一块内存空间用于存放wait到的就绪事件并将listen套接字添加到epoll模型中去。
- 析构函数:判断listen套接字和epoll模型文件描述符是否>=0,如果是,就close掉它们。再判断_ptr_events是否为nullptr,如果否,要delete[]释放动态申请的内存空间。
- 服务器运行函数Start:常驻进程,执行while死循环,每层while循环都调用epoll_wait检测事件就绪的情况,如果epoll_wait返回值大于0,那么调用Handler函数处理就绪事件。
- 就绪事件处理函数Handler:接收一个参数n表示已就绪事件的数量,遍历_ptr_events[0] ~ ptr_events[n-1],根据就绪的是listen文件描述符还是普通文件描述符,分类进行后续处理,listen文件描述符调用Accepter函数接收对端连接,普通文件描述符调用Reciever函数接收数据。
- 接收对端连接函数Accepter:获取对端连接,分配文件描述符fd,并将fd添加到epoll模型中去。
- 数据读取函数Reciever:调用read函数读取客户端发送的数据,如果read返回值>0,那么就执行对应操作处理读取到的数据,如果read返回值为0,那么表示客户端关闭,要将对应的文件描述符fd从epoll模型中删除。
代码3.2:EpollServer的实现(EpollServer.cc源文件)
#include "EpollServer.hpp"
EpollServer::EpollServer(uint16_t port, const std::string& ip)
: _listenSock(-1), _epfd(-1), _port(port)
, _ip(ip), _ptr_events(nullptr)
{
// 1.获取监听套接字
_listenSock = Sock::Socket();
if(_listenSock < 0) exit(1);
// 2.绑定端口号
if(Sock::Bind(_listenSock, _ip, _port) < 0) exit(2);
// 3.设置监听状态
if(Sock::Listen(_listenSock) < 0) exit(3);
// 4.创建epoll模型
_epfd = Epoll::EpollCreate();
if(_epfd < 0) exit(4);
// 5.为_events开辟内存空间并进行初始化
_ptr_events = new epoll_event[maxevents];
// 6.将listenSock添加到epoll模型中
if(Epoll::EpollCtl(_epfd, EPOLL_CTL_ADD, _listenSock, EPOLLIN) < 0) exit(5);
logMessage(NORMAL, "EpollServer init success!\n");
}
// Epoll服务器启动运行函数
void EpollServer::Start()
{
while(1)
{
// 对epoll进行等待
int n = Epoll::EpollWait(_epfd, _ptr_events, maxevents, -1);
switch(n)
{
case 0:
logMessage(DEBUG, "epoll wait time out!\n");
break;
case -1:
logMessage(ERROR, "epoll wait error, %d:%s\n", errno, strerror(errno));
break;
default:
Handler(n);
break;
}
}
}
// 析构函数
EpollServer::~EpollServer()
{
if(_listenSock >= 0) close(_listenSock);
if(_epfd >= 0) close(_epfd);
if(_ptr_events) delete[] _ptr_events;
}
// 就绪事件处理函数
void EpollServer::Handler(int n)
{
// 遍历_events,查找已经就绪的事件
for(int i = 0; i < n; ++i)
{
// 分listen套接字和普通套接字两种情况讨论
if(_ptr_events[i].data.fd == _listenSock) Accepter(_listenSock);
else Reciever(_ptr_events[i].data.fd);
}
}
// 连接接收函数
void EpollServer::Accepter(int listenSock)
{
std::string cli_ip; // 发起连接的客户端ip
uint16_t cli_port; // 客户端端口号
int fd = Sock::Accept(listenSock, cli_ip, cli_port);
if(fd < 0) return;
// 将新增的fd添加到epoll模型中去
Epoll::EpollCtl(_epfd, EPOLL_CTL_ADD, fd, EPOLLIN);
logMessage(NORMAL, "Add a new fd to epoll success, fd:%d\n", fd);
}
// 信息读取函数
void EpollServer::Reciever(int fd)
{
char buffer[1024];
ssize_t n = read(fd, buffer, 1023);
// 如果读取成功
if(n > 0)
{
buffer[n - 1] = '\0';
printf("Client# %s\n", buffer);
}
else if(n == 0)
{
// 对端关闭,将fd从epoll模型中拿走
Epoll::EpollCtl(_epfd, EPOLL_CTL_DEL, fd, EPOLLIN);
if(n == 0)
{
// logMessage(NORMAL, "Remove fd from epoll success, fd:%d\n", fd);
printf("Remove fd from epoll success, fd:%d\n", fd);
close(fd);
}
}
else // 读取数据失败
{
logMessage(ERROR, "Read message fail, %d:%s\n", errno, strerror(errno));
}
}
四. 总结
- 相比于通过select和poll实现IO多路转接,epoll不需要程序员维护数组来控制关注的事件,在有事件就绪后也不需要遍历数组查找具体哪个事件就绪,效率较高。
- epoll底层维护一颗红黑树存储要关心的事件,维护一个就绪队列表示已经就绪的事件。
- epoll实现IO多路转接,要进行的操作为:epoll_create创建epoll模型 -> epoll_ctl向epoll模型中添加受到关注的文件描述符 -> epoll_wait等待事件就绪。