【Linux】多路转接epoll

发布时间:2026/8/17 19:10:45
【Linux】多路转接epoll 目录一、多路转接epoll1.1 初识epoll1.2 epoll的相关系统调用1.3 epoll的工作流程二、epoll版本Echo Server服务器2.1 基本框架2.2 补充其它接口三、epoll的工作模式个人主页矢望个人专栏C、Linux系统编程、Linux网络编程、C语言、数据结构、Coze-AI、MySQL一、多路转接epoll1.1 初识epollepoll是Linux内核为处理大量并发连接而设计的高效I/O事件通知机制它通过让应用程序只询问“哪些文件描述符已就绪”而非遍历全部从而以O(1)复杂度实现海量socket的事件管理。按照man手册的说法epoll是为处理大批量句柄而作了改进的poll。它是在2.5.44内核中被引进的。它几乎具备了上期博客所说select/poll的一切优点被公认为Linux2.6下性能最好的多路I/O就绪通知方法。1.2 epoll的相关系统调用epoll有三个相关的系统调用分别是epoll_create、epoll_ctl、epoll_wait。epoll_create创建epoll实例。intepoll_create(intsize);作用在内核中创建一个epoll实例相当于一个事件管理器返回一个文件描述符epfd后续所有操作都通过它进行。size参数历史遗留早期提示内核要监控多少fd现在内核会动态扩展传入任意正整数即可如1Linux 2.6.8后已被忽略。返回值成功返回非负epfd失败返回-1并设置errno。epoll_ctl用户控制关心哪些fd的哪些事件。intepoll_ctl(intepfd,intop,intfd,structepoll_event*event);作用向内核注册/修改/删除要监控的fd及其对应的事件。参数拆解epfd由epoll_create返回的句柄。op操作类型三种EPOLL_CTL_ADD添加一个新的fd到监控列表。EPOLL_CTL_MOD修改已注册fd的事件类型。EPOLL_CTL_DEL从监控列表中移除fd此时event可为NULL。fd你要监控的目标文件描述符如socket。event结构体指针指定关心的事件如EPOLLIN可读、EPOLLOUT可写和用户数据epoll_data联合体可放fd或ptr。常见事件EPOLLIN可读、EPOLLOUT可写、EPOLLPRI紧急数据带外数据、EPOLLERR错误、EPOLLHUP挂断对端关闭、EPOLLET边缘触发模式、EPOLLRDHUP对端半关闭需内核支持、EPOLLONESHOT一次性触发事件处理后自动移除。返回值成功0失败-1。关键点这里是用户告诉内核要关心这些fd的这些事件。epoll_event结构structepoll_event{uint32_tevents;// 位掩码EPOLLIN, EPOLLOUT, EPOLLET...epoll_data_tdata;// 联合体可放fd、指针等用户数据};events是你关心的/就绪的事件标志。data用于用户自定义数据通常放fd方便在epoll_wait返回后直接知道哪个fd就绪也可放自定义结构体指针。epoll_wait等待事件就绪并返回。intepoll_wait(intepfd,structepoll_event*events,intmaxevents,inttimeout);作用阻塞或超时等待直到有监控的fd发生event时内核将就绪的事件填充到events数组返回给用户。参数拆解epfdepoll实例句柄。events用户提供的结构体数组内核把就绪的fd和事件写入这里输出参数。maxevents数组最大容量必须0。timeout超时毫秒数-1表示永久阻塞0表示立即返回非阻塞轮询。返回值成功返回就绪的fd数量即填充到events数组的元素个数。超时返回0。失败返回-1。关键点这里是内核告诉用户哪些fd已经就绪了一次返回多个用户直接遍历events[0]到events[ret-1]即可。三者协作流程epoll_create() → 拿到 epfd ↓ epoll_ctl(ADD) → 注册要监控的 socket fd 和事件如 EPOLLIN ↓ epoll_wait() → 阻塞等待返回就绪的 fd 列表 ↓ 遍历 events 数组处理每个就绪的 fd ↓ 处理完后再次 epoll_wait 循环1.3 epoll的工作流程如上。创建实例epoll_create内核创建一个epoll对象并且内部会维护一棵红黑树rb_tree用于存储所有被监控的fd。以及一个就绪链表用于存储已就绪的fd。红黑树初始为空。注册事件epoll_ctl用户通过epoll_ctl把要监控的fd和关心的事件如EPOLLIN添加到红黑树中。每个节点包含fd、events关心的事件、回调函数指针等。等待事件epoll_wait当监控的fd上发生I/O事件时内核通过回调机制自动将该节点从红黑树激活并移动到就绪链表中(红黑树节点内嵌一个struct list_node成员回调触发时把该节点的list_node挂到就绪链表节点本身始终在红黑树中)。epoll_wait直接检查就绪链表是否为空非空 → 把就绪链表的节点复制到用户传入的events数组返回个数。为空 → 阻塞等待直到有事件触发回调。调用epoll_ctl控制红黑树中的节点红黑树是以fd作为键值的因为fd都是唯一的所以通过fd修改节点。等到红黑树中有节点就绪时就把红黑树节点结构中的list_node设置成自己的节点也就是把红黑树上的就绪节点链入到就绪链表中。这个过程时通过内核的回调机制完成的。调用epoll_wait就是遍历就绪链表这个链表中的所有fd的事件都是就绪的。这样的设计就避免了select、poll的需要不断拷贝fd数组的缺陷epoll的fd被管理到了内核中的红黑树中不需要在调用时做拷贝了。epoll 红黑树存全部增删改查O(logN) 就绪链表只存就绪取用O(1) 回调机制自动激活三个设计让注册与检测分离避免了select/poll的全量拷贝全量遍历两大痛点。epoll模型在Linux内核中就是eventpoll结构体红黑树里面的节点的结构体信息是epitem。如上节点结构体中包含红黑树就绪链表等信息。struct file结构体中有一个private_data指针指向eventpoll结构体。二、epoll版本Echo Server服务器2.1 基本框架和编写select、poll一样这里的日志模块Socket模块就直接拿之前的使用了。Main.ccintmain(intargc,char*argv[]){if(argc!2){std::cerrUsage:\n\t;std::cerrargv[0] portstd::endl;}uint16_tportstd::stoi(argv[1]);// 定义网络对象std::unique_ptrEpollServersvrstd::make_uniqueEpollServer(port);// 启动服务器svr-Loop();return0;}如上简单定义网络对象启动服务器。EpollServer.hppstaticconstintgport8080;staticconstintign_size256;staticconstintrevs_num64;// 本次调用最多能返回的就绪事件个数classEpollServer{public:EpollServer(uint16_tportgport):_port(port),_isquit(false),_epfd(-1),_listensock(std::make_uniqueTcpSocket()){// 1. 创建listensockfd_listensock-BuildTcpSocketMethod(_port);LOG(LogLevel::INFO)create listen socket success, sockfd is : _listensock-Sockfd();// 2. 创建Epoll模型_epfdepoll_create(ign_size);if(_epfd0){LOG(LogLevel::FATAL)create epoll error! return val: _epfd;return;}LOG(LogLevel::INFO)create epoll success, _epfd: _epfd;// 3. 把listenfd添加到epoll模型中让内核关心fd事件structepoll_eventev;ev.eventsEPOLLIN;// 关心读事件ev.data.fd_listensock-Sockfd();// 添加相关的fd在读取就绪链表之后就可以知道就绪的是哪个fdintnepoll_ctl(_epfd,EPOLL_CTL_ADD,_listensock-Sockfd(),ev);if(n0){LOG(LogLevel::INFO)add listen sock to epoll success...;}}voidLoop(){inttimeout-1;// 阻塞等待while(!_isquit){// 等待事件就绪并返回structepoll_eventrevs[revs_num];intnepoll_wait(_epfd,revs,revs_num,timeout);if(n0)// n 个 fd 就绪{LOG(LogLevel::DEBUG)Event Ready! n n;// 事件派发器// ...}elseif(n0)// 超时timeout设为非0时的模式才有{LOG(LogLevel::INFO)time out...;}else// 异常{LOG(LogLevel::ERROR)epoll_wait error;break;}}}~EpollServer(){}private:uint16_t_port;// 服务器端口号bool_isquit;// 标识服务器是否退出int_epfd;// epoll模型fdstd::unique_ptrSocket_listensock;// 监听socket};如上在构造函数中进行创建tcp socket并绑定监听然后调用epoll_create在内核中创建epoll模型并使用epoll_ctl添加listenfd到epoll模型中。在Loop接口中就是等待所有fd的就绪事件并返回这里的struct epoll_event revs[revs_num];中的revs_num是用户自己设定的数字标识本次最多能返回的就绪个数我设置的revs_num是64。如果就绪数量是20个会全部返回因为缓冲区足够如果就绪数量是100个那么只会返回前64个剩余的会等到下一次调用epoll_wait时再返回。如果存在就绪的就会交给事件派发器和select、poll编写时一致等下实现这个接口。现在运行测试:如上listenfd就绪之后疯狂打印事件就绪消息。2.2 补充其它接口接下来服务器确实的就是对就绪事件的处理工作。由于可能是listenfd上的事件就绪也可能是普通文件描述符上的事件就绪所以我们就需要有不同的处理逻辑和select、poll时的编写一样我们把它们分成两个不同的事件处理接口。然后由一个事件派发器把事件分类派发给这两个不同的事件处理接口。// 连接管理器voidListener(){InetAddr clientaddr;intnewsockfd_listensock-Accepter(clientaddr);if(newsockfd0)return;// 获取新链接成功LOG(LogLevel::INFO)accept success, new sockfd: newsockfd clientaddr.ToString();// 托管给epoll模型structepoll_eventev;ev.eventsEPOLLIN;// 关心读事件ev.data.fdnewsockfd;intnepoll_ctl(_epfd,EPOLL_CTL_ADD,newsockfd,ev);if(n0){LOG(LogLevel::INFO)add sock to epoll success, new sockfd: newsockfd;}}// IO 处理器voidIOService(intsockfd){charinbuffer[1024];ssize_t nrecv(sockfd,inbuffer,sizeof(inbuffer),0);if(n0){inbuffer[n]\0;LOG(LogLevel::INFO)client say #inbuffer;std::string echo_strserver echo #;echo_strinbuffer;send(sockfd,echo_str.c_str(),echo_str.size(),0);}elseif(n0)// 对方关闭连接{LOG(LogLevel::INFO)client quit, sockfd: sockfd;// 1. 不让epoll关心fdepoll_ctl(_epfd,EPOLL_CTL_DEL,sockfd,nullptr);// 2. 关闭fdclose(sockfd);}else{LOG(LogLevel::INFO)recv error, sockfd: sockfd;// 1. 不让epoll关心fdepoll_ctl(_epfd,EPOLL_CTL_DEL,sockfd,nullptr);// 2. 关闭fdclose(sockfd);}}voidDispatcher(structepoll_eventrevs[],intn){// 这个循环是必须的for(inti0;in;i){intsockfdrevs[i].data.fd;// 提取fduint32_treventsrevs[i].events;if(reventsEPOLLIN){// fd 就绪区分是listenfd还是普通fdif(sockfd_listensock-Sockfd())// listenfd{// 连接管理器Listener();}else// 普通fd{// IO 处理器IOService(sockfd);}}else{// TODO}}}如上就是事件派发器、连接管理器、IO处理器接口的编写。事件派发器在收到就绪队列之后需要进行遍历就绪队列这个遍历是必须的虽然也需要遍历但是它没有做任何浪费时间的事情因为每一个fd都是就绪的。而在select、poll的遍历中它是需要遍历所有的fd它会遍历很多没有就绪的fd这就造成了时间浪费。连接管理器获取新连接之后不能直接对fd进行读写操作因为需要让epoll模型执行等待事件就绪。我们关心的是fd上的读事件因为写事件默认是就绪的只有特殊情况才会设置。另外在IO处理器接口中如果n0的情况我们需要移除epoll对fd的关心还要关闭文件描述符。这里值得注意的是epoll_ctl想要删除对特定fd事件的关心它的前提是fd必须在系统中是合法的如果非法就会出错。epoll_ctl需要合法的fd描述符如果fd已被关闭内核会返回EBADF错误。所以这里必须先调用epoll_ctl删除fd再关闭fd。运行测试三、epoll的工作模式epoll存在两种工作模式/通知模式。分别是LT模式或ET模式。LT模式叫做水平触发(Level Triggered)ET模式叫做边缘触发(Edge Triggered)。工作模式的命名方式来自硬件电路LTLevel Triggered水平触发在电路中只要信号处于高电平就持续触发对应epoll只要缓冲区有数据就一直通知。ETEdge Triggered边缘触发在电路中仅在信号从低电平跳变到高电平的瞬间触发对应epoll仅在数据从无到有或增多时通知一次。epoll默认是处于LT模式下的。LT模式是一个fd的事件就绪比如关心的是读事件只要fd的接受缓冲区有数据在LT模式下就会一直通知用户数据就绪了。也就是只要缓冲区有数据每次epoll_wait都会返回该fd。ET模式是一个fd的事件就绪比如关心的是读事件只有接受缓冲区的数据从无到有或者数据增多的时候ET才会通知用户读取而且只会通知一次。一直通知和只通知一次的区别LT模式(一直通知)epoll_wait返回时会检查每个被关注fd的就绪队列只要fd的读缓冲区非空或写缓冲区未满该fd就会被放入就绪队列epoll_wait每次返回前都会重新扫描所有fd的就绪状态如果fd仍就绪下次epoll_wait会再次返回。也就是一直激活。ET模式(只通知一次)内核为每个fd维护一个标志位记录是否已通知当事件状态发生变化时如空→非空内核才设置标志位并放入就绪队列epoll_wait返回后内核清除该fd的就绪标志除非状态再次变化否则该fd不会再次放入就绪队列。也就是只激活一次所以ET模式下必须一次性读完数据否则会丢失事件通知。处于ET模式下就必须把本轮数据全部读完所以就需要循环读取。但是循环读取会有一个问题如果你每次读取100字节数据缓冲区中是200字节的数据你读了两次发现都读满了你肯定要读第三次因为你不知道缓冲区中是否还有数据可是第三次读取时被阻塞住了epoll的整个代码都是单进程的可不敢阻塞所以就必须设置fd是非阻塞模式ET的本质是让用户尽快取走数据如果是tcp通信这就可以给发送方通告一个更大的接受窗口所以这就有概率提高网络的吞吐量LT模式和ET模式相比ET模式的效率更高因为ET模式的无效通知少所以它的通知效率高。处于ET模式可以在一定概率上提高网络吞吐量。LT模式下采用循环读非阻塞的方式也能实现ET模式为什么要存在ET模式能做到和必须这么做不是一回事处于ET模式下能百分百保证都采用循环读非阻塞的方式。LT和ET的使用场景LT水平触发默认选择适合绝大多数场景编程简单、安全不易出错。ET边缘触发高并发、大流量、追求极致性能时使用需配合非阻塞fd和循环读写编程复杂。总结以上就是本期博客分享的全部内容啦如果觉得文章还不错的话可以三连支持一下你的支持就是我前进最大的动力技术的探索永无止境! 道阻且长行则将至后续我会给大家带来更多优质博客内容欢迎关注我的CSDN账号我们一同成长(▽)