【Linux网络】I/O多路转接技术 - epoll
📢博客主页:https://blog.csdn.net/2301_779549673
📢博客仓库:https://gitee.com/JohnKingW/linux_test/tree/master/lesson
📢欢迎点赞 👍 收藏 ⭐留言 📝 如有错误敬请指正!
📢本文由 JohnKi 原创,首发于 CSDN🙉
📢未来很长,值得我们全力奔赴更美好的生活✨
文章目录
- 🏳️🌈一、epoll 概念
- 🏳️🌈二、epoll 相关调用
- 2.1 epoll_create 创建句柄
- 2.2 epoll_ctl 执行事件
- 2.3 epoll_wait 阻塞调用
- 🏳️🌈三、epoll 工作原理
- 3.1 数据到达主机
- 3.2 epoll 工作原理
- 3.3 模拟演示
- 3.3.1 EpollServer 类
- 3.3.1.1 基本结构
- 3.3.1.2 构造函数、析构函数
- 3.3.1.3 初始化函数 InitServer()
- 3.3.1.4 循环函数 Loop()
- 3.3.1.5 处理就绪事件函数 HandlerEvent()
- 3.3.1.6 监听套接字处理函数 HandlerNewConnection()
- 3.3.1.7 普通套接字处理函数 HandlerIO()
- 3.3.2 EpollServer.cpp 主函数
- 3.3.3 运行结果
- 🏳️🌈四、epoll 的优点(与 select 的缺点对应)
- 🏳️🌈五、epoll 工作方式
- 🏳️🌈六、对比 LT 和 ET
- 🏳️🌈七、理解 ET 模式和 非阻塞文件描述符
- 🏳️🌈八、ET 模式的典型陷阱
- 🏳️🌈九、epoll 的使用场景
- 👥总结
🏳️🌈一、epoll 概念
- 按照
man
手册的说法: 是为处理大批量句柄而作了改进的 poll. - 它是在 2.5.44 内核中被引进的(epoll(4) is a new API introduced in Linux kernel 2.5.44)
- 它几乎具备了之前所说的一切优点,被公认为 Linux2.6 下性能最好的多路 I/O 就绪通知方法.
作用:等待多个fd,等待fd上面的新事件就绪,通知程序员,事件已经就绪,可以进行IO拷贝了!
定位:只负责进行等,等就绪事件派发!
🏳️🌈二、epoll 相关调用
2.1 epoll_create 创建句柄
int epoll_create(int size); // 创建一个 epoll 句柄
参数
- size:在早期版本的 Linux 中指定了监听的文件描述符数量上限,自从 linux2.6.8 之后,这个参数被忽略 ,因为 epoll 自动调整以处理最大数量的文件描述符。因此,传递任何大于 0 的值都是可以的,通常使用 1 作为默认值。
返回值
- 成功时,epoll_create() 返回一个非负的文件描述符,该描述分用于后续的 epoll 操作
- 失败时,返回 -1 并设置 errno 以指示错误类型
注意
- 用完之后,必须调用
close
关闭
2.2 epoll_ctl 执行事件
// 注册、注销、修改 epoll 事件
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
参数:
- epfd: 由 epoll_create() 或 epoll_createl() 返回的 epoll 文件描述符
- op:要执行的操作,可以是以下三个值之一
EPOLL_CTL_ADD
:向 epoll 实例中添加一个新的文件描述符EPOLL_CTL_DEL
:从 epoll 实例中删除一个文件描述符EPOLL_CTL_MOD
:修改一个已经存在于 epoll 实例中的文件描述符的监听事件
- fd:要添加、删除或修改的文件描述符
- event:执行一个 epoll_event 结构体的指针,该结构体指定了要监听的事件类型和数据。对于 EPOLL_CTL_DEL 操作,这个参数可以是 nullptr,因为删除操作不需要直到事件类型
返回值:
- 成功时,epoll_ctl() 返回 0
- 失败时,返回 -1 并设置 errno 以指示错误类型
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 */
} __EPOLL_PACKED;
events
可以是以下几个宏的集合:
- EPOLLIN : 表示对应的文件描述符可以读 (包括对端 SOCKET 正常关闭);
- EPOLLOUT : 表示对应的文件描述符可以写;
- EPOLLPRI : 表示对应的文件描述符有紧急的数据可读 (这里应该表示有带外数据到来);
- EPOLLERR : 表示对应的文件描述符发生错误;
- EPOLLHUP : 表示对应的文件描述符被挂断;
- EPOLLET : 将 EPOLL 设为边缘触发(Edge Triggered)模式, 这是相对于水平触发(Level Triggered)来说的.
- EPOLLONESHOT:只监听一次事件, 当监听完这次事件之后, 如果还需要继续监听这个 socket 的话, 需要再次把这个 socket 加入到 EPOLL 队列里.
2.3 epoll_wait 阻塞调用
// 阻塞调用线程,直到有至少一个文件描述符上的事件变得就绪,或者超时发生
int epopll_wait(int epfd, struct epoll_event* events, int maxevents, int timeout);
参数
- epfd: 由 epoll_create() 或 epoll_createl() 返回的 epoll 文件描述符
- events:指向一个
epoll_event
结构体数组的指针,该数组用于存储返回的事件信息 - maxevents:events 数组的大小,即最多可以返回的事件数量
- timeout:等待事件的超时事件(毫秒)
- 如果为 -1,则
无限期阻塞
,直到有事件发生 - 如果为 0 ,则
立即返回
- 如果为 -1,则
返回值
- 成功时,epoll_wait() 返回就绪事件的数量,这些事件被村粗在 events 数组中
- 失败时,返回 -1 并设置 errno 以指示错误类型
🏳️🌈三、epoll 工作原理
3.1 数据到达主机
- 数据到达主机的原理 涉及多个层级和协议的协同工作。通过逐层封装、转发和接收处理,数据能够准确地从源主机传输到目的主机
- 硬件中断时由硬件设备发出的信号,用于通知计算机系统发生了某个事件,需要系统进行处理。这些硬件设备可以是磁盘、网卡、键盘、时钟等
3.2 epoll 工作原理
- 当某一进程调用
epoll create
方法时,Linux 内核会创建一个eventpoll
结构体,这个结构体中有两个成员与epoll
的使用方式密切相关.
struct eventpoll{// 红黑树的根节点,这棵树中存储着所有添加到 epoll 中的需要监控的时间struct rb_root rbr;// 双链表中则存放着将要通过 epoll_wait 返回给用户的满足条件的事件struct list_head rdlist;
};
- 每一个
epoll
对象都有一个独立的eventpoll
结构体,用于存放通过epoll_ctl
方法向 epoll 对象中添加进来的事件 - 这些事件就会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效地是识别出来(红黑树地插入时间效率是 lgn,其中 n 为树地高度)
- 而>所有添加到
epoll
中的事件都会与设备(网卡)驱动程序建立回调关系</font,也就是说,当相应地事件发生时会调用这个回调方法 - 这个回调方法在内核中叫
ep_poll_callback
,它将发生的事件添加到rdlist
双链表中 - 在
epoll
中,对于每一个事件,都会建立一个epitem
结构体
struct epitem{struct rb_node rbn; // 红黑树节点struct list_head rdlink; // 双向链表节点struct epoll_filefd ffd; // 事件句柄信息struct eventpoll* ep; // 指向其所属地 eventpoll 对象struct epoll_event event; // 其弟啊发生地事件类型
};
- 当调用 epoll_wait 检查是否有事件发生时,只需要检查 eventpoll 对象中 rdlist 双链表中是否有 epitem 元素即可
- 如果 rdlist 不会空,则把发生的事件复制到用户态,同时将事件数量返回给用户,这个操作的时间复杂度是 O(1)
总结一下, epoll 的使用过程就是三部曲
- 调用 epoll_create 创建一个 epoll 句柄
- 调用 epoll_ctl ,将要监控的文件描述符进行注册
- 调用 epoll_wait 等待文件描述符就绪
3.3 模拟演示
3.3.1 EpollServer 类
3.3.1.1 基本结构
EpollServer
类的成员变量包括 端口号,listen套接字,epfd(epoll_create()函数的返回值),接收事件的数组
成员函数与PollServer
类基本一致!
class EpollServer{const static int gsize = 128;const static int gnum = 1024;public:EpollServer(uint16_t port);void InitServer();void Loop();~EpollServer();private:uint16_t _port;SockPtr _lostensock;int _epfd;struct epoll_event _events[gnum];
};
3.3.1.2 构造函数、析构函数
构造函数
初始化端口号,根据端口号创建监听套接字对象 以及 创建epoll句柄
析构函数
关闭 epfd(合法的前提下) 和 listensock
EpollServer(uint16_t port): _port(port), _listensock(std::unique_ptr<TcpSocket>()) {_listensock->BuildListenSocket(_port);_epfd = ::epoll_create(gsize);if (_epfd < 0) {LOG(LogLevel::FATAL) << "epoll_create error: " << errno;exit(1);}LOG(LogLevel::INFO) << "epoll_create success. epfd: " << _epfd;
}
~EpollServer() {if (_epfd >= 0)::close(_epfd);_listensock->Close();
}
3.3.1.3 初始化函数 InitServer()
InitServer()
函数使用系统调用(epoll_ctl),将 listensock 添加到 epoll
void InitServer() {struct epoll_event ev;ev.events = EPOLLIN;ev.data.fd = _listensock->Sockfd();// 将监听套接字添加到 epoll 中int n = epoll_ctl(_epfd, EPOLL_CTL_ADD, _listensock->Sockfd(), &ev);if (n < 0) {LOG(LogLevel::FATAL) << "epoll_ctl errno\n";exit(2);}LOG(LogLevel::INFO) << "epoll_ctl success, listensockfd: "<< _listensock->Sockfd();
}
3.3.1.4 循环函数 Loop()
Loop()
函数调用epoll_wait
系统调用进行等待,根据返回值执行对应的操作:
- 返回值为0 :打印超时日志,并退出循环
- 返回值为-1 :打印出错日志,并退出循环
- 返回值大于0 :打印事件发生日志,并处理合法事件
void Loop() {int timeout = 1000;while (true) {int n = ::epoll_wait(_epfd, _events, gnum, timeout);switch (n) {case 0:LOG(LogLevel::INFO) << "epoll timeout";break;case -1:LOG(LogLevel::ERROR) << "epoll_wait error: " << errno;break;default:LOG(LogLevel::INFO) << "haved event happened, nums : " << n;HandlerEvent(n);break;}}
}
3.3.1.5 处理就绪事件函数 HandlerEvent()
HandlerEvent()
函数处理就绪事件,主要分为以下两步:
- 从事件数组中读取
合法fd
和events
- 判断读事件是否就绪
listensock
就绪normal sockfd
就绪
void HandlerEvent(int nums) {for (int i = 0; i < nums; ++i) {// 1. 从事件数组中读取合法的 fd 和 eventsint fd = _events[i].data.fd;uint32_t events = _events[i].events;LOG(LogLevel::INFO) << "上面有事件就绪了,具体事件是:" << fd << " "<< std::to_string(events).c_str();// 2. 判断是监听套接字就绪还是其他套接字就绪if (events & EPOLLIN) {if (fd == _listensock->Sockfd())HandlerNewConnection();elseHandlerIO(fd);}}
}
3.3.1.6 监听套接字处理函数 HandlerNewConnection()
- 获取链接
- 获取链接成功将新的
fd
和读事件
添加到epoll
中(使用epoll_ctl系统调用)
void HandlerNewConnection() {InetAddr client;int sockfd = _listensock->Accepter(&client);if (sockfd < 0) {LOG(LogLevel::ERROR) << "accept error: " << errno;return;}LOG(LogLevel::INFO) << "get a new connection from "<< client.AddrStr().c_str() << ", sockfd : " << sockfd;struct epoll_event ev;ev.data.fd = sockfd;ev.events = EPOLLIN;::epoll_ctl(_epfd, EPOLL_CTL_ADD, sockfd, &ev);LOG(LogLevel::INFO) << "epoll_ctl success, sockfd: " << sockfd;
}
3.3.1.7 普通套接字处理函数 HandlerIO()
HandlerIO()
函数处理普通fd情况,直接读取文件描述符中的数据,根据recv()函数的返回值做出不一样的决策,主要分为以下三种情况:
- 返回值大于0,读取文件描述符中的数据,并使用
send()
函数做出回应! - 返回值等于0,读到文件结尾,打印客户端退出的日志,关闭文件描述符,将 epfd 从 epoll 中移除并关闭 fd
- 返回值小于0,读取文件错误,打印接受失败的日志,然后同上!
void HandlerIO(int fd) {char buffer[1024];ssize_t n = ::recv(fd, buffer, sizeof(buffer) - 1, 0);if (n > 0) {buffer[n] = 0;std::cout << buffer;std::string content = "<html><body><h1>hello linux</h1></body></html>";std::string echo_str = "HTTP/1.0 200 OK\r\n";echo_str += "Content-Type: text/html\r\n";echo_str +="Content-Length: " + std::to_string(content.size()) + "\r\n\r\n";echo_str += content;::send(fd, echo_str.c_str(), echo_str.size(), 0);} else if (n == 0) {LOG(LogLevel::DEBUG) << "client " << fd << " closed";// 1. 从 epoll 中移除,从 epoll 中移除 fd,这个必须是健康 合法的// fd,否则会移除出错::epoll_ctl(_epfd, EPOLL_CTL_DEL, fd, nullptr);// 2. 关闭fd::close(fd);} else {LOG(LogLevel::ERROR) << "recv error: " << errno;// 1. 从 epoll 中移除,从 epoll 中移除 fd,这个必须是健康 合法的// fd,否则会移除出错::epoll_ctl(_epfd, EPOLL_CTL_DEL, fd, nullptr);// 2. 关闭fd::close(fd);}
}
3.3.2 EpollServer.cpp 主函数
根据主函数反向实现类和成员函数
#include "EpollServer.hpp"int main(int argc, char* argv[]){if(argc != 2){std::cerr << "Usage: " << argv[0] << " locak-port" << std::endl; }uint16_t port = std::stoi(argv[1]);std::unique_ptr<EpollServer> svr = std::make_unique<EpollServer>(port);svr->InitServer();svr->Loop();return 0;
}
3.3.3 运行结果
🏳️🌈四、epoll 的优点(与 select 的缺点对应)
- 接口使用方便: 虽然拆分成了三个函数, 但是反而使用起来更方便高效. 不需要每次循环都设置关注的文件描述符, 也做到了输入输出参数分离开
- 数据拷贝轻量: 只在合适的时候调用
EPOLL_CTL_ADD
将文件描述符结构拷贝到内核中, 这个操作并不频繁(而select/poll
都是每次循环都要进行拷贝) - 事件回调机制: 避免使用遍历, 而是使用回调函数的方式, 将就绪的文件描述符结构加入到就绪队列中 ,
epoll_wait
返回直接访问就绪队列就知道哪些文件描述符就绪. 这个操作时间复杂度 O(1). 即使文件描述符数目很多, 效率也不会受到影响. - 没有数量限制: 文件描述符数目无上限.
网上有些博客说, epoll 中使用了内存映射机制
- 内存映射机制: 内核直接将就绪队列通过 mmap 的方式映射到用户态. 避免了拷贝内存这样的额外性能开销.
- 这种说法是不准确的. 我们定义的
struct epoll_event
是我们在用户空间中分配好的内存. 势必还是需要将内核的数据拷贝到这个用户空间的内存中的.
🏳️🌈五、epoll 工作方式
你妈喊你吃饭的例子
你正在吃鸡, 眼看进入了决赛圈, 你妈饭做好了, 喊你吃饭的时候有两种方式:
- 如果你妈喊你一次, 你没动, 那么你妈会继续喊你第二次, 第三次…(亲妈,水平触发)
- 如果你妈喊你一次, 你没动, 你妈就不管你了(后妈, 边缘触发)
epoll
有 2 种工作方式 - 水平触发(LT)
和 边缘触发(ET)
假如有这样一个例子:
- 我们已经把一个
tcp socket
添加到epoll
描述符 - 这个时候
socket
的另一端被写入了 2KB 的数据 - 调用
epoll_wait
,并且它会返回. 说明它已经准备好读取操作 - 然后调用
read
, 只读取了 1KB 的数据 - 继续调用 epoll_wait…
水平触发 Level Triggered
工作模式
epoll
默认状态下就是LT
工作模式.- 当
epoll
检测到socket
上事件就绪的时候, 可以不立刻进行处理. 或者只处理一部分. - 如上面的例子, 由于只读了 1K 数据, 缓冲区中还剩 1K 数据, 在第二次调用epoll_wait 时, epoll_wait 仍然会立刻返回并通知 socket 读事件就绪.
- 直到缓冲区上所有的数据都被处理完,
epoll_wait
才不会立刻返回. - 支持
阻塞读写
和非阻塞读写
边缘触发 Edge Triggered
工作模式
- 如果我们在第 1 步将 socket 添加到 epoll 描述符的时候使用了 EPOLLET 标志,
epoll
进入ET
工作模式. - 当
epoll
检测到socket
上事件就绪时, 必须立刻处理. - 如上面的例子, 虽然只读了 1K 的数据, 缓冲区还剩 1K 的数据, 在第二次调用epoll_wait 的时候, epoll_wait 不会再返回了.
- 也就是说,
ET
模式下, 文件描述符上的事件就绪后, 只有一次处理机会. ET
的性能比LT
性能更高( epoll_wait 返回的次数少了很多). Nginx 默认采用ET 模式使用 epoll.- 只支持
非阻塞
的读写
select
和 poll
其实也是工作在 LT 模式下.
epoll
既可以支持 LT, 也可以支持 ET.
🏳️🌈六、对比 LT 和 ET
LT
是epoll
的默认行为.- 使用 ET 能够减少 epoll 触发的次数. 但是代价就是强逼着程序猿
一次响应就绪过程
中就把所有的数据都处理完
- 相当于一个文件描述符就绪之后, 不会反复被提示就绪, 看起来就比 LT 更高效一些. 但是在 LT 情况下如果也能做到每次就绪的文件描述符
都立刻处理
,不让这个就绪被重复提示的话
, 其实性能也是一样的.
- 相当于一个文件描述符就绪之后, 不会反复被提示就绪, 看起来就比 LT 更高效一些. 但是在 LT 情况下如果也能做到每次就绪的文件描述符
- 另一方面, ET 的代码复杂程度更高了.
- ET 的通知效率更高
- ET可能给对方一个更大的接受窗口,增加IO效率 – 即ET的IO效率更高
🏳️🌈七、理解 ET 模式和 非阻塞文件描述符
使用 ET
模式的 epoll
, 需要将文件描述设置为非阻塞. 这个不是接口上的要求, 而是 “工程实践” 上的要求,逻辑如下:
有一个问题:LT也可以设置非阻塞,LT我也可以循环读取完毕啊,为什么要有ET呢?
最简单的理解,ET是被强制要求非阻塞的,但是LT可以是阻塞也可以是非阻塞!
假设这样的场景: 服务器接收到一个 10k 的请求, 会向客户端返回一个应答数据. 如果客户端收不到应答, 不会发送第二个 10k 请求
如果服务端写的代码是阻塞式的 read, 并且一次只 read 1k 数据的话(read 不能保证一次就把所有的数据都读出来, 参考 man 手册的说明, 可能被信号打断), 剩下的 9k 数据就会待在缓冲区中.
此时由于 epoll 是 ET 模式, 并不会认为文件描述符读就绪. epoll_wait 就不会再次返回. 剩下的 9k 数据会一直在缓冲区中. 直到下一次客户端再给服务器写数据.epoll_wait 才能返回
但是问题来了
- 服务器只读到 1k 个数据, 要 10k 读完才会给客户端返回响应数据.
- 客户端要读到服务器的响应, 才会发送下一个请求
- 客户端发送了下一个请求, epoll_wait 才会返回, 才能去读缓冲区中剩余的数据.
所以, 为了解决上述问题(阻塞 read 不一定能一下把完整的请求读完), 于是就可以使用非阻塞轮训的方式来读缓冲区, 保证一定能把完整的请求都读出来.
而如果是 LT 没这个问题. 只要缓冲区中的数据没读完, 就能够让 epoll_wait 返回文件描述符读就绪.
🏳️🌈八、ET 模式的典型陷阱
- 事件丢失
- 若未在 ET 模式下一次性处理完所有数据,剩余数据不会再次触发 EPOLLIN,导致数据滞留。
- 解决:必须循环读取直到 EAGAIN。
- 饥饿问题
- 若某个 fd 持续有数据到达,可能独占事件循环,导致其他 fd 得不到处理。
- 解决: 设置处理上限(如每次最多读取 10 次),或使用 EPOLLONESHOT 标志。
- 错误处理遗漏
- 未处理 EPOLLERR 或 EPOLLHUP,导致程序无法感知连接异常。
- 解决: 始终优先检查错误事件:
🏳️🌈九、epoll 的使用场景
epoll
的高性能, 是有一定的特定场景的. 如果场景选择的不适宜, epoll 的性能可能适得其反.
- 对于多连接, 且多连接中只有一部分连接比较活跃时, 比较适合使用 epoll.
例如, 典型的一个需要处理上万个客户端的服务器, 例如各种互联网 APP 的入口服务器,这样的服务器就很适合 epoll.
如果只是系统内部, 服务器和服务器之间进行通信, 只有少数的几个连接, 这种情况下用 epoll 就并不合适. 具体要根据需求和场景特点来决定使用哪种 IO 模型.
👥总结
本篇博文对 【Linux网络】I/O多路转接技术 - epoll 做了一个较为详细的介绍,不知道对你有没有帮助呢
觉得博主写得还不错的三连支持下吧!会继续努力的~