ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

epoll高并发原理与内核设计:从select到红黑树、就绪链表与LT/ET实战

epoll高并发原理与内核设计:从select到红黑树、就绪链表与LT/ET实战 我写网络服务这么多年最常被问到的题目之一就是“epoll到底是怎么做到高并发的”。很多朋友学到epoll的时候已经从accept阻塞模型走到了select、poll发现它们的瓶颈一个比一个明显然后听到epoll“高效”、“大规模连接”这些宣传语却始终搞不清楚它究竟高效在哪里。这篇文章直接照着“多路转接epoll”这个主题往下挖从它要解决的问题、内核的数据结构设计、事件触发模式到实际编码里那些踩过的坑一次性串完。适合正在学习网络编程的后端开发也适合那些服务一上并发就CPU飙高、却找不到原因的运维和业务研发。先说明白一件事epoll是Linux内核提供给用户态的高并发网络事件通知机制它不是某种协议也不是一个库而是三个系统调用配合一套数据结构。理解它比背住几个API重要得多。1. 从阻塞IO到多路转接select和poll到底哪里不够用1.1 阻塞模型的天花板网络服务最基本的需求是处理多个客户端的连接和数据收发。最原始的做法是accept阻塞在监听套接字上一个连接来了就建立一个线程这个线程在recv上阻塞等待数据。这样做的问题是线程数等于连接数时线程上下文切换、内存占用、内核调度开销都会跟着线性的增长。几千个连接同时在线时光是切换线程就能占掉大量CPU时间片真正处理业务逻辑的CPU所剩无几。于是多路转接的思路出现了与其一个线程盯着一个连接不如一个线程盯着所有连接。select和poll就是这个思路下的产物。它们把“我需要关注哪些socket、关注什么事件”告诉内核然后内核负责把这些socket的状态变化告知用户态。逻辑听着挺顺实现上却有两个致命瓶颈。1.2 select的fd_set位图与1024限制select的核心数据结构是fd_set可以理解为一张位图每一位对应一个文件描述符。这种位图结构简单但受到了FD_SETSIZE的限制默认是1024。也就是说select最多只能同时监视1024个fd。在高并发场景下这个上限是没法妥协的。你当然可以重新编译内核或修改这个宏但那等于每次升级环境都要给自己挖坑生产环境不可能这么干。poll用pollfd数组替代了fd_set理论上不存在1024的限制但它的底层逻辑还是“把所有要监视的fd传给内核内核线性扫描返回后用户态再线性扫描”。每次调用都有两次全量遍历内核态扫描全部fd的驱动事件状态用户态扫描返回的数组找出就绪的fd。连接数到了几千每次事件轮询的开销就是O(n)级别的而且这个n是全部监听fd的总数不是就绪fd的个数。1.3 用户态和内核态之间的数据拷贝开销select和poll还有一个隐藏成本每次调用都需要把完整的fd集合从用户态拷贝到内核态处理完再拷贝回来。连接数一多这个拷贝消耗的内存带宽和CPU周期非常可观。为了检查“有没有事件”你需要为每一次调用付一次全量拷贝的账哪怕大多数fd根本没有变化。这就像你每天要检查一整栋楼里所有房间的“需要服务”灯亮没亮于是你把每间房的门牌号全部抄一遍递给管理员管理员挨个跑一遍再回来告诉你结果然后你自己还得挨个核对一遍。房间里明明没变化你也得重复这个流程。epoll的出现就是对这个流程的一次彻底重构。2. epoll内核设计拆解红黑树、就绪链表与回调机制的结合2.1 三个系统调用分工异常明确epoll的核心实现围绕三个系统调用展开epoll_create或者epoll_create1在内核中创建一个epoll实例返回一个文件描述符。epoll_ctl向这个实例注册、修改、删除某个fd及其关注的事件。epoll_wait等待已经就绪的事件并返回。关键在于epoll_ctl。它把你在用户态定好的关心列表真正落到了内核的数据结构里而不是像select那样每次轮询时重新传一遍。注册一次长期生效。这个“注册”动作把“全量拷贝”变成了“增量更新”。2.2 为什么偏偏是红黑树内核在每个epoll实例里建了一棵红黑树树上的每个节点对应一个注册进去的fd。红黑树不是随便选的它能在O(log n)的时间复杂度内完成fd的增、删、查。fd的数值本身可以作为key来比较大小天然适合树结构。相比之下哈希表虽然查询O(1)但在内核这种环境里处理冲突、动态扩容都比较复杂红黑树的性能更稳定、实现也更成熟。epoll_ctl调用过来时内核先在红黑树里查找这个fd是否已经存在。存在就更新事件不存在就插入。这个查找、插入过程与总连接数成对数关系性能不会像线性扫描那样随连接数增多而崩坏。2.3 就绪链表与回调机制O(1)的真相红黑树解决的是“如何快速管理海量fd”的问题。但真正让epoll高效的是另一个设计就绪链表ready list和回调callback。每个被注册的fd在它的底层驱动层会挂一个等待队列项这个等待队列项里的回调函数被设置成ep_poll_callback。当socket上有数据到达、连接建立、或者出错时内核驱动的中断处理流程会调用这个回调函数把当前fd自动添加到epoll实例的的就绪链表中。这样epoll_wait要做的事情就简单得多检查就绪链表是否为空不为空就从链表头取出事件并拷贝到用户态的events数组。认真讲内核态不在需要扫描全量fd来“找”就绪事件了。就绪事件是事件发生的那一瞬间被回调函数主动挂到链表上的而不是等到你调用epoll_wait时再去全量检索。从这个角度看epoll处理就绪事件的时间复杂度是O(k)k是就绪事件数量而select和poll是O(n)n是全部fd数量。在连接多、活跃少的场景下这个差距是天壤之别。2.4 对比表select/poll/epoll的差异维度selectpollepoll数据结构位图fd_setpollfd数组红黑树 就绪链表fd上限FD_SETSIZE默认1024无上限受内存限制无上限受内存限制用户态到内核态拷贝每次全量拷贝每次全量拷贝epoll_ctl注册一次增量更新查找就绪事件方式内核线性扫描用户态再次扫描内核线性扫描用户态再次扫描回调机制就绪事件主动挂链时间复杂度O(n)O(n)管理O(log n)取就绪O(k)水平触发/边缘触发仅水平触发仅水平触发支持LT和ET3. LT与ET的深入对比水平触发和边缘触发该如何选3.1 一句话理解LT和ET水平触发Level TriggeredLT是默认模式。只要fd的读缓冲区里有数据epoll_wait就会一直返回这个fd的EPOLLIN事件直到你把它读完。边缘触发Edge TriggeredET只会在状态变化的那一刻通知一次也就是“从无数据到有数据”的那一次跳变。如果你没读完它不会因为残留数据而再次通知你。打个更直白的比方LT像一个热心前台只要电梯门口有人等着她每隔几分钟就来告诉你一次ET像一个高冷前台看到有人来了就只说一次你说“知道了”她就不再提醒哪怕人还站在门外。3.2 ET模式为什么必须配非阻塞IO这是新手最容易踩的坑。ET模式下epoll_wait接收到EPOLLIN通知后你需要历次循环read直到返回EAGAIN否则很可能漏掉一部分数据。可read如果阻塞在socket上就没有“返回EAGAIN”这回事。缓冲区没有数据时阻塞read会一直睡眠等到新数据到来。这样就坏事了读完之后代码卡在最后一次read上后续其他fd的事件全都得不到处理整个事件循环被一个连接拖死。所以用ET模式的epoll每一个加入监视的socket都必须设置成非阻塞。read循环读到返回-1并且errno是EAGAIN或EWOULDBLOCK才代表本次数据已经全部读完。没有这一句判断ET就是个定时炸弹。3.3 真实场景演示读一个TCP消息假设对端发送了100字节数据本地读缓冲区有100字节。LT模式下即使你只read了50字节缓冲区还剩50字节内核状态依然视为“可读”下一次epoll_wait还会返回EPOLLIN。ET模式下状态变化只发生在那100字节从无到有的那一刻如果你只读了50字节就退出去剩余50字节要等到对端再次发送数据才会触发新的事件。这就是ET丢数据的经典场景。ET的正确读法如下while (1) { ssize_t n read(fd, buf, sizeof(buf)); if (n -1) { if (errno EAGAIN || errno EWOULDBLOCK) { break; // 数据全部读完 } // 真正的错误 break; } if (n 0) { // 对端关闭 close(fd); break; } // 处理buf里的n字节 }3.4 优化的直觉到底选LT还是ET很多文章把ET说得神乎其神好像不用ET就不配谈高并发。我的观点是ET确实可以减少系统调用次数但它不是性能的全部。LT模式的应用层代码更简单、更不容易丢数据适合大多数场景。ET的优势在于减少事件通知的重复唤醒如果每个连接的数据量都很大、且处理时间较长ET能有效避免频繁的无效epoll_wait返回。生产环境的真实选择往往取决于数据特征和业务逻辑复杂度。我见过只用LT就把单机连接数做到百万级的网关服务也见过ET模式下因为一个read循环不完整导致线上数据丢失的惨案。先保证正确性和可维护性再谈优化。4. 从零拼装一个基于epoll的事件循环4.1 服务端骨架监听、注册、分发下面这个例子是一个最精简的TCP服务端骨架用epoll的LT模式支持多个客户端连接。代码本身可以编译运行重点看它的结构而不是工程化细节。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include fcntl.h #include errno.h #include sys/socket.h #include netinet/in.h #include sys/epoll.h #include arpa/inet.h #define MAX_EVENTS 64 #define PORT 9000 int set_nonblock(int fd) { int flags fcntl(fd, F_GETFL, 0); if (flags -1) return -1; return fcntl(fd, F_SETFL, flags | O_NONBLOCK); } int main() { int listen_fd socket(AF_INET, SOCK_STREAM, 0); if (listen_fd -1) { perror(socket); return 1; } int reuse 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, reuse, sizeof(reuse)); set_nonblock(listen_fd); struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); addr.sin_port htons(PORT); if (bind(listen_fd, (struct sockaddr *)addr, sizeof(addr)) -1) { perror(bind); return 1; } if (listen(listen_fd, 128) -1) { perror(listen); return 1; } int epfd epoll_create1(0); if (epfd -1) { perror(epoll_create1); return 1; } struct epoll_event ev; ev.events EPOLLIN; // 监听可读事件监听socket的可读意味着有新连接 ev.data.fd listen_fd; if (epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, ev) -1) { perror(epoll_ctl: listen_fd); return 1; } struct epoll_event events[MAX_EVENTS]; printf(echo server listening on port %d\n, PORT); while (1) { int nfds epoll_wait(epfd, events, MAX_EVENTS, -1); if (nfds -1) { perror(epoll_wait); break; } for (int i 0; i nfds; i) { int fd events[i].data.fd; if (fd listen_fd) { // 新连接到来 while (1) { int conn_fd accept(listen_fd, NULL, NULL); if (conn_fd -1) { if (errno EAGAIN || errno EWOULDBLOCK) { // 当前没有更多待accept的链接退出accept循环 break; } perror(accept); break; } set_nonblock(conn_fd); ev.events EPOLLIN | EPOLLRDHUP; ev.data.fd conn_fd; if (epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, ev) -1) { perror(epoll_ctl: conn_fd); close(conn_fd); } } } else { if (events[i].events (EPOLLERR | EPOLLHUP | EPOLLRDHUP)) { // 连接出错或对端关闭 close(fd); continue; } char buf[4096]; ssize_t n read(fd, buf, sizeof(buf)); if (n 0) { // 简单回显实际业务里在这里处理数据 write(fd, buf, n); } else if (n 0) { close(fd); } else { if (errno ! EAGAIN errno ! EWOULDBLOCK) { close(fd); } } } } } close(epfd); close(listen_fd); return 0; }4.2 关键几点必须讲透epoll_create1(0)和epoll_create(0)功能类似前者是更现代的方式。返回的这个epfd本身就是一个文件描述符要注意别和业务fd混在一起。事件分发时通过events[i].data.fd来区分是监听fd还是普通连接fd。data是一个联合体你可以放fd也可以放指针。更成熟的写法是放一个结构体指针data.ptr指向连接对象里面不仅存fd还存收发缓冲区、连接状态等。这样事件循环的扩展性会强很多。accept在LT模式下不需要写while循环因为epoll_wait只要发现监听socket可读就说明存在待accept的连接。但我这里仍然写了while EAGAIN的退出条件原因是我把listen_fd设成了非阻塞。这样一个连接到达时会触发EPOLLIN而我可以一次把所有pending的连接都accept完减少系统调用次数。4.3 EPOLLIN、EPOLLOUT和其他事件位EPOLLINfd可读TCP连接场景下包括新数据到达、对端关闭连接时读到0。EPOLLOUTfd可写。发缓冲区有空余空间时触发。这个事件位用不好会引发CPU忙等后面细说。EPOLLRDHUP对端关闭连接或半关闭。在HTTP服务里经常用来及时感知对端断开比通过read返回0再去处理更快也能避免不必要的数据写入。EPOLLERRfd发生错误比如TCP的RST包。EPOLLHUP挂起。通常代表对端异常关闭。在我的实践中注册事件时习惯把EPOLLRDHUP一并加上这样能提前清理掉已经断开的连接避免它们占着红黑树里的节点继续参与事件轮询。4.4 主循环必须考虑的问题epoll_wait的第三个参数是超时时间。填-1表示永久阻塞直到有事件到来才返回填0表示立即返回即使没有事件填正数则是毫秒级超时。事件循环里如果只有网络事件用-1阻塞没问题。但如果同一进程还需要处理定时任务、心跳检测就不能一直阻塞在epoll_wait上。常见解法是传入一个合适的timeout比如50ms或100ms把epoll_wait当成一个可中断的定时器来用。当超时返回且没有事件时就顺手把定时器任务跑一遍。4.5 EPOLLOUT的正确用法按需注册而不是常驻监听前面提到epoll_wait会持续返回EPOLLIN前提是你没把数据读完。EPOLLOUT同样有这个问题大多数时候socket的发送缓冲区都是有空间的如果你在连接建立时就注册了EPOLLOUT内核会认为“可写”这个条件始终满足epoll_wait会不停返回这个fd的EPOLLOUT事件造成CPU忙等。正确用法是在业务线程需要发送数据时先尝试write如果write返回-1且errno是EAGAIN说明发送缓冲区已经满了此时才把EPOLLOUT注册到epoll里等内核告诉你“缓冲区有空间了”再去把剩余数据发出去。发完之后立刻把EPOLLOUT从事件集合中删除EPOLL_CTL_MOD避免事件反复触发。这个“按需注册”的思路是epoll编程里很重要的一条经验。5. 实战踩坑清单ET模式的边界与高并发下的系统陷阱5.1 ET模式下漏读根子在于没有读完整ET模式下数据到达只通知一次。很多人以为把read放进while(EAGAIN)循环就完事了但在多线程处理模式下如果读取和业务处理不在同一个线程读完的缓冲区可能还没有被消费完而其他线程又在往同一个缓冲区塞新数据这时ET事件的“边缘”就已经错过了。谨慎的做法是把“读取socket”和“解析业务数据”拆开socket读到应用层缓冲区后立即返回业务数据解析放在另外的调度单元里这样ET通知的边缘只负责“从内核缓冲区搬到用户缓冲区”不负责“业务处理”语义就清晰很多。5.2 连接风暴导致的accept惊群多进程/多线程模型下多个线程都调用了epoll_wait在同一个epfd上等待或者各自有epfd但监听同一个listen_fd。当新连接到达时内核可能唤醒多个等待线程但实际只有一个线程能successfully accept其他线程空手而归。这个现象叫惊群效应。Linux 2.6之后epoll_wait本身对同一epfd的唤醒做了处理但不同epfd监听同一fd时仍可能惊群。高并发网关里常见的对策是用SO_REUSEPORT让多个进程各自bind同一个端口内核做负载均衡或者用EPOLLEXCLUSIVE事件标志告诉内核在唤醒时只选择一个等待线程。这些是架构层面的取舍不一而足。5.3 timeout参数不是摆设有些人在事件循环里把timeout填成0本来想做非阻塞轮询结果CPU被打满。因为没有事件时epoll_wait立即返回外层while循环空转白白烧CPU时间。另一种情况是填成-1导致进程无法响应定时任务和信号。更合理的方式是-1用于纯网络事件循环正数用于混合场景。我自己常写100ms这个值不算太敏感也不会造成明显延迟。5.4 连接fd泄漏与红黑树残留连接关闭时如果忘记把fd从epoll实例中删除EPOLL_CTL_DEL内核红黑树里会残留这个节点。由于fd已经被关闭epoll_wait不会再返回它的事件但它仍然占着红黑树的空间。连接频繁建立关闭积累下来就是内核内存泄漏。关闭fd前顺手epoll_ctl删除一下或者依赖close(fd)时内核自动清理与epoll实例的关联都能避免这个问题。不过实践经验告诉我显式删除的代码可读性更好别人review时能一眼看出这个连接的生命周期边界在哪里。5.5 EPOLLONESHOT的使用场景EPOLLONESHOT的意思是这个fd的事件触发一次后自动从epoll监听集合中摘除需要再次手工EPOLL_CTL_MOD才能重新注册。它主要用在“同一时间只允许一个线程处理这个fd”的场景。比如一个连接的数据较长由一个工作线程慢慢处理期间不希望其他线程因为这个fd又可读而再次被epoll_wait唤醒。TCP服务端从accept拿到连接后给该fd注册EPOLLIN | EPOLLONESHOT处理完业务、还没读完数据时再重新EPOLL_CTL_MOD添加EPOLLIN就实现了连接和线程的绑定关系。这个机制天然防止了同一连接被并发读写。5.6 高并发下多线程epoll的结构建议单线程epoll可以做到很高的性能但业务处理耗时一旦上去就会阻塞事件分发。多线程结构常见有两种一是主线程只做accept把新连接分发到多个worker线程每个worker线程独立epoll_wait处理自己的连接集合二是多个线程共享同一个epfd通过mutex保护epoll_ctl。前者通常更干净因为每个连接只属于一个线程不会出现并发操作同一fd的问题。我维护过一个网关服务就是用“主线程accept 线程池按hash分发”的模型把单机并发连接数稳定压在几十万级别。6. 一些数据结构层面的思考以及epoll的适用边界6.1 用户态也需要自己的“事件上下文”epoll只负责内核态的事件通知它不帮你维护应用层的状态机。每个连接的收发缓冲区、当前解析到协议头的哪个字段、是否需要回复心跳这些都要在用户态自己组织。连接量大时用户态基于fd做索引的哈希表、二叉搜索树或者开数组直接索引就成了必不可少的配套结构。我在代码里经常用一块连续内存池按fd作为下标来管理连接对象配合epoll_event中的data.ptr指向这个连接对象既省内存又很快。6.2 epoll不一定会干掉一切传输优化epoll高效的前提是“大量空闲连接 少数活跃连接”这种工作负载。如果一个连接几乎持续有数据要处理那epoll并不比一个线程一个连接的阻塞模型快多少因为事件通知的频率趋近于数据处理的频率。这时瓶颈反而在业务处理、内存拷贝和锁争用上。所以设计高并发服务的时候不要只盯着epoll。应用层协议设计的合理与否、零拷贝比如sendfile、内存池、多线程调度每一项都可能比单纯调优epoll带来更大的提升。6.3 io_uring的兴起对epoll地位的影响近几年的Linux内核版本里io_uring作为新的异步IO框架热度很高。它通过共享内存环形队列提交和收割IO请求减少了系统调用次数和内存拷贝。有人问io_uring会不会取代epoll。我的看法是对于网络事件通知这种模式epoll的架构仍然成熟稳定短期内不会被替换io_uring更擅长的是文件IO和自定义异步任务。长远的趋势一定是io_uring逐渐覆盖更多场景但epoll作为“同步事件通知”的基本范式在未来很长一段时间里依然是网络编程的必修课。7. 最后的实操建议动手前先厘清需求多路转接epoll这件事不是把API背下来就完了。我自己的体会是至少要亲手写一个完整的echo server然后把select实现、poll实现、epoll实现各做一版用压测工具对比它们在连接数、吞吐量、CPU占用上的差异远比看十篇源码解析更能内化。压测时注意处理客户端的TIME_WAIT状态、监听队列长度这些外部变量不然很容易得出偏差很大的结论。如果只是实现一个中小规模的网关服务LT模式配非阻塞fd已经非常稳如果对单机性能极致敏感再上ET模式。ET模式绝不等于“性能银弹”它带来的复杂度需要足够的测试覆盖来兜底。最后分享一个写epoll服务时很有用的习惯每次epoll_wait返回后先按事件类型分类统计一次比如新增连接多少个、可读事件多少个、异常关闭多少个把它们打到日志或监控系统里。线上抖动时这些数据能一瞬间帮你定位问题是出在accept风暴、读写热点还是连接异常而不是只看到一个CPU飙高在那里瞎猜。
返回列表