
🔥小叶-duck:个人主页
❄️个人专栏:《Data-Structure-Learning》《C++入门到进阶&自我学习过程记录》
《Linux系统从入门到实践》《Linux网络从入门到实践》
✨未择之路,不须回头
已择之路,纵是荆棘遍野,亦作花海遨游
目录
[一、实战:基于 epoll 的回显服务器实现](#一、实战:基于 epoll 的回显服务器实现)
[1.1 项目整体架构](#1.1 项目整体架构)
[1.2 服务器核心类定义](#1.2 服务器核心类定义)
[1.3 构造函数 EpollServer:初始化监听套接字与 epoll 模型](#1.3 构造函数 EpollServer:初始化监听套接字与 epoll 模型)
[1.4 Start:服务运行主函数](#1.4 Start:服务运行主函数)
[1.5 Dispatcher:事件派发器](#1.5 Dispatcher:事件派发器)
[1.6 HandlerAccept:连接管理器](#1.6 HandlerAccept:连接管理器)
[1.7 HandlerRead:IO 处理器](#1.7 HandlerRead:IO 处理器)
[1.8 Main.cc:主函数入口](#1.8 Main.cc:主函数入口)
[1.9 完整代码和运行测试(EpollServer.hpp)](#1.9 完整代码和运行测试(EpollServer.hpp))
[二、epoll 的两种工作模式 LT vs ET](#二、epoll 的两种工作模式 LT vs ET)
[2.1 LT 水平触发(Level Triggered)](#2.1 LT 水平触发(Level Triggered))
[2.1.1 工作规则](#2.1.1 工作规则)
[2.1.2 特性与场景](#2.1.2 特性与场景)
[2.2 ET 边缘触发(Edge Triggered)](#2.2 ET 边缘触发(Edge Triggered))
[2.2.1 开启方式](#2.2.1 开启方式)
[2.2.2 工作规则](#2.2.2 工作规则)
[2.2.3 特性与场景](#2.2.3 特性与场景)
[2.3 两种模式的定义与核心差异](#2.3 两种模式的定义与核心差异)
[2.4 具象化类比:快递派发模型](#2.4 具象化类比:快递派发模型)
[2.4.1 LT 模式:持续通知的快递员](#2.4.1 LT 模式:持续通知的快递员)
[2.4.2 ET 模式:仅通知一次的快递员](#2.4.2 ET 模式:仅通知一次的快递员)
[2.5 ET 模式与非阻塞文件描述符的绑定关系](#2.5 ET 模式与非阻塞文件描述符的绑定关系)
[2.5.1 核心结论](#2.5.1 核心结论)
[2.5.2 ET 模式为什么要循环读取数据?](#2.5.2 ET 模式为什么要循环读取数据?)
[2.5.3 为什么不能搭配阻塞 IO?](#2.5.3 为什么不能搭配阻塞 IO?)
[2.6 ET 模式高性能背后的原理](#2.6 ET 模式高性能背后的原理)
[2.6.1 减少重复事件通知,提升通知效率](#2.6.1 减少重复事件通知,提升通知效率)
[2.6.2 带动 TCP 滑动窗口发挥更好的传输能力](#2.6.2 带动 TCP 滑动窗口发挥更好的传输能力)
[2.6.3 补充:LT 与 ET 在工程开发上的约束区别](#2.6.3 补充:LT 与 ET 在工程开发上的约束区别)
[2.7 LT 与 ET 触发模式多维度对比](#2.7 LT 与 ET 触发模式多维度对比)
[三、LT 与 ET 总结与核心考点复盘](#三、LT 与 ET 总结与核心考点复盘)
前言
在上一篇文章中,我们已经完成 epoll 内核底层原理的学习,了解了 epoll 三个核心系统调用,以及内核中红黑树、就绪队列等关键数据结构,搞懂了 epoll 相比 select、poll 在 IO 多路复用上的底层优势。
理论最终要落地到代码工程。本篇我们将从实战出发,从零基于 epoll 实现一套完整的回显服务器,拆解项目整体架构,逐模块分析服务类的各个核心函数,把 epoll 的 API 融入真实的服务端代码。
在完成服务开发之后,我们会深入讲解 epoll 的两种事件触发模式:LT 水平触发与 ET 边缘触发。我们会对比二者底层逻辑、适用场景,解释 ET 模式必须搭配非阻塞 IO 的完整逻辑,剖析 ET 高性能背后的两层原理,同时梳理工程选型的判断依据。最后复盘本章核心考点,把原理和工程实践结合,打通 epoll 从内核原理到业务落地的完整链路。
一、实战:基于 epoll 的回显服务器实现
在上篇文章我们已经将 epoll 的接口使用、底层原理结合源代码讲解完了。原理部分讲解完毕,所以本篇文章结合完整 C++ 代码,动手实现一套基于 epoll 的 Echo 回显服务器,直观理解 epoll 在工程中的封装思路与使用流程。
1.1 项目整体架构
代码拆分为多个模块,都是 Linux 网络编程常用基础组件:
- **Mutex.hpp:**封装 POSIX 互斥锁与 RAII 风格 LockGuard,保障临界资源操作原子性。
- **Logger.hpp:**日志模块,支持控制台 / 文件输出,依靠 RAII 临时对象自动刷新日志。
- InetAddr.hpp: 封装
sockaddr_in地址结构,完成字节序、IP 字符串与整数相互转换。 - **Socket.hpp:**利用模板方法封装 TCP 套接字,统一服务端、客户端启动逻辑。
- **EpollServer.hpp:**核心 epoll 服务类,实现事件循环、事件分发逻辑(本章重点)。
- **Main.cpp:**程序入口。
前面几个属于通用工具组件,在前面我们实现的很多代码中已经重复使用了多次,这里就不再展开分析了,重点解析核心EpollServer类的实现。
1.2 服务器核心类定义
cpp
#pragma once
#include <iostream>
#include <memory>
#include <sys/epoll.h>
#include "Socket.hpp"
using namespace SocketModule;
class EpollServer
{
const static int nums = 64;
const static int defaultfd = -1; // 标记当前位置无连接fd
public:
EpollServer(int port);
void Start();
void Dispatcher(int nums);
~EpollServer();
private:
void HandlerAccept(); // 处理新连接接入
void HandlerRead(int sockfd); // 处理普通套接字读写事件
private:
std::unique_ptr<Socket> _listensock; // 监听套接字
bool _isrunning; // 服务器状态
// // int fd_array[nums];
// struct pollfd _fds[nums]; //epoll不再需要用户使用任何辅助数组
int _epfd; // epoll模型对应的文件描述符
struct epoll_event _events[nums];
// 因为我们不需要向这个数组输入什么,所以并非是辅助数组,而是一个缓冲区专门用于接收epoll_wait接口返回的就绪事件
};
类中包含核心成员:监听套接字智能指针、epoll 实例句柄 _epfd 、事件缓冲区数组 _events ,_isrunning用于控制服务主循环启停。
1.3 构造函数 EpollServer:初始化监听套接字与 epoll 模型
功能:创建监听套接字、构建 epoll 实例,并且将监听 fd 注册进 epoll 模型。
cpp
EpollServer(int port) : _listensock(std::make_unique<TcpSocket>()), _isrunning(false)
{
// 1. 创建监听套接字,完成socket、bind、listen
_listensock->BuildTcpSocketMethod(port);
// 2. 创建epoll实例
_epfd = epoll_create(256);
if (_epfd < 0)
{
LOG(LogLevel::FATAL) << "epoll_create error";
exit(EPOLL_CREATE_ERR);
}
LOG(LogLevel::FATAL) << "epoll_create success, _epfd: " << _epfd;
//3. 将listen套接字注册到epoll模型,向红黑树新增监听套接字节点
struct epoll_event ev;
ev.data.fd = _listensock->Fd();
ev.events = EPOLLIN; // 设置为读事件
int res = epoll_ctl(_epfd, EPOLL_CTL_ADD, _listensock->Fd(), &ev);
if (res < 0)
{
LOG(LogLevel::FATAL) << "add listensock error";
exit(EPOLL_CTL_ERR);
}
}
代码分析:
- 调用
BuildTcpSocketMethod,一次性完成 TCP 套接字创建、端口绑定、监听。 epoll_create创建 epoll 实例,返回_epfd,也就是 epoll 对应的文件描述符。- 构造
epoll_event结构体,设置监听 fd 与关注事件EPOLLIN,调用epoll_ctl执行EPOLL_CTL_ADD,把监听套接字加入 epoll 红黑树。
epoll_ctl 第三个参数已经传入文件描述符,但我们依然在 epoll_event 的 data 字段保存 fd。为什么?
内核不会修改 epoll_event 里 data 成员,这块属于用户自行维护的数据 ;后续事件就绪时,依靠 data.fd 判断事件来源,区分是新连接还是普通客户端 IO 事件。
这里要注意:我们在用户态局部创建的 struct epoll_event ev ,并不会直接存入内核红黑树。 epoll_ctl 不需要直接使用 fd 去查找,内核会通过 task_struct 找到文件描述符表,根据 fd 找到对应的struct file ;struct file 的 private_data 指针指向 epoll 对应的内核 eventpoll 结构体,从而操作 epoll 内部的红黑树。
学习了前面 epoll 的底层原理,这块就非常容易理解了。
执行 EPOLL_CTL_ADD 时,内核会动态创建 epitem 内核对象 ,挂载到红黑树,同时注册回调函数。当数据就绪时,底层驱动触发回调,将红黑树上对应的节点移动到就绪队列;回调过程不需要遍历整棵红黑树,依靠 wait_queue_t 配合container_of 宏 反向取出 epoll_entry,直接找到红黑树节点放入就绪链表。
一句话总结:红黑树用来管理所有被监听 fd ,就绪队列用来存放已经发生事件的 fd。
1.4 Start:服务运行主函数
功能:开启服务循环,持续调用
epoll_wait等待事件就绪,事件就绪后交给事件派发器处理。
cpp
void Start()
{
_isrunning = true;
while (_isrunning)
{
int timeout = -1;
int n = epoll_wait(_epfd, _events, nums, timeout);
if (n < 0)
{
LOG(LogLevel::ERROR) << "epoll_wait error";
}
else if (n == 0)
{
LOG(LogLevel::DEBUG) << "timeout...";
}
else
{
Dispatcher(n);
}
}
_isrunning = false;
}
代码分析:
_isrunning作为循环标记,控制服务启停。epoll_wait阻塞等待事件,timeout 设置为 - 1 代表永久阻塞。- 返回值 n>0:代表有 n 个 fd 事件就绪,调用
Dispatcher分发事件; n<0:系统调用出错; n=0:超时(timeout=-1 时一般不会触发)。
重点:epoll_wait 只会返回已经就绪的 fd 事件,不需要像 select/poll 那样遍历全部 fd 做无效扫描。
1.5 Dispatcher:事件派发器
功能:遍历 epoll_wait 返回的就绪事件,区分监听套接字事件与普通客户端套接字事件,分发到对应的处理函数。
cpp
void Dispatcher(int nums)
{
for (int i = 0; i < nums; i++)
{
if (_events[i].events & EPOLLIN)
{
if (_events[i].data.fd == _listensock->Fd())
{
// 监听套接字可读,代表新连接到来
HandlerAccept();
}
else
{
// 普通客户端套接字可读,处理IO读写
HandlerRead(_events[i].data.fd);
}
}
}
}
代码分析:
- 遍历本次就绪事件数组,只处理读事件
EPOLLIN。 - 判断 fd 是否等于监听套接字 fd:
- 等于监听 fd: 触发
HandlerAccept,接收新连接; - 其他 fd: 属于客户端连接,交给
HandlerRead处理数据读写。
- 等于监听 fd: 触发
和 select/poll 巨大差异:epoll 返回的数组 里全部都是就绪 fd ,不需要额外遍历所有 fd 做无效判断。
当有新连接到来时,epoll_wait 返回值大于 0,程序进入 Dispatcher。这里存在一个重要细节:如果没有及时调用accept取走连接,会出现死循环触发可读事件,原理和 select、poll 类似。
客户端完成三次握手后,连接会存入内核的全连接队列。只要队列不为空,监听套接字的读事件就会持续处于就绪状态。内核检测到读就绪,通过回调机制把红黑树对应的节点放入 epoll 就绪队列,epoll_wait就会捕获到就绪节点并返回。 若不调用accept将连接从全连接队列取出,队列一直非空,监听 fd 的就绪状态不会清除,下一轮epoll_wait会再次返回就绪事件,反复进入事件派发逻辑。
**注意:**虽然 epoll 返回的数组全部是就绪事件,免去遍历全部 fd 的开销,但代码依旧需要循环遍历就绪数组,同一时刻可能存在多个事件同时就绪,需要逐个处理。
1.6 HandlerAccept:连接管理器
功能:监听套接字就绪,调用 accept 获取新客户端连接 fd,把新 fd 注册进 epoll,交由 epoll 托管监听。
cpp
void HandlerAccept()
{
InetAddr client;
int sockfd = _listensock->Accept(&client);
if (sockfd >= 0)
{
LOG(LogLevel::INFO) << "get a new link, sockfd: " << sockfd << ", client: " << client.StringAddress();
// 将新的sockfd添加到epoll模型的红黑树
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = sockfd;
int n = epoll_ctl(_epfd, EPOLL_CTL_ADD, sockfd, &ev);
if (n < 0)
{
LOG(LogLevel::WARNING) << "epoll_ctl add sockfd error";
}
else
{
LOG(LogLevel::INFO) << "epoll_ctl add sockfd success, sockfd: " << sockfd;
}
}
else
{
LOG(LogLevel::WARNING) << "accept error...";
}
}
代码分析:
Accept获取新连接 fd 与客户端地址。此时不会阻塞,因为监听套接字 EPOLLIN 事件已经就绪,内核全连接队列存在待取出连接。- 构造 epoll_event,关注新 fd 的读事件,调用
epoll_ctl ADD,将客户端 fd 加入 epoll 红黑树,后续由 epoll 监控该 fd 的可读事件。
注意: 拿到新连接 fd不能直接 recv 读取数据 ,连接建立不等于有数据,直接读会阻塞事件循环;交给 epoll 托管,等待数据就绪后 epoll 通知。
1.7 HandlerRead:IO 处理器
功能:客户端套接字可读,读取客户端数据,实现回显;检测连接关闭 / 异常,从 epoll 移除 fd 并关闭资源。
cpp
void HandlerRead(int sockfd)
{
char buffer[1024];
int n = read(sockfd, buffer, sizeof(buffer));
if (n > 0)
{
buffer[n] = 0;
std::cout << "client say# " << buffer << std::endl;
}
else if (n == 0)
{
LOG(LogLevel::INFO) << "client quit...";
// 客户端关闭连接,先从epoll移除fd,再close
int res = epoll_ctl(_epfd, EPOLL_CTL_DEL, sockfd, nullptr);
if (res > 0)
{
LOG(LogLevel::INFO) << "epoll_ctl remove sockfd success, sockfd: " << sockfd;
}
close(sockfd);
}
else
{
LOG(LogLevel::ERROR) << "read error";
int res = epoll_ctl(_epfd, EPOLL_CTL_DEL, sockfd, nullptr);
if (res > 0)
{
LOG(LogLevel::INFO) << "epoll_ctl remove sockfd success, sockfd: " << sockfd;
}
close(sockfd);
}
}
代码分析:
read读取客户端数据,事件就绪时缓冲区有数据,不会阻塞。- **n > 0:**读取成功,打印客户端消息,完成回显逻辑(可自行补充 send 回发数据)。
- n == 0: 对端正常关闭连接。必须先调用 epoll_ctl 删除 fd,再 close;如果先 close,fd 直接失效,epoll 删除操作会失败。
- **n < 0:**读取出错,同样执行 epoll 删除、关闭 fd,释放资源。
1.8 Main.cc:主函数入口
cpp
#include "EpollServer.hpp"
int main(int argc, char *argv[])
{
if(argc != 2)
{
std::cout << "Usage: " << argv[0] << " port" << std::endl;
exit(USAGE_ERR);
}
ENABLE_FILE_LOG_STRATEGY();
uint16_t port = std::stoi(argv[1]);
std::unique_ptr<EpollServer> svr = std::make_unique<EpollServer>(port);
svr->Start();
return 0;
}
程序入口创建 EpollServer 实例,调用 Start 启动事件循环,服务监听 8080 端口,支持多客户端同时接入、消息回显。
1.9 完整代码和运行测试(EpollServer.hpp)
cpp
#pragma once
#include <iostream>
#include <memory>
// #include <sys/poll.h>
#include <sys/epoll.h>
#include "Socket.hpp"
using namespace SocketModule;
class EpollServer
{
const static int nums = 64;
const static int defaultfd = -1; // 表示当前位置没有连接fd占用
public:
EpollServer(int port) : _listensock(std::make_unique<TcpSocket>()), _isrunning(false) // 派生类对象传给基类指针
{
// 1.创建listen套接字
_listensock->BuildTcpSocketMethod(port);
// 2.创建epoll模型
_epfd = epoll_create(256);
if (_epfd < 0)
{
// epoll模型创建失败
LOG(LogLevel::FATAL) << "epoll_create error";
exit(EPOLL_CREATE_ERR);
}
LOG(LogLevel::FATAL) << "epoll_create success, _epfd: " << _epfd;
// 3.将listen套接字设置到内核的epoll模型中(也就是向红黑树新增listen套接字节点)
// 对节点的event_poll进行初始化
struct epoll_event ev;
ev.data.fd = _listensock->Fd();
// 问题:epoll_ctl第三个参数已经传了文件描述符,为什么我们还要在struct epoll_event中data字段设置fd?
// 前面我们说了内核不会修改struct epoll_event中的data字段,这是用户自己去维护的,你当然可以不用设置
// 但后续判断读事件就绪是新连接到来还是普通套接字可读我们需要借助data字段的fd进行判断
ev.events = EPOLLIN; // 事件设置为读事件
// 还是同select和poll的问题:到这里我们就把节点设置到红黑树当中了吗??没有
// 红黑树是epoll模型中的一部分,而epoll模型是内核管理的,struct event_poll ev只是在局部函数(也就是用户栈)中创建的,并没有设置到内核中
int res = epoll_ctl(_epfd, EPOLL_CTL_ADD, _listensock->Fd(), &ev);
// 讲解了epoll的原理这里我们也就清楚了为什么第一个参数要传epoll_create的返回值了
// epoll_ctl并不需要_epfd这个文件描述符,而是通过task_struct获取文件描述符表,通过文件描述符索引找到对应的struct file
// struct file中的private_data指针指向的就是_epfd所对应的epoll模型,也就是struct pollevent结构体的起始地址
// 正是通过文件描述符就能获取对应的epoll模型,进而对模型中的红黑树进行节点操作
// 然后通过EPOLL_CTL_ADD宏判断是新增节点,动态开辟创建struct epitem结构体对象,并且同时设置对应的回调函数机制
// 用于后续事件就绪,通过底层驱动唤醒等待队列中的节点,调用对应的回调函数将红黑树的节点放入就绪队列中
// (调用回调函数不需要再遍历红黑树查找节点,等待队列中挂的是wait_queue_t,通过container_of宏反向获取整个epoll_entry结构体,
// 再通过epoll_entry结构体就能获取到base指针,该指针就直接指向的是红黑树中对应的节点,直接放入就绪队列即可)
if (res < 0)
{
// 新增节点失败
LOG(LogLevel::FATAL) << "add listensock error";
exit(EPOLL_CTL_ERR);
}
}
~EpollServer()
{
}
void Start()
{
_isrunning = true;
while (_isrunning)
{
// 能不能直接accpet获取新连接??不能,依然会阻塞等待
// 那怎么做?让epoll模型专门进行等操作
int timeout = -1;
int n = epoll_wait(_epfd, _events, nums, timeout);
if (n < 0)
{
// 等待失败
LOG(LogLevel::ERROR) << "epoll_wait error";
}
else if (n == 0)
{
// wait超时
LOG(LogLevel::DEBUG) << "timeout...";
}
else
{
Dispatcher(n);
}
}
_isrunning = false;
}
// 事件派发器:将就绪的事件派发到指定的模块进行处理
void Dispatcher(int nums)
{
// 当有新连接到来的时候,epoll_wait返回值大于0进入Dispatcher
// 但是如果我们没有及时accept就会死循环打印,为什么?原因和前面的select和poll类似
// 客户端完成三次握手后,连接进入内核的全连接队列。只要队列不为空(不调用accept),监听套接字的读事件就一直处于"就绪"状态。
// 内核发现读事件就绪,通过回调机制将红黑树中对应的节点放入 epoll 就绪队列。
// epoll_wait 发现就绪队列中有就绪节点,将其取出并返回,
// 因为没有调用 accept() 把连接从全连接队列中取走,队列始终非空。只要状态依旧就绪,下一次 epoll_wait 会再次激活节点并返回。
// std::cout << "event ready..." << std::endl;
for (int i = 0; i < nums; i++)
{
// 问题:我们还需要再像之前的select、poll那样去判断数组中是否存在不合法的fd?是否存在没有就绪的fd?都不需要!
// 从epoll_wait获取上来的数据存放在我们的_events数组中,其中一定全是就绪的事件!!
// 因为epoll的优势就在于:epoll模型天然的将没有就绪的事件和就绪的事件通过不同的数据结构解耦了
// 但我们依然还是需要循环处理就绪事件,因为本来就会同时存在多个事件就绪,只是不再需要进行无效的判断了
if (_events[i].events & EPOLLIN)
{
// 读事件就绪
if (_events[i].data.fd == _listensock->Fd())
{
// 读事件就绪 && 新连接到来
HandlerAccept();
}
else
{
// 读事件就绪 && 普通socket可读
HandlerRead(_events[i].data.fd);
}
}
}
}
// 连接管理器:处理新连接到来的事件就绪
void HandlerAccept()
{
InetAddr client;
int sockfd = _listensock->Accept(&client); // 此时accept还会阻塞吗?不会
if (sockfd >= 0)
{
LOG(LogLevel::INFO) << "get a new link, sockfd: " << sockfd << ", client: " << client.StringAddress();
// int pos = 0;
// for (pos = 0; pos < nums; pos++)
// {
// if (_fds[pos].fd == defaultfd)
// {
// // 说明当前pos位置是空闲的,不管是之前就没有使用还是之前的fd断开连接,我们都可以使用这个位置
// break;
// }
// }
// if (pos == nums)
// {
// // Poll这里, 扩容
// // 数组满了。如果是真实的生产环境,这里可以通过动态分配(比如 std::vector)来进行扩容,
// // 这是 poll 优于 select(上限写死1024) 的另一大特点。这里暂时做拒绝连接处理。
// LOG(LogLevel::WARNING) << "poll server full...";
// close(sockfd);
// }
// else
// {
// _fds[pos].fd = sockfd;
// _fds[pos].events |= POLLIN;
// _fds[pos].revents = 0;
// }
// 对于epoll来说都多余了,我们完全不再需要额外判断任何无效的情况了,内核全都会帮我们维护好
// 将新的sockfd添加到epoll模型的红黑树中,如何添加?epoll_ctl
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = sockfd;
int n = epoll_ctl(_epfd, EPOLL_CTL_ADD, sockfd, &ev);
if (n < 0)
{
LOG(LogLevel::WARNING) << "epoll_ctl add sockfd error";
}
else
{
LOG(LogLevel::INFO) << "epoll_ctl add sockfd success, sockfd: " << sockfd;
}
}
else
{
LOG(LogLevel::WARNING) << "accept error..";
}
}
// IO处理器:处理读事件就绪
void HandlerRead(int sockfd)
{
// 到这里,读取数据的时候会不会阻塞?不会!
// 因为如果接收缓冲区没有数据,那么读事件就不会就绪,也就不会调用这个函数
// 而只有接收缓冲区有数据就绪了,才会调用这个函数进行处理,read/recv就不会阻塞了!
std::cout << "处理读取操作..." << std::endl;
char buffer[1024];
int n = read(sockfd, buffer, sizeof(buffer));
if (n > 0)
{
buffer[n] = 0;
std::cout << "client say# " << buffer << std::endl;
}
else if (n == 0)
{
LOG(LogLevel::INFO) << "client quit...";
// 注意:不同于poll,poll是先关闭sockfd,再移除poll对fd的关心
// 而epoll移除fd的关心是调用epoll_ctl,当第二个参数为EPOLL_CTL_DEL,必须移除的是合法的fd
// 所以对于epoll来说需要先移除epoll对fd的关心,再关闭文件描述符
int res = epoll_ctl(_epfd, EPOLL_CTL_DEL, sockfd, nullptr);
if (res > 0)
{
LOG(LogLevel::INFO) << "epoll_ctl remove sockfd success, sockfd: " << sockfd;
}
close(sockfd);
}
else
{
LOG(LogLevel::ERROR) << "read error";
int res = epoll_ctl(_epfd, EPOLL_CTL_DEL, sockfd, nullptr);
if (res > 0)
{
LOG(LogLevel::INFO) << "epoll_ctl remove sockfd success, sockfd: " << sockfd;
}
close(sockfd);
}
}
private:
std::unique_ptr<Socket> _listensock; // 监听套接字
bool _isrunning; // 服务器状态
// // int fd_array[nums];
// struct pollfd _fds[nums]; //epoll不再需要用户使用任何辅助数组
int _epfd; // epoll模型对应的文件描述符
struct epoll_event _events[nums];
// 因为我们不需要向这个数组输入什么,所以并非是辅助数组,而是一个缓冲区专门用于接收epoll_wait接口返回的就绪事件
};
运行结果展示:

二、epoll 的两种工作模式 LT vs ET
epoll 最经典的面试考点,就是水平触发(LT)和边缘触发(ET)的区别。select、poll 的底层机制仅等价于 LT 模式,我们前面实现的代码使用的就是 epoll 默认的 LT 模式。
2.1 LT 水平触发(Level Triggered)
2.1.1 工作规则
只要 fd 对应的缓冲区存在未处理的就绪数据,epoll_wait 就会持续返回该事件,直到数据被完全读取、就绪状态恢复。
2.1.2 特性与场景
- **优点:**逻辑简单、代码容错率高,阻塞 IO、非阻塞 IO 都可以配合使用。
- 缺点: 业务代码处理不及时的时候,会造成
epoll_wait频繁触发事件,占用 CPU 资源。 - 默认状态: 注册事件时,不添加
EPOLLET标志,就是 LT 模式。
打个通俗的比方:就像妈妈喊你吃饭,你坐在那儿不动,她就会一遍一遍地喊,直到你起身去吃为止。
举个具体例子: 套接字接收缓冲区来了 2KB 数据,epoll 通知可读;你只读了 1KB,缓冲区还剩 1KB。那么下一次调用 epoll_wait 时,它会立刻返回,继续通知这个 fd 可读,直到缓冲区里的数据被全部读完。
2.2 ET 边缘触发(Edge Triggered)
2.2.1 开启方式
在注册事件时,通过位或添加EPOLLET标志开启边缘触发。
cpp
ev.events = EPOLLIN | EPOLLET;
2.2.2 工作规则
仅在 fd 状态发生突变的瞬间触发一次事件,例如空缓冲区收到新数据、缓冲区由满变为可写。如果本次通知之后没有读完所有数据,缓冲区剩余数据不会再次触发事件,直到下一次新数据到来,状态再次发生变化。
还是那个比方:妈妈只喊你一次,你不来她就再也不喊了,等下一顿饭做好了才会再喊一次。
对应到刚才的例子: 缓冲区来了 2KB 数据,epoll 通知一次可读。如果你只读了 1KB,剩下 1KB 留在缓冲区,接下来调用 epoll_wait 不会再返回这个事件。只有当对方又发送了新数据,状态再次变化时,才会再触发一次通知。
重点: ET 模式必须配合非阻塞 IO使用。单次事件触发后,需要循环读写直到缓冲区无数据 / 无空间,否则会造成数据滞留,出现数据丢失风险。
2.2.3 特性与场景
- 优点: 事件触发次数最少,减少
epoll_wait系统调用次数,CPU 占用更低,适合超高并发场景,Nginx 默认采用 ET 模式。 - **缺点:**代码复杂度高,对 IO 读写逻辑要求严格,一旦没有读完缓冲区数据,剩余数据会一直驻留在内核缓冲区,不会再触发通知。
2.3 两种模式的定义与核心差异
- 水平触发(Level Triggered, LT):只要文件描述符对应的内核缓冲区中存在未处理的数据,内核就会持续向应用层通知该文件描述符的就绪状态,直至数据被全部读取。
- 边缘触发(Edge Triggered, ET):仅当文件描述符的状态发生跳变(即数据从无到有、数据量从少到多)的瞬间,内核才会向应用层发送一次就绪通知;若本次通知后应用层未将数据全部读取,内核不会再重复通知,直至下一次新数据到达。
2.4 具象化类比:快递派发模型
快递对应内核缓冲区的数据,快递员的通知电话对应内核的就绪事件,收件人对应应用层程序。
2.4.1 LT 模式:持续通知的快递员
该模式下的快递员责任心极强,只要三轮车上还留有收件人的未取包裹,就会反复拨打电话通知收件人取件;即便收件人每次只取走部分包裹,快递员也会持续致电,直至所有包裹全部被取走。
对应技术逻辑:只要接收缓冲区中存在未读数据,epoll_wait就会持续返回该文件描述符的就绪事件,应用层可以分多次读取数据,不存在数据遗漏的风险。
2.4.2 ET 模式:仅通知一次的快递员
该模式下的快递员仅在新包裹到达、包裹数量发生变化时拨打一次电话,无论收件人是否取件、是否取完,都不会再次致电;若收件人本次未取完包裹,只能等待下一批新包裹到达时,才会再次收到通知。
对应技术逻辑:仅当内核缓冲区的数据量发生增量变化时,才会触发一次就绪通知;若应用层本次未将缓冲区数据全部读取,剩余数据会一直驻留在内核缓冲区,且不会再触发新的就绪事件,存在数据滞留甚至丢失的风险。

2.5 ET 模式与非阻塞文件描述符的绑定关系
2.5.1 核心结论
工程开发中,使用 epoll 的 ET 边缘触发模式 ,必须将对应文件描述符设置为非阻塞 IO。这不是 API 语法层面强制限制,而是工程上为防止线程挂死、保证缓冲区数据完整读取的必要条件。
2.5.2 ET 模式为什么要循环读取数据?
ET 仅在缓冲区状态发生跳变时,只发送一次就绪通知。一旦收到就绪事件,应用层必须把本次内核缓冲区里所有数据全部读取到用户空间;如果本次没有读完,剩余数据不会再次触发事件,会永久留在缓冲区无法被读取。
因为应用层无法提前预知内核缓冲区 一共有多少字节数据,所以必须采用循环读取 ,反复调用read,直到确认缓冲区已经为空。
**补充:**LT 模式并没有强制要求循环读完,LT 支持一次读一部分,后续 epoll_wait 会持续通知就绪;但 ET 的机制倒逼我们必须一次把本轮所有数据读完。
2.5.3 为什么不能搭配阻塞 IO?
假设 ET 搭配阻塞文件描述符:
事件就绪后,我们循环调用read读取数据。当缓冲区数据全部读完之后 ,问题来了:我们作为用户不可能知道内核的情况,当前这一次是不是把缓冲区数据读完我们不清楚,那么读完这一次下次我们还会不会继续读?会!
所以,再次调用read读取数据时,阻塞模式下的read会直接挂起线程,持续等待新数据到来。
但 ET 模式的特性是:没有新数据、状态不发生变化 ,如果内核不再发送就绪通知 。那么线程就被永久阻塞在read调用,无法处理其他 fd 的事件,整个事件循环卡死,服务器失去响应。
而使用非阻塞 IO + ET :循环调用read读取,当缓冲区数据被全部读完,read返回 - 1,同时errno置为EAGAIN或EWOULDBLOCK。
这个错误码就是缓冲区为空的标志,此时停止读取,退出读循环,继续处理其他连接事件。既保证全部数据读取完毕,又不会阻塞事件循环。

2.6 ET 模式高性能背后的原理
2.6.1 减少重复事件通知,提升通知效率
LT 水平触发 的逻辑是:只要内核缓冲区还有未读完的数据,epoll 就会持续上报就绪事件 。如果应用程序处理速度跟不上,就会反复收到大量重复事件,触发多次系统调用,不断在内核态和用户态之间切换,带来额外开销。
而 ET 边缘触发 只在状态发生增量变化 的时候,触发一次事件通知。不会反复推送相同就绪事件,有效事件占比更高,省去大量无效的系统调用,减轻内核分发事件的压力。
2.6.2 带动 TCP 滑动窗口发挥更好的传输能力
ET 模式 的特性会迫使应用层一次性把内核缓冲区的数据全部读取到用户空间 ,除了防止数据滞留之外,还可以尽快清空接收缓冲区 ,向 TCP 对端通告更大的接收窗口 win 。
在 TCP 协议里,接收端会根据自身接收缓冲区剩余容量,向发送端通告接收窗口**。**接收缓冲区清空越快,通告的窗口尺寸就越大,发送端就能一次性发送更多报文,提升整体网络吞吐,同时减少 ACK 报文频繁交互带来的损耗。
2.6.3补充:LT 与 ET 在工程开发上的约束区别
LT 模式下,开发者当然也可以手动写非阻塞 + 循环读的代码,快速清空缓冲区。但 LT 本身不会强制要求开发者必须采用这种写法。 ET 模式在机制层面强制上层代码必须使用最优的读写范式。在多人协作的大型项目中,ET 这种强约束特性,更容易保障代码的性能底线,这也是它在工程实践中独特的价值。


2.7 LT 与 ET 触发模式多维度对比
| 对比维度 | 水平触发(LT) | 边缘触发(ET) |
|---|---|---|
| 事件触发逻辑 | 只要内核缓冲区留存未读取数据,就会持续上报就绪事件 | 仅在数据产生增量、IO 状态发生变化的瞬间触发一次事件通知 |
| 启用方式 | epoll 原生默认模式,无需额外配置标志位 | 需要在注册 fd 时,显式指定EPOLLET标志开启 |
| IO 模式适配 | 阻塞 IO、非阻塞 IO 两种模式都能正常工作 | 强制搭配非阻塞 IO,禁止使用阻塞 IO |
| 开发编码难度 | 逻辑简单,出错概率低,开发门槛低 | 编码复杂度更高,必须实现循环读写的代码范式 |
| 数据处理风险 | 不会出现数据遗漏,数据处理安全性高 | 代码编写失误时,容易造成数据残留、丢失问题 |
| 事件与系统调用开销 | 会产生大量重复就绪事件,带来更多系统调用与态切换开销 | 事件通知次数少,几乎没有冗余通知,系统调用开销更低 |
| 性能潜力 | 性能上限有限,适合常规业务 | 可挖掘更高吞吐,适配高并发海量连接场景 |
| 业务适用场景 | 业务逻辑复杂,优先保障稳定性、降低 bug 风险 | 自研高性能网络框架,需要极致优化并发性能 |
小结:LT 胜在简单稳定,是通用开发的稳妥选择;ET 用更高的编码代价换取更少的事件通知,适合追求极致性能的高性能服务。
三、LT 与 ET 总结与核心考点复盘
1. 两种触发模式底层本质区别:
LT 水平触发的核心判定 依据是 IO 状态 :只要内核缓冲区还留存未读取的数据,就会持续向应用层上报就绪事件。
ET 边缘触发的核心判定 依据是状态变化 :只有当新数据抵达、IO 状态发生跳变的那一刻,才会触发一次事件通知。
这是两类模式所有特性差异的根源,理解这一点,就能推导后续所有行为区别。
2. ET 模式必须搭配非阻塞 IO 的完整逻辑链:
这是网络编程面试高频考点,完整逻辑链条分为四步:
ET 只会在数据发生变化时触发一次事件通知 → 应用层必须在本次事件回调里,把缓冲区全部数据读取完毕 → 需要循环调用 read 持续读取数据 → 如果使用阻塞 read,缓冲区读完之后 read 调用会直接挂起阻塞当前进程 → 因此必须启用非阻塞 IO,依靠EAGAIN返回值判断缓冲区数据读取完毕。
整条链路环环相扣,任何一环缺失,都会造成逻辑漏洞,引发数据丢失或者服务卡死。
3. ET 高性能的两层核心原理:
不要只停留在 "事件通知次数更少" 这个浅层结论,完整原理包含两层:
第一层:减少无效重复事件,降低系统调用开销。 LT 模式下缓冲区数据未读完就会反复推送事件,频繁触发用户态与内核态切换;ET 仅在新数据到来时通知一次,剔除大量冗余事件,减轻内核事件分发压力。
**第二层:优化 TCP 滑动窗口,提升网络吞吐。**ET 强制应用程序尽快清空内核接收缓冲区,缓冲区释放越快,TCP 向发送端通告的接收窗口就越大,发送方能够一次性发送更多数据包,减少 ACK 报文交互频次,从传输层层面提升整体吞吐量,这也是很多学习者容易忽略的深层原理。
4. 工程选型参考准则:
LT 模式优势在于实现简单、稳定性强,代码出错概率低。 适合业务逻辑复杂、迭代更新频繁,优先保障业务可用性的场景。
ET 模式的优势是性能上限更高,同时自带强约束,倒逼开发者写出规范的读写逻辑。 适合高并发、追求极致性能的底层网络框架,像 Nginx、Redis 这类高性能组件,默认都采用 ET 边缘触发。
在实际项目选型时,如果业务没有极致高并发的性能诉求,优先选择 LT 模式,工程稳定性会更好。
结束语
到这里,epoll 下篇的内容就全部讲解完毕。我们先通过完整的代码实战,亲手搭建了基于 epoll 的回显服务器,拆解了事件派发、连接管理、IO 读写处理等核心模块,把前面学到的 epoll 内核知识落地到工程代码中。
随后我们深入剖析了 LT 水平触发与 ET 边缘触发两种工作模式,理清了二者底层触发逻辑的根本差别,完整推导 ET 必须配合非阻塞 IO 的原理,同时拆解 ET 高性能的两层来源,从事件通知开销到 TCP 滑动窗口的联动效应。我们也对比了两种模式在编码难度、性能、适用场景上的差异,梳理了工程选型的判断标准,对高频考点做了复盘总结。
epoll 作为 Linux 高性能 IO 多路复用的基石,是后端高并发网络服务的核心。掌握 epoll 的内核原理、编码实战以及 LT/ET 的取舍,才算真正理解 Linux 高并发网络编程。