epoll接口
epoll_create
创建一个epoll模型
- 自从linux2.6.8之后,size参数是被忽略的
- 返回值是一个文件描述符
- 用完之后, 必须调用close()关闭
epoll_ctl
epoll_ctl用于添加、修改或删除关注的文件描述符,并设置感兴趣的事件类型(如读事件、写事件等)。epoll_ctl事件注册函数,它不同于select()是在监听事件时告诉内核要监听什么类型的事件, 而是在这里先注册要监听的事件类型.
参数:
- 第一个参数是epoll_create()的返回值(epoll模型).
- 第二个参数表示动作,用三个宏来表示.
- 第三个参数是需要监听的fd.
- 第四个参数是告诉内核需要监听什么事件
第二个参数的取值:
- EPOLL_CTL_ADD :添加新的fd到epfd中
- EPOLL_CTL_MOD :修改已经注册的fd的监听事件
- EPOLL_CTL_DEL :从epfd中删除一个fd
struct epoll_event结构如下:
events可以是以下几个宏的集合:本质也是位图的形式
- EPOLLIN : 表示对应的文件描述符可以读 (包括对端SOCKET正常关闭);
- EPOLLOUT : 表示对应的文件描述符可以写;
- EPOLLPRI : 表示对应的文件描述符有紧急的数据可读 (这里应该表示有带外数据到来);
- EPOLLERR : 表示对应的文件描述符发生错误;
- EPOLLHUP : 表示对应的文件描述符被挂断;
- EPOLLET : 将EPOLL设为边缘触发(Edge Triggered)模式, 这是相对于水平触发(Level Triggered)来说的.
- EPOLLONESHOT:只监听一次事件, 当监听完这次事件之后, 如果还需要继续监听这个socket的话, 需要再次把这个socket加入到EPOLL队列里.
epoll_wait
等待事件的发生,并返回准备好的文件描述符集合
- 参数events是分配好的epoll_event结构体数组.
- epoll将会把发生的事件赋值到events数组中 (events不可以是空指针,内核只负责把数据复制到这个events数组中,不会去帮助我们在用户态中分配内存).
- maxevents告之内核这个events有多大,这个 maxevents的值不能大于创建epoll_create()时的size.
- 参数timeout是超时时间 (毫秒,0会立即返回,-1是永久阻塞).
- 如果函数调用成功,返回对应I/O上已准备好的文件描述符数目,如返回0表示已超时, 返回小于0表示函数失败.
epoll工作原理
epoll的实现机制是通过内核与用户空间共享一个事件表。这个事件表中存放着所有需要监控的文件描述符以及它们的状态。当文件描述符的状态发生变化时,内核会将这个事件通知给用户空间,用户空间再根据事件类型进行相应的处理。
具体来说,epoll的实现包括以下几个关键部分:
- socket等待队列:用于在socket接收到数据后添加就绪epoll事件节点和唤醒eventpoll等待队列项。当socket收到数据后,会唤醒socket等待队列项,并执行等待队列项注册的回调函数ep_poll_callback。该函数将就绪epoll事件节点添加至就绪队列,并唤醒eventpoll等待队列项。
- eventpoll等待队列:用于阻塞当前进程,当epoll_wait未检测到就绪epoll事件节点时,会使用等待队列将当前进程挂起。后续ep_poll_callback函数会唤醒当前进程。
- 就绪队列:用于存储就绪epoll事件节点,用户通过epoll_wait函数获取就绪epoll事件节点。
- 红黑树:用于存储通过epoll_ctl函数注册的epoll事件节点。红黑树是一种自平衡二叉查找树,能够高效地增加、删除和查找节点,从而提高epoll事件的处理效率。
epoll的工作流程主要包括以下几个步骤:
- 创建epoll文件:使用epoll_create函数创建一个epoll文件描述符。这个函数会分配一个内核中的eventpoll对象,并返回一个文件描述符用于后续操作。
- 增加、删除、修改epoll事件:使用epoll_ctl函数增加、删除或修改epoll事件。这些事件会存储在内核epoll结构体红黑树中。
- 等待epoll事件:使用epoll_wait函数等待并获取就绪的epoll事件,从就绪队列里拿。该函数会阻塞当前进程,直到有就绪的epoll事件或者超时。
- 处理epoll事件:根据获取到的epoll事件类型进行相应的处理。例如,如果事件类型是EPOLLIN(socket可读),则读取socket数据;如果事件类型是EPOLLOUT(socket可写),则向socket写入数据。
epoll的优点
(和 select 的缺点对应)
- 接口使用方便: 虽然拆分成了三个函数, 但是反而使用起来更方便高效. 不需要每次循环都设置关注的文件描述符, 也做到了输入输出参数分离开
- 数据拷贝轻量: 只在合适的时候调用 EPOLL_CTL_ADD 将文件描述符结构拷贝到内核中, 这个操作并不频繁(而select/poll都是每次循环都要进行拷贝)
- 事件回调机制: 避免使用遍历, 而是使用回调函数的方式, 将就绪的文件描述符结构加入到就绪队列中,epoll_wait 返回直接访问就绪队列就知道哪些文件描述符就绪. 这个操作时间复杂度O(1). 即使文件描述符数目很多, 效率也不会受到影响.
- 没有数量限制: 文件描述符数目无上限.
epoll工作方式
1.工作方式介绍
epoll有2种工作方式水平触发(LT)和边缘触发(ET)
- 水平触发(LT):LT模式是epoll的默认触发模式。在这种模式下,当文件描述符的状态发生变化时,内核会通知用户空间。如果用户空间没有及时处理该事件,内核会继续通知用户空间,直到事件被处理为止。这种模式的优点是编程简单,出错概率较小;缺点是可能会产生多余的通知,降低效率。总结:事件就绪一直通知
- 边缘触发(ET):ET模式是一种高速触发模式,只支持非阻塞socket。在这种模式下,当文件描述符的状态从未就绪变为就绪时,内核会通知用户空间一次。然后,内核会假设用户空间已经知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到用户空间做了某些操作导致那个文件描述符不再为就绪状态为止。这种模式的优点是效率高,减少了内核和用户空间之间的通信次数;缺点是编程复杂,需要用户空间自行处理就绪事件的循环读取或写入操作,倒逼用户要把数据全部读取或写入。总结:事件发生变化,通知一次
ET模式倒逼程序员、每次通知,都必须把本轮数据全部取走->循环读取,读取出错->fd默认是阻塞的->ET,所有的fd必须是非阻塞的。这样Tcp会向对方通告一个更大的窗口,从而从概率上让对方一次给我发送更多的数据!所以更高效
假如有这样一个例子:
- 我们已经把一个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.
2.对比LT和ET
- LT是 epoll 的默认行为. 使用 ET 能够减少 epoll 触发的次数. 但是代价就是强逼着程序猿一次响应就绪过程中就把所有的数据都处理完.
- 相当于一个文件描述符就绪之后, 不会反复被提示就绪, 看起来就比 LT 更高效一些. 但是在 LT 情况下如果也能做到,每次就绪的文件描述符都立刻处理, 把fd设为非阻塞,也循环读取,不让这个就绪被重复提示的话, 其实性能也是一样的.
- 另一方面, ET 的代码复杂程度更高了
3.理解ET模式和非阻塞文件描述符
使用 ET 模式的 epoll, 需要将文件描述设置为非阻塞. 这个不是接口上的要求, 而是 "工程实践" 上的要求.
假设这样的场景: 服务器接受到一个10k的请求, 会向客户端返回一个应答数据. 如果客户端收不到应答, 不会发送第二个10k请求
如果服务端写的代码是阻塞式的read, 并且一次只 read 1k 数据的话(read不能保证一次就把所有的数据都读出来,参考 man 手册的说明, 可能被信号打断), 剩下的9k数据就会待在缓冲区中.
所以, 为了解决上述问题(阻塞read不一定能一下把完整的请求读完), 于是就可以使用非阻塞轮训的方式来读缓冲区,保证一定能把完整的请求都读出来.
而如果是LT没这个问题. 只要缓冲区中的数据没读完, 就能够让 epoll_wait 返回文件描述符读就绪.
epoll的使用场景
epoll的高性能, 是有一定的特定场景的. 如果场景选择的不适宜, epoll的性能可能适得其反.
对于多连接, 且多连接中只有一部分连接比较活跃时, 比较适合使用epoll.
例如, 典型的一个需要处理上万个客户端的服务器, 例如各种互联网APP的入口服务器, 这样的服务器就很适合epoll.
如果只是系统内部, 服务器和服务器之间进行通信, 只有少数的几个连接, 这种情况下用epoll就并不合适. 具体要根据需求和场景特点来决定使用哪种IO模型.
epoll样例代码 LT模式
其他文件在这 Epoll代码
EpollServer.hpp
#pragma once
#include <iostream>
#include <cstring>
#include "Epoller.hpp"
#include "Socket.hpp"
#include "log.hpp"
uint32_t EVENT_IN = (EPOLLIN);
uint32_t EVENT_OUT = (EPOLLOUT);
class EpollServer : public nocopy
{
static const int num = 64;
public:
EpollServer(const uint16_t port) : _port(port), _listsocket_ptr(new Sock()), _epoller_ptr(new Epoller())
{
}
void Init()
{
_listsocket_ptr->Socket();
_listsocket_ptr->Bind(_port);
_listsocket_ptr->Listen();
}
void Accepter()
{
// 获取了一个新连接
std::string clientip;
uint16_t clientport;
int sock = _listsocket_ptr->Accept(&clientport, &clientip);
if (sock > 0)
{
// 我们能直接读取吗?不能
_epoller_ptr->EpollerUpdate(EPOLL_CTL_ADD, sock, EVENT_IN);
log(Info, "get a new link, client info@ %s:%d", clientip.c_str(), clientport);
}
}
// for test 只是为了测试,下面的读写代码有一定的bug
void Recver(int fd)
{
char buffer[1024];
ssize_t n = read(fd, buffer, sizeof(buffer) - 1); // bug?
if (n > 0)
{
buffer[n] = 0;
std::cout << "get a messge: " << buffer << std::endl;
// wrirte
std::string echo_str = "server echo $ ";
echo_str += buffer;
write(fd, echo_str.c_str(), echo_str.size());
}
else if (n == 0)
{
log(Info, "client quit, me too, close fd is : %d", fd);
// 细节3
_epoller_ptr->EpollerUpdate(EPOLL_CTL_DEL, fd, 0);
close(fd);
}
else
{
log(Warning, "recv error: fd is : %d", fd);
_epoller_ptr->EpollerUpdate(EPOLL_CTL_DEL, fd, 0);
close(fd);
}
}
void Dispatcher(struct epoll_event revs[], int num)
{
for (int i = 0; i < num; i++)
{
uint32_t events = revs[i].events;
int fd = revs[i].data.fd;
if (events & EVENT_IN)
{
if (fd == _listsocket_ptr->GetFd())
{
Accepter();
}
else
{
// 其他fd上面的普通读取事件就绪
Recver(fd);
}
}
else if (events & EVENT_OUT)
{
// 先不考虑
}
else
{
// 先不考虑
}
}
}
void Start()
{
_epoller_ptr->EpollerUpdate(EPOLL_CTL_ADD, _listsocket_ptr->GetFd(), EVENT_IN);
struct epoll_event revs[num];
while (1)
{
int n = _epoller_ptr->EpollerWait(revs, num);
if (n > 0)
{
// 有事件就绪
log(Debug, "event happened, fd is : %d", revs[0].data.fd);
Dispatcher(revs, n);
}
else if (n == 0)
{
log(Info, "time out ...");
}
else
{
log(Error, "epll wait error");
}
}
}
~EpollServer()
{
}
private:
std::shared_ptr<Sock> _listsocket_ptr;
std::shared_ptr<Epoller> _epoller_ptr;
uint16_t _port;
};