
半桔 :个人主页
🔥 个人专栏 : 《IO多路转接》《手撕面试算法》《C++从入门到入土》
🔖世界上有太多孤独的人,害怕先踏出第一步。《绿皮书》
文章目录
- 前言
- [一. epoll模型的底层原理](#一. epoll模型的底层原理)
-
- [1.1 红黑树](#1.1 红黑树)
- [1.2 就绪队列](#1.2 就绪队列)
- [二. epoll 接口](#二. epoll 接口)
- [三. epoll 服务器实现](#三. epoll 服务器实现)
-
- [3.1 网络套接字接口封装](#3.1 网络套接字接口封装)
- [3.2 对epoll接口进行封装](#3.2 对epoll接口进行封装)
- [3.3 设计epoll服务器的类](#3.3 设计epoll服务器的类)
- [3.4 进行初始化](#3.4 进行初始化)
- [3.5 对任务进行派发](#3.5 对任务进行派发)
- [3.6 服务器主循环函数](#3.6 服务器主循环函数)
- [四. epoll模型的优势](#四. epoll模型的优势)
前言
在 Linux 网络编程领域,IO 多路复用是支撑高并发网络服务的核心技术基石。当服务端需要同时应对成百上千甚至数万级的客户端连接时,如何高效监控、响应这些连接的 IO 事件,直接决定了服务器的性能上限。
传统的select与poll模型,受限于文件描述符管理效率、事件通知机制等缺陷(如select的文件描述符数量上限、每次轮询的线性遍历开销等),在高并发场景下逐渐力不从心。而epoll作为 Linux 专为解决高并发 IO 痛点设计的多路复用模型,凭借更高效的事件通知、更灵活的文件描述符管理,成为了 Nginx、Redis 等高性能中间件背后的 "性能引擎"。
为了帮助开发者系统掌握epoll,本文将从 "底层逻辑""工程实现""技术优势" 三个维度展开解析:
- 先深入
epoll的底层原理,剖析红黑树、就绪队列的设计智慧与epoll接口的工作机制; epoll服务器的落地实践,从网络套接字封装、接口二次封装,到服务器类设计、初始化、任务派发与主循环的构建,逐步讲解高并发服务器的实现路径;- 最后总结
epoll相对于传统模型的核心优势,让你清晰理解它在高并发场景下的不可替代性。
希望通过本文,你能对epoll的 "原理 - 实现 - 价值" 形成完整认知,为高性能网络编程实践筑牢基础。
一. epoll模型的底层原理
epoll是效率最高的多路转接方案。
在了解epoll的接口之前,我们有必要先学习以下epoll的底层原理,其是如何实现最高效率的。
如下图所示就是一个epoll原理示意图:
1.1 红黑树
- 在
epoll模型中,存在一颗红黑树,由操作系统进行管理和维护,内部存储了所有我们要进行等待的文件描述符以及需要进行等待的时间; - 当对应的文件中有数据了,就会向CPU发送中断信号,CPU会执行操作系统中的中断向量表中的方法,将内容进来,同时操作系统快速查找对应文件描述符在红黑树的位置,并将其添加到就绪队列中。
1.2 就绪队列
操作系统会将所有读写时间就绪的文件描述符添加到就绪队列中,当上层调用epoll的时候,可以直接将该就绪队列拷贝出去就行了。
通过上述就绪队列来让上层获取满足条件的文件描述符就可以避免像select,poll一样对整个数组进行访问,来看那些文件就绪。大大提高了效率。
整体步骤就是如下:
- 上层创建一个红黑树;
- 上层将要进行等待的文件描述符添加到红黑树中;
- 操作系统将就绪的文件描述符添加到就绪队列中;
- 上层要进行获取时,操作系统直接将就绪队列拷贝出去即可。
下面进行接口介绍,解释上述步骤在进行实现的时候要调用那些接口。
二. epoll 接口
首先就是创建一颗红黑树:
int epoll_create(int size):
- 参数:已经被弃用了,没有实际意义,传入一个大于0的数即可;
- 返回值:返回一个文件描述符,用来对
epoll模型进行管理。
接下来就是向红黑树中增加文件,删除文件,以及进行修改,这三种使用的都是同一个接口:
int epoll_ctl(int epfd , int op , int fd , struct epoll_event *event):
- 参数一:要进行操作的
epoll模型,就是在创建epoll模型时返回的文件描述符; - 参数二:op表示要进行的操作,其中包含:
EPOLL_CTL_ADD , EPOLL_CTL_MOD , EPOLL_CTL_DEL分别表示对红黑树进行增加节点,修改节点,删除节点; - 参数三fd:表示要进行等待的文件描述符;
struct epoll_event是操作系统内提供的一个结构体:
cpp
typedef union epoll_data {
void *ptr;
int fd;
__uint32_t u32;
__uint64_t u64;
} epoll_data_t;
struct epoll_event {
__uint32_t events; // 要进行等待的事件,读/写/异常
epoll_data_t data; // 用户数据变量,可以存储触发事件的文件描述符相关的数据。
};
- 参数四:存储对应的文件描述符的等待事件,以及用户数据变量,可以存储触发事件的文件描述符相关的数据。
- 返回值:表示有多少个文件描述符资源已经就绪。
最后一个接口就是让epoll模型返回就绪队列:
int epoll_wait(int epfd , struct epoll_event *evemts , int maxevents , int timeout):
- 参数一:文件描述符;
- 参数二:表明将就绪队列放在哪里;
- 参数三:参数二的长度,外界最多可以获取多少就绪的文件描述符;
- 参数四:进行等待的时间,当时间到/有文件就绪就进行返回;
- 返回值:表示有多少文件资源已经就绪。
三. epoll 服务器实现
此处我们只进行一个简单的服务器实现,不进行过多设计,只是简单使用一下对应接口。此处我们在进行设计的时候假设:接收到的TCP报文是完整的。
3.1 网络套接字接口封装
关于网络套接字接口的封装,此处就不再展开说明了,有兴趣的可以看我之前关于TCP的文章;此处直接贴实现:
cpp
const std::string defaultip_ = "0.0.0.0";
enum SockErr
{
SOCKET_Err,
BIND_Err,
};
class Sock
{
public:
Sock(uint16_t port)
: port_(port),
listensockfd_(-1)
{
}
void Socket()
{
listensockfd_ = socket(AF_INET, SOCK_STREAM, 0);
if (listensockfd_ < 0)
{
Log(Fatal) << "socket fail";
exit(SOCKET_Err);
}
Log(Info) << "socket sucess";
}
void Bind()
{
struct sockaddr_in server;
server.sin_family = AF_INET;
server.sin_port = htons(port_);
inet_pton(AF_INET, defaultip_.c_str(), &server.sin_addr);
if (bind(listensockfd_, (struct sockaddr *)&server, sizeof(server)) < 0)
{
Log(Fatal) << "bind fail";
exit(BIND_Err);
}
Log(Info) << "bind sucess";
}
void Listen()
{
if (listen(listensockfd_, 10) < 0)
{
Log(Warning) << "listen fail";
}
Log(Info) << "listen sucess";
}
int Accept()
{
struct sockaddr_in client;
socklen_t len = sizeof(client);
int fd = accept(listensockfd_ , (sockaddr*)&client , &len);
if(fd < 0)
{
Log(Warning) << "accept fail";
}
return fd;
}
int Accept(std::string& ip , uint16_t& port)
{
struct sockaddr_in client;
socklen_t len = sizeof(client);
int fd = accept(listensockfd_ , (sockaddr*)&client , &len);
if(fd < 0)
{
Log(Warning) << "accept fail";
}
port = ntohs(client.sin_port);
char bufferip[64];
inet_ntop(AF_INET , &client.sin_addr , bufferip , sizeof(bufferip) - 1);
ip = bufferip;
return fd;
}
int Get_fd()
{
return listensockfd_;
}
~Sock()
{
close(listensockfd_);
}
private:
uint16_t port_;
int listensockfd_;
};
3.2 对epoll接口进行封装
为了我们后续方便使用,我们此处对epoll的相关接口进行简单封装:
cpp
enum EpollErr
{
CREAR_Err,
};
class Epoll
{
public:
Epoll()
{
// 创建epoll模型
_epfd = epoll_create(1);
if (_epfd < 0)
{
Log(Fatal) << "epoll_create fail";
exit(CREAR_Err);
}
Log(Info) << "epoll create sucess ";
}
void Add_fd(int fd, uint32_t event)
{
// 添加文件描述符到红黑树中
struct epoll_event epevt;
epevt.events = event;
epevt.data.fd = fd;
if (epoll_ctl(_epfd, EPOLL_CTL_ADD, fd, &epevt) < 0)
{
Log(Warning) << "epoll add error : " << strerror(errno);
}
Log(Info) << "epoll add sucess , fd : " << fd ;
}
void Del_fd(int fd)
{
// 删除要进行等待的文件描述符
if (epoll_ctl(_epfd, EPOLL_CTL_DEL, fd, nullptr) < 0)
{
Log(Warning) << "epoll del error : " << strerror(errno);
}
Log(Info) << "epoll del sucess , fd : " << fd;
}
void Mod_fd(int fd, uint32_t event)
{
// 对文件描述符的事件进行修改
struct epoll_event epevt;
epevt.events = event;
epevt.data.fd = fd;
if (epoll_ctl(_epfd, EPOLL_CTL_MOD, fd, &epevt) < 0)
{
Log(Warning) << "epoll mod error : " << strerror(errno);
}
Log(Info) << "epoll mod sucess , fd : " << fd ;
}
int Wait(struct epoll_event *ep_array, int max_size, int timeout)
{
// 进行等待
return epoll_wait(_epfd, ep_array, max_size, timeout);
}
private:
int _epfd;
};
3.3 设计epoll服务器的类
Sock对象,进行网络通信;Epoll对象,来使用epoll的接口;- 一个
struct epoll_event的数组,在调用epoll_wait接口时进行使用。
cpp
class Epollserver
{
static const int default_array_num = 1024;
public:
Epollserver(uint16_t port)
:_epoll_ptr(new Epoll) ,
_sock_ptr(new Sock(port))
{}
private:
std::shared_ptr<Epoll> _epoll_ptr;
std::shared_ptr<Sock> _sock_ptr;
struct epoll_event _ep_array[default_array_num];
};
3.4 进行初始化
- 创建套接字
- 进行绑定
- 设置监听
- 将网络套接字,加入到epol1模型中
cpp
void Init()
{
// 1. 创建套接字
// 2. 进行绑定
// 3. 设置监听
// 4. 将网络套接字 ,加入到epoll模型中
_sock_ptr->Socket();
_sock_ptr->Bind();
_sock_ptr->Listen();
_epoll_ptr->Add_fd(_sock_ptr->Get_fd() , EPOLLIN);
}
3.5 对任务进行派发
因为存在两种文件描述符,因此我们需要对不同文件的处理分开:
- 对于网络套接字,就获取连接;
- 对于普通文件描述符,就将数据读取上来,在进行返回。
cpp
void Sockfd_Ready()
{
// 网络套接字就绪
// 1. 获取建立文件描述符
// 2. 将文件描述符加入到epoll模型中
int newfd = _sock_ptr->Accept();
_epoll_ptr->Add_fd(newfd, EPOLLIN);
}
void Normalfd_Ready(int fd)
{
// 普通文件描述符就绪
// 1. 将数据读取上来
// 2. 向用户端返回信息
char buffer[1024];
int n = read(fd, buffer, sizeof(buffer) - 1);
if (n > 0)
{
buffer[n] = 0;
std::string ret = "server got a message : ";
ret += buffer;
write(fd, ret.c_str(), ret.size());
}
else if (n == 0)
{
// 对方将文件关闭
// 1. 此处我们也就不需要在进行等待了, 从epoll模型中移除
// 2. 关闭文件描述符
_epoll_ptr->Del_fd(fd);
close(fd);
}
else
{
// 出错了
// 1. 打印日志
// 2. 将文件描述符从epoll模型中移除
// 3. 关闭文件描述符
Log(Warning) << "read fail";
_epoll_ptr->Del_fd(fd);
close(fd);
}
}
void Dispatcher(int n)
{
int listensock = _sock_ptr->Get_fd();
for (int i = 0; i < n; i++)
{
int fd = _ep_array[i].data.fd;
if (fd == listensock)
{
Sockfd_Ready();
}
else
{
Normalfd_Ready(fd);
}
}
}
3.6 服务器主循环函数
此时就可以根据上述接口进行编写主循环函数了,主循环函数只需要负责将epoll模型中的就绪队列中的数据拿出来就行了。
cpp
void Run()
{
while (1)
{
int n = _epoll_ptr->Wait(_ep_array, default_array_num, -1);
if (n > 0)
{
Dispatcher(n);
}
else if( n == 0)
{
Log(Info) << "no file is ready";
}
else
{
Log(Warning) << "epoll wait fail";
}
}
}
四. epoll模型的优势
- 检测是否存在就绪的文件时间复杂度为O(1),当然获取的还是要进行拷贝;
epoll模型会帮我们维护要进行检测的文件描述符,不需要我们再设计函数自己维护;epoll模型返回的就是就绪的文件,不需要再进行判断是否满足条件了;- 要进行检测的文件数量没有限制。