epoll
epoll
是 Linux 下多路复用 I/O 接口 select/poll 的增强版本,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统 CPU 利用率。它是 Linux 下多路复用 API 的一个选择,相比 select
和 poll
,epoll
提供了更高的性能,并且使用起来也更加方便。
epoll的工作原理
eventpoll框架的核心在于它能够高效地处理多个文件描述符上的事件,避免了传统I/O多路复用机制(如select和poll)中的轮询开销。eventpoll通过以下方式实现:
- 注册文件描述符:当文件描述符被注册到eventpoll时,会创建一个epitem(eventpoll item)结构体,用于表示该文件描述符及其关心的事件类型。这个epitem会被插入到eventpoll的红黑树(rbtree)中,以便快速查找和管理。
- 等待事件发生:通过调用epoll_wait()系统调用,应用程序会在eventpoll的等待队列(wq)上等待。此时,指定的回调函数是default_wake_function,用于在事件发生时唤醒等待的线程。
- 事件通知:当被监测的文件描述符上有事件发生时,会调用ep_poll_callback()回调函数,将相应的epitem插入到eventpoll的就绪链表(rdllist)中。epoll_wait()会从这个链表中取出epitem,并将对应的事件通知给应用程序。
注意:以上操作均有系统自主完成
epoll 的相关系统调用
epoll_create()
创建一个 epoll 的句柄.
cpp
#include <sys/epoll.h>
int epoll_create(int size);
size
参数用于告诉内核这个监听列表(epoll 实例)打算同时监视多少个文件描述符。
返回值:如果调用成功,epoll_create 返回一个新的文件描述符,该描述符用于后续的 epoll_ctl()和 epoll_wait()调用。
如果调用失败,则返回 -1,并设置 errno 以指示错误原因。
epoll_ctl()
允许程序在 epoll 实例中添加、修改或删除文件描述符(file descriptors)的监听事件.
cpp
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
参数说明
- epfd:由
epoll_create ()
函数生成的 epoll 实例的文件描述符。- op:指定要执行的操作,常用的值包括:
EPOLL_CTL_ADD
:向 epoll 实例注册新的文件描述符和事件。
EPOLL_CTL_MOD
:修改已注册的文件描述符的事件。
EPOLL_CTL_DEL
:从 epoll 实例中删除一个文件描述符。- fd:要操作的目标文件描述符,即要注册、修改或删除的文件描述符。
- event:指向 struct epoll_event 结构体的指针,该结构体包含了要注册或修改的事件信息。对于
EPOLL_CTL_DEL
操作,该参数可以为 NULL。
cpptypedef 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; /* 与事件相关的数据 */ };
events:这是一个位掩码,用于指示发生的事件类型。常见的事件类型包括:
EPOLLIN
:表示对应的文件描述符可以进行读操作。
EPOLLOUT
:表示对应的文件描述符可以进行写操作。
EPOLLERR
:表示发生错误。
EPOLLHUP
:表示挂起(hang up)事件,比如对端关闭了连接。
EPOLLET
:将事件设置为边缘触发(Edge Triggered)模式,这是与水平触发(Level Triggered)模式相对的一种触发模式。
EPOLLONESHOT
:用于确保事件被触发一次后,除非再次使用 epoll_ctl 重新注册,否则不再接收该事件。data:这是一个联合体,可以存储与事件相关的数据。它提供了多种方式来关联事件和特定的数据或文件描述符:
ptr
:可以指向任意类型的数据,通常用于存储用户自定义的数据结构指针。
fd
:直接存储文件描述符的值,当只需要管理文件描述符时,这种方式更为直接(常用)。
u32
和u64
:分别提供了32位和64位的无符号整数存储,这些字段可以用来存储特定的值或标识符。
epoll_wait()
程序调用 epoll_wait 时,它会阻塞当前线程,直到注册在 epoll 实例上的文件描述符上有事件发生,或者超时时间到达。
cpp
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
参数说明
epfd
:由epoll_create
函数生成的 epoll 实例的文件描述符。
events
:指向struct epoll_event
数组的指针,用于存储发生的事件。当epoll_wait
返回时,该数组将被填充有发生事件的文件描述符和事件类型的信息。
maxevents
:指定 events 数组的最大长度,即 epoll_wait 一次可以处理的最大事件数。
timeout
:指定等待 I/O 事件发生的超时时间(毫秒)。如果设置为 -1,则epoll_wait
将无限期地等待,直到有事件发生。如果设置为 0,则 epoll_wait 将立即返回,无论是否有事件发生。如果设置为一个正整数,则 epoll_wait 将等待指定的毫秒数,如果在这段时间内有事件发生,则返回;否则返回 0,表示超时。
返回值成功时,epoll_wait 返回发生事件的文件描述符数量。如果返回 0,则表示在指定的超时时间内没有事件发生。
如果发生错误,epoll_wait 返回 -1,并设置 errno 以指示错误原因。
epoll的工作方式
水平触发(Level Triggered, LT)
工作原理:在水平触发模式下,只要被监控的文件描述符上有可读写事件发生(即数据到达但未被读取,或可写空间可用但未被写入),epoll_wait就会通知用户程序 。
如果数据到达但是没有被读取,或者可写空间可用但是没有被写入,epoll_wait会再次通知用户程序,直到相应的操作被执行。
特点:
- 通知次数:只要条件满足,就会不断地通知。
- 读写策略:可以更灵活地处理读写,不需要连续读取或写入直到遇到错误。
- 效率:由于频繁的通知,可能会引起较多的上下文切换,影响效率。
- 编程复杂度:相对容易理解和使用。
边缘触发(Edge Triggered, ET)
工作原理:边缘触发模式是一种更高效的触发方式。在这种模式下,epoll_wait仅在状态变化时通知用户程序一次,比如从无数据到有数据,或者从不可写变为可写 。
当收到一个可读事件时,需要一直读取数据,直到返回EAGAIN错误(表示没有更多数据可读)。同样,对于可写事件,需要一直写入数据,直到不能再写入为止。
知次数:只在状态发生变化时通知一次。
读写策略:读操作需要一直进行,直到遇到EAGAIN错误;写操作也是如此,需要一直写,直到无法继续写入。
效率:减少了系统调用的次数,提高了应用程序的效率。
编程复杂度:要求程序必须更加小心地处理事件,以避免错过任何事件,这使得编程变得更加复杂。
主要区别
. | 水平触发(LT) | 边缘触发(ET) |
---|---|---|
通知次数 | 只要条件满足,就会不断地通知 | 只在状态发生变化时通知一次 |
读写策略 | 可以更灵活地处理读写 | 读操作需要一直进行,直到遇到EAGAIN错误;写操作也是如此 |
效率 | 可能会引起较多的上下文切换,影响效率 | 减少了系统调用的次数,提高了应用程序的效率 |
编程复杂度 | 相对容易理解和使用 | 要求程序必须更加仔细地处理事件,以避免错过任何事件,编程复杂度高 |
epoll_server实例(LT方式)
我们将对上一篇的select_server进行一定的修改即可;
epollServer.hpp
cpp
#pragma once
#include <iostream>
#include <string>
#include <memory>
#include <sys/epoll.h>
#include "InetAddr.hpp"
#include "Socket.hpp"
#include "Log.hpp"
using namespace socket_ns;
class EpollServer
{
const static int gnum = 64;
public:
EpollServer(uint16_t port = 8080)
: _port(port),
_listensock(std::make_unique<TcpSocket>()),
_epfd(-1)
{
// 1. 创建listensock
InetAddr addr("0", _port);//0表示任意ip
_listensock->BuildListenSocket(addr);
// 2. 创建epoll模型
_epfd = ::epoll_create(128);//返回值是epoll的fd
if (_epfd < 0)
{
LOG(FATAL, "epoll_create error\n");
exit(5);
}
LOG(DEBUG, "epoll_create success, epfd: %d\n", _epfd);
// 3. 只有一个listensock, listen sock 关心的事件:读事件
struct epoll_event ev; //结构体包含事件的信息
ev.events = EPOLLIN;//事件可读
ev.data.fd = _listensock->SockFd(); //将listenfd放入到信息中
epoll_ctl(_epfd, EPOLL_CTL_ADD, _listensock->SockFd(), &ev);
}
//对事件的处理
void handlerEvent(int num)
{
for (int i = 0; i < num; i++)//可处理多个事件
{
// 逐一将事件取出
uint32_t revents = _revs[i].events;
int sockfd = _revs[i].data.fd;
// 读事件就绪
if (revents & EPOLLIN)
{
if (sockfd == _listensock->SockFd())//监听fd,表示将创建连接fd
{
InetAddr clientaddr;
int newfd = _listensock->Accepter(&clientaddr); // 不会被阻塞,事件已知被响应
if (newfd < 0)
continue;
// 获取新链接成功
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = newfd;
epoll_ctl(_epfd, EPOLL_CTL_ADD, newfd, &ev);//将新事件添加到epoll中
LOG(DEBUG, "_listensock ready, accept done, epoll_ctl done, newfd is: %d\n", newfd);
}
else//表示连接的fd有事情发生
{
char buffer[1024];
ssize_t n = ::recv(sockfd, buffer, sizeof(buffer), 0); //接收客户端数据
if (n > 0)
{
LOG(DEBUG, "normal fd %d ready, recv begin...\n", sockfd);
buffer[n] = 0;
std::cout << "client say# " << buffer << std::endl;
std::string echo_string = "server echo# ";
echo_string += buffer;
::send(sockfd, echo_string.c_str(), echo_string.size(), 0);//将结果返回
}
else if (n == 0)//表示连接已被断开,没有断开无数据传输将阻塞于epoll
{
LOG(DEBUG, "normal fd %d close, me too!\n", sockfd);
// 对端连接关闭了
::epoll_ctl(_epfd, EPOLL_CTL_DEL, sockfd, nullptr);
::close(sockfd);
}
else
{
::epoll_ctl(_epfd, EPOLL_CTL_DEL, sockfd, nullptr); // 这里表示将epoll中的sockfd删除
::close(sockfd);//而fd是拷贝进去的,只是将拷贝在epoll中的fd擦除,对应的fd事件还没有被关闭
}
}
}
}
}
//循环执行
void Loop()
{
int timeout = -1;//表示epoll阻塞等待,直到有事件发生
while (true)
{
int n = ::epoll_wait(_epfd, _revs, gnum, timeout);//用于等待事件的发生
switch (n)
{
case 0://规定时间内无事件发生
LOG(DEBUG, "epoll_wait timeout...\n");
break;
case -1://发生错误
LOG(DEBUG, "epoll_wait failed...\n");
break;
default://有事件发生
LOG(DEBUG, "epoll_wait haved event ready..., n : %d\n", n);
handlerEvent(n);
break;
}
}
}
~EpollServer()
{
_listensock->Close();//关闭listen_fd
if (_epfd >= 0)//关闭epoll的fd
::close(_epfd);
}
private:
uint16_t _port; //端口号
std::unique_ptr<Socket> _listensock; //监听sock
int _epfd; //epoll的fd
struct epoll_event _revs[gnum];//事件数组,存储对应事件
};
_epfd
: epoll是Linux底层中一种高效的I/O多路复用机制,所以也是属于一种事件,需要在用户层创建对应的文件描述符用于表示对epoll的创建;
_revs
: 虽然在底层有红黑树来进行存储对应的事件,但是在用户层是无法了解到底层的存储执行的,因为epoll的底层全由系统来完成的,用户无法操作,所以还需要一个事件数组来存储对应的事件。
初始化:
128是设置这次的最大事件管理数量,相比于select来说他是无上限的,比较灵活;
对于事件的控制 :将事件信息包含在ev结构体中即可;
Loop:
这里我们将事件数组放入到函数中,当
epoll_wait
返回时,该数组将被填充有发生事件的文件描述符和事件类型的信息。这样就不用我们手动添加到事件数组中。正是因为底层的红黑树会先存储着对应的事件信息,当被监测的文件描述符上有事件发生时,将相应的epitem插入到eventpoll的就绪链表(rdllist)中。epoll_wait()会从这个链表中取出epitem,放到_revs中,所以调用该函数会存到事件数组中。
handlerEvent:
EPOLLIN是0x001, revents如果对应位上是可读的(如:0x003)那么就能表示读事件就绪了;
cpp
#include "epollServer.hpp"
#include "Log.hpp"
#include <iostream>
#include <memory>
// ./selectserver port
int main(int argc, char *argv[])
{
if (argc != 2)
{
std::cout << "Usage: " << argv[0] << " port" << std::endl;
return 0;
}
uint16_t port = std::stoi(argv[1]);
EnableScreen();
std::unique_ptr<EpollServer> svr = std::make_unique<EpollServer>(port);
svr->Loop();
return 0;
}
注:ET模式相对来说比较复杂,需要涉及到非阻塞的程序,等下一篇Reactor再详细展示。
epoll的优点
- 支持水平触发(LT)和边缘触发(ET)
- 接口简单易用
- 没有最大文件描述符数量的限制 :select 和 poll 都有文件描述符数量的限制,而 epoll 则没有。
- 只管理"活跃"的连接:epoll 会检查注册在其上的所有 socket,只将那些真正活跃的 socket 返回给用户,即减少了无效的等待时间。
- 高效处理大量并发连接:epoll能够高效地处理大量并发连接,尤其适用于只有少量活跃连接的大量并发场景。它通过内核与用户空间共享一个事件表来跟踪所有需要监控的文件描述符,当文件描述符的状态发生变化时,内核会通知用户空间,从而避免了传统方法中的线性扫描。
- 提高CPU利用率:epoll在等待事件就绪时,如果就绪队列中没有事件,会主动让出CPU,从而提高了CPU的利用率。这使得epoll在处理大量并发连接时能够更加高效地利用系统资源。