写网络服务的人,早晚都会撞上同一个问题:连接数一上来,CPU没满、内存也没满,但服务就是卡死了。我第一次被这个问题困住是在一次模拟高并发的压测里,单线程阻塞模型下,一个新连接的accept都得排队等前面那一次read返回,整个服务像堵了车的十字路口,吞吐量惨不忍睹。后来真正从原理层面搞懂epoll的工作方式,才意识到问题从来不在"处理得慢",而在"等得蠢"——我们一直在用一种让线程干等、或者让CPU空转的姿势去管理一大堆socket。这篇文章想把这些东西一次说透:从阻塞IO的根源讲到多路复用的演进,再进内核看epoll的红黑树和回调机制,最后落到事件循环骨架和一堆实战里才踩得到的坑。适合正在写高并发服务的开发者,也适合那些已经把epoll用起来、但遇到诡异问题只能靠猜的人。
1. 一次read()背后:阻塞、非阻塞与"等"的本质差异
1.1 阻塞IO的天花板不在性能,在线程模型
很多人初学网络编程用的都是最朴素的模型:主线程accept,每个连接开一个线程去read、write。单个连接的时候一切正常,read()没有数据就睡在那里,内核把CPU让给别人,看起来非常优雅。但连接数一多,问题就全来了。
首先是线程资源。每个线程默认栈空间就有8MB的虚拟内存,再加上内核里的task_struct、thread_info等一堆结构,一万个连接就是一万个线程。你可以算一笔账:8MB乘以10000,光是虚拟地址空间就是80GB的规模,即使物理内存没吃满,线程切换的开销也足够让CPU忙于搬上下文而不是干活。上下文切换一次的成本是微秒级,但一秒钟发生十万次切换,系统时间就全耗在这里了。
其次是阻塞的本质问题。阻塞IO的read()意味着"我要等数据来",这个等待期间线程是睡着的,不占CPU,但线程本身占着内存和内核资源。更尴尬的是,你明知道10000个连接里常常只有零星几个有数据,却必须为所有可能到来的连接都准备一个线程,这是一种非常彻底的浪费——大多数线程都在空睡,占着资源却不产生价值。
1.2 非阻塞IO的另一笔账:轮询会烧CPU
既然阻塞太浪费,那就把socket设成非阻塞。O_NONBLOCK标志一加,read()没数据时立刻返回EAGAIN,线程不会被挂起。环境变了,模型也得跟着变:你得主动去打探每个socket有没有数据,比如用一个循环把所有fd都问一遍。
这就是轮询。问题也很明显:复杂度是O(n),n是连接总数。每次都要从头到尾遍历所有socket,绝大多数都没数据,但你还是得一个一个调read去试探。连接少的时候无所谓,连接从几百涨到几万,你会发现CPU时间几乎全耗在"反复确认没有数据"这件事上,真正干活的时间占比低得可怜。
所以网络编程序列战中真正缺的不是"能不能等得起",也不是"能不能不等",而是一个中间层:让内核告诉我们哪些fd准备好了。这就是多路复用出现的原因,也是select、poll、epoll这一整条演进路线的起点。
2. 多路复用前传:select和poll到底差在哪
2.1 select的三重限制:数量、拷贝、反复协商
select是很多人接触的第一个多路复用接口。它把fd集合放在一个位图fd_set里,通过FD_SET、FD_ISSET这些宏来操作。select的问题有三个,任何一个在高并发下都是硬伤。
第一是fd数量上限。fd_set位图大小由FD_SETSIZE决定,通常就是1024。哪怕你的服务器完全可以承受5万连接,select在这里也只能管前1024个fd,剩下的一概视而不见。有人会说可以改宏重编内核,但实际上多数发行版里这个上限就焊死在1024附近,改起来牵扯面极大。
第二是每次调用都要把整个fd_set从用户态拷贝到内核态,内核扫描完所有fd的等待队列,再把结果拷回来。这个拷贝和扫描的开销是O(n)的,而且n再大它也是全量扫描一遍——哪怕只有一个fd可读,也要把所有fd查一圈。
第三是fd_set在select内部会被内核改写,你调用一次之后必须重新把所有感兴趣的fd设进去,否则下次就没得查了。加上它还要维护读、写、异常三个集合,代码写起来又啰嗦又容易错。
2.2 poll的改进与没改进:数量解锁,复杂度没解锁
poll换了一种数据结构:不再用位图,而是用pollfd数组(fd、events、revents三个字段)。它把"我关心哪些事件"和"内核告诉我哪些事件就绪"分开了,fd数量上限也解除了。每次调用时你把数组传进去,内核填好revents再返回,你遍历一次数组处理就绪事件。
但你要注意,poll本质上没有逃出select的宿命:每次调用,仍然要把整个fd数组从用户态拷进内核,内核仍然要逐个fd去检查状态。复杂度依然是O(n),只是n的上限变大了。你说"我保持这10000个fd的监听状态不变,下次别让我再传了",poll做不到,它每次都是无状态的全量查询。
所以select和poll共享一个根本矛盾:兴趣集合在两次调用之间根本没变,但你每次都得重新提交、重新扫描。这就好比每次去食堂打饭都要把菜单重新抄一遍递给厨师,哪怕你每天吃的都是同一道菜。我们真正需要的,是让内核把"关心哪些fd"这件事记下来,然后只用O(就绪数量)的成本告诉我们"谁来了"。
3. epoll三件套:用法骨架与容易被忽视的参数细节
3.1 epoll_create1的三个理由
epoll的第一步是创建一个实例。老代码里经常看到epoll_create(int size),这个size参数在Linux 2.6.8之后就被内核忽略了,它不会真的提前分配这么多fd的空间,只是历史遗留的约定,传个大于0的数就行。我更推荐直接用epoll_create1,理由有三个:
一是语义干净,只需要传flags,不再需要那个骗人的size参数;二是可以传EPOLL_CLOEXEC,新内核里正确的用法。EPOLL_CLOEXEC意味着这个fd在exec执行新程序时会自动关闭,避免子进程继承一个用不到的epoll句柄,这是安全实践上一个很经典的fd泄漏隐患;三是少一层历史包袱,代码读起来意图明确。
需要注意epoll_create1返回的fd本身需要被close,它不是普通socket,你关了它,整个就绪队列和红黑树都会被回收。如果服务里做fork模型,子进程千万不要随手close父进程的epfd,否则父进程的事件通知就全断了——这种问题极难排查。
3.2 epoll_ctl的ADD、MOD、DEL与事件掩码
事件注册走epoll_ctl,核心参数是op和event。ADD是把fd挂进去,重复ADD同一个fd会返回EEXIST;MOD是修改已有fd的关注事件;DEL是摘除。三个操作各有各的坑。
ADD时要填epoll_event结构,events是位掩码,data是个union。新手最容易踩的坑是把data.fd直接当唯一信息,但实际开发里data应该存你自己上下文对象的指针,比如conn_t *,里面包含fd、读缓冲、写缓冲、状态机字段。内核只把这个值原样返给你,你不利用它,就要拿fd再查一遍自己的哈希表,白白多一次查找。
事件掩码里除了常见的EPOLLIN、EPOLLOUT,我强烈建议关注EPOLLRDHUP。这是内核专门为"对端关闭连接"准备的信号,比靠读EPOLLIN后read返回0来判断EOF要更直接。它跟EPOLLIN是独立的,表示的是"对端挂了或半关闭",很多线上问题就是没监听它,导致对端close了你这里毫无反应,连接变成半死不活的僵尸。
再提醒一个细节:MOD不是累加。你ADD时注册了EPOLLIN,后来想再关心EPOLLOUT,MOD的events必须写EPOLLIN | EPOLLOUT,而不是只写EPOLLOUT。否则就等于把EPOLLIN覆盖掉了。所以很多人会在自己的上下文里保存一份current_events,每次改动前先按需拼好整个掩码。
3.3 epoll_wait的timeout与maxevents博弈
epoll_wait的timeout参数有三种写法:-1表示无限等待,直到有事件才返回;0表示立即返回,不管有没有事件都回来;正整数表示最多等多少毫秒。线程池模型里,很多框架会把timeout设成几十毫秒,为了让线程有机会醒来处理定时器任务,而不是纯靠事件唤醒。如果你的服务只有纯粹的事件驱动,没有定时器,用-1反而能减少无谓的系统调用。
maxevents则是本轮最多返回多少个事件。这个值必须小于等于你传入的events数组长度,否则数组越界。很多人把这个值填得很小比如16,然后发现高并发下epoll_wait频繁被唤醒、批量吞吐差;又有人填得很大比如4096,但数组分配得跟上。我的建议是数组按你单次合理处理上限分配,通常512到1024比较平衡,太大反而在栈上占空间,堆上也得提前初始化。
骨架代码长这样:
#include <sys/epoll.h> #include <unistd.h> #include <stdio.h> #include <errno.h> #define MAX_EVENTS 64 int main(void) { int epfd = epoll_create1(EPOLL_CLOEXEC); if (epfd < 0) { perror("epoll_create1"); return 1; } struct epoll_event ev; ev.events = EPOLLIN; ev.data.fd = 0; // 这里先用标准输入示意 if (epoll_ctl(epfd, EPOLL_CTL_ADD, 0, &ev) < 0) { perror("epoll_ctl"); return 1; } struct epoll_event events[MAX_EVENTS]; for (;;) { int n = epoll_wait(epfd, events, MAX_EVENTS, -1); if (n < 0) { if (errno == EINTR) continue; perror("epoll_wait"); break; } for (int i = 0; i < n; i++) { char buf[256]; ssize_t len = read(events[i].data.fd, buf, sizeof(buf)); if (len > 0) { write(1, buf, len); } } } close(epfd); return 0; }这段代码只是演示流程,真正的服务器还要处理EAGAIN、对端关闭、EPOLLOUT注册时机等一堆事,这些放到后面细说。
4. 内核里的真相:红黑树、就绪链表与回调唤醒
4.1 红黑树:把注册这件事从O(n)降到O(logn)
很多人对epoll的认识停留在"它很快,是O(1)的",但O(1)到底指什么,未必说得清楚。要讲明白就得进内核。每个epoll实例在内核里是一个struct eventpoll,里面躺着两个核心结构:一棵红黑树和一个就绪链表。
红黑树管的是"你关心哪些fd"。当epoll_ctl执行ADD时,内核会分配一个epitem节点,以fd为key插入红黑树。为什么是红黑树而不是哈希表或者普通链表?链表在增删上确实是O(1),但查找是O(n);哈希表查找是O(1),但需要考虑扩容、冲突,而且内核里fd的增删规模难预测。红黑树是平衡二叉搜索树,查找、插入、删除都是O(logn),内存开销可控、行为稳定,还能天然地按fd有序遍历,对内核这种追求确定性多于峰值的场景非常合适。
红黑树意味着EPOLL_CTL_MOD不是直接改一个数组下标就完事,它要先在树里找到这个epitem,再修改事件掩码。性能虽好,但也不是零成本。所以实际编码里有个约定俗成的优化:能用一次MOD就绝不用DEL+ADD,后者是两次O(logn)加一次节点分配释放,前者只是一次查找加字段修改。
4.2 ep_poll_callback:epoll为什么不需要扫描全部fd
接下来是关键。select/poll每次调用都要遍历所有fd去问"你好了吗",epoll不干这种事,它反过来——在每个被监听的fd自己身上挂了一个回调函数。
具体流程是:ADD之后,内核会调用fd对应的poll方法,把epitem挂到这个fd的等待队列上,同时注册ep_poll_callback。当这个fd真的来数据时(比如TCP收到报文、数据写进接收缓冲区),协议栈在唤醒等待队列时就会执行这个回调。回调干的事很直接:把自己这个epitem追加到eventpoll的就绪链表尾巴上,然后看看有没有线程正在epoll_wait里睡,有的话把它唤醒。
所以"事件从发生到被用户感知"的路径是:网卡中断 -> 协议栈收包 -> 唤醒socket等待队列 -> 回调把epitem挂到就绪链表 -> 唤醒epoll_wait。整个链条里没有一个环节是全量扫描的,复杂度只跟"有多少fd就绪"相关,跟"一共注册了多少fd"无关。这才是epoll号称O(1)的真正含义。
4.3 就绪链表上的LT与ET差异从哪来
epoll_wait返回时,内核会把就绪链表上的epitem逐个取出,填充到用户传入的events数组里。但取完之后,这个epitem是留在链表上还是踢出去,取决于触发模式——这正是LT和ET在实现层面的分水岭。
水平触发(LT)下,取走事件之后,如果这个fd的状态仍然处于"就绪"(比如缓冲区里还有数据没读完),内核会把epitem重新放回就绪链表,或者干脆不摘出去,所以下次epoll_wait还会再报一次。边沿触发(ET)下,取走事件之后直接从链表摘除,下次只有再发生一次新的状态变化(比如缓冲区从空变成非空、或者又有新数据写入)才会再次触发回调、再次挂入链表。
这就解释了一个很常见的困惑:为什么ET模式下你只读了一部分数据,之后即使没读完,epoll也不再通知你。因为通知的是一次"边沿变化",而不是持续状态。理解了这个内核行为,你就明白为什么ET模式下"读到EAGAIN为止"不是风格问题,而是正确性问题。
5. LT还是ET:两种触发语义与实战选型
5.1 水平触发:像闹钟,反复提醒你还有事没做完
LT的语义和select、poll完全一致:只要fd处于可读状态,每次epoll_wait都会返回它。这对开发者非常友好,逻辑简单——你可以任意分多次读,读不完下次接着读,内核会继续通知你。
LT模式下甚至可以用阻塞fd,因为即使没数据也不影响你的事件分发逻辑。但我不建议贪这个方便,主流写法仍然是所有socket一律设非阻塞。原因很简单:一个连接同时被事件驱动和阻塞读写混用,一旦某个分支忘了解除阻塞,整个事件循环就会被卡死,这类bug比ET漏读还难查。
5.2 边沿触发:像门铃,只在你按下那一刻响
ET只在状态变化时通知一次:缓冲区从空到有数据、或者对端发来新的报文,是一次"边沿"。门铃响过一次之后,你要么把数据全读干净,要么就永远错过这批数据,直到下一次边沿到来。
所以ET模式有两个硬性前提。第一,fd必须是非阻塞的。因为你必须在一次事件里循环read到EAGAIN,如果fd是阻塞的,读完缓冲区的最后一截后,下一次read就会挂起整个线程。第二,你必须处理EAGAIN,并且把它当作"这次读完了"的正常信号,而不是错误。每次都停在第一个EAGAIN之外还不行,你得把数据读尽了才能安全地归还控制权。同理,accept也要循环到EAGAIN,否则积压在受理队列里的新连接可能一直得不到accept,客户端已经连上了你却迟迟没服务它。
5.3 怎么选:一张表和一个判断标准
很多团队一上来就搞ET,因为"高性能服务器都用ET",结果写出一堆肉眼难以发现的bug。我的意见是:先想清楚瓶颈到底在哪。
| 维度 | LT(水平触发) | ET(边沿触发) |
|---|---|---|
| 编码难度 | 低,漏处理不会丢事件 | 高,必须循环读/写至EAGAIN |
| 对端close检测 | 依赖读到的EOF或EPOLLRDHUP | 同理,但更容易因漏读而延迟发现 |
| 事件重复唤醒 | 高,就绪期间反复唤醒 | 低,只在边沿触发 |
| CPU浪费风险 | 就绪事件多时可能空转 | 漏读导致数据滞留 |
| 适用场景 | 通用、中小规模、快速交付 | 追求极致吞吐的大规模IO密集服务 |
判断标准其实很朴素:如果你的应用是"读出所有输入、算完、写回所有输出"这种批处理式的工作循环,LT省心且性能足够好;如果你要的是极低延迟、要在一个线程里扛几万连接,并且你有信心把每次读写的边界处理干净,那就上ET。大多数业务服务,LT其实已经够了,ET带来的收益远小于它带来的坑。
5.4 EPOLLONESHOT:多线程处理同一连接时的安全阀
再说一个容易跟ET混淆的选项:EPOLLONESHOT。它跟ET不同,ET限制的是"同一状态变化的重复通知",EPOLLONESHOT限制的是"同一事件的重复分发"——事件上报一次之后,这个fd的监听被自动禁用,必须显式再调一次MOD重新打开。
这玩意儿在多线程事件处理模型里非常有用。设想你有一个连接在epoll_wait线程A里被分发给了工作线程B处理,处理期间连接又有新数据来,如果没有ONESHOT,同一个fd可能又被事件循环线程分发出去,两个线程同时读写一个socket,数据顺序直接乱套。加了EPOLLONESHOT,第一次分发后监听自动关闭,当前处理者完成后重新MOD,就保证了任何时刻只有一个线程拥有这个fd的处理权。代价是每次重新MOD多一次系统调用,但数据一致性远比这点开销重要。
6. 事件循环骨架与实战避坑清单
6.1 一个可以直接抄的LT模式回声服务器骨架
纸上谈兵这么多,给一个能跑的最小骨架。下面这一段是LT模式,逻辑最简单,适合作为你项目的基础模板。ET版本只需要把注册事件的掩码加上EPOLLET,然后把handle_client和accept改成循环到EAGAIN。
#include <sys/epoll.h> #include <sys/socket.h> #include <netinet/in.h> #include <fcntl.h> #include <unistd.h> #include <string.h> #include <errno.h> #include <stdio.h> #define MAX_EVENTS 128 #define BUF_SIZE 4096 static void set_nonblock(int fd) { int flags = fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK); } static void handle_client(int fd) { char buf[BUF_SIZE]; ssize_t n = read(fd, buf, sizeof(buf)); if (n == 0) { close(fd); // 对端关闭,回收fd return; } if (n < 0) { if (errno == EAGAIN || errno == EWOULDBLOCK) return; close(fd); return; } write(fd, buf, n); // 简单的回声:原样写回 } int main(void) { int listen_fd = socket(AF_INET, SOCK_STREAM, 0); int opt = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_addr.s_addr = INADDR_ANY; addr.sin_port = htons(9000); bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)); listen(listen_fd, 128); set_nonblock(listen_fd); int epfd = epoll_create1(EPOLL_CLOEXEC); struct epoll_event ev = {0}; ev.events = EPOLLIN; ev.data.fd = listen_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev); struct epoll_event events[MAX_EVENTS]; for (;;) { int n = epoll_wait(epfd, events, MAX_EVENTS, -1); for (int i = 0; i < n; i++) { int fd = events[i].data.fd; if (fd == listen_fd) { for (;;) { int conn = accept(listen_fd, NULL, NULL); if (conn < 0) { if (errno == EAGAIN || errno == EWOULDBLOCK) break; if (errno == EINTR) continue; break; } set_nonblock(conn); struct epoll_event cev = {0}; cev.events = EPOLLIN; cev.data.fd = conn; epoll_ctl(epfd, EPOLL_CTL_ADD, conn, &cev); } } else { handle_client(fd); } } } }注意这段代码里data直接存了fd本身,真实项目中建议alloc一个conn结构体,把fd、读写状态、输出缓存都放进去,data存指针。为什么不存fd?因为高并发下你要在事件里查这个fd对应的业务上下文,挨个hash查找的代价不小,直接把指针塞给内核省事得多。fd本身只是一个int,丢了上下文还得重建。
6.2 EAGAIN、惊群与EPOLLEXCLUSIVE:多线程下的三个坎
先说完EAGAIN。LT模式下EAGAIN只是普通的"暂时没数据",代码里读到EAGAIN就continue,等下一次通知。但很多人会把EAGAIN和ECONNRESET混在一起,对端强制关闭时会返回ECONNRESET,这属于正常的"对方走了",直接close就好。ET模式则如前所述,EAGAIN意味着"你该停手了",是循环读的正常退出条件。两种语义千万别搞反,否则要么漏读,要么死循环。
再说惊群。多个线程同时阻塞在同一个epfd上的epoll_wait,某个事件到来时,老内核会唤醒所有等待线程,但事件最终只会被其中一个拿走,其他线程空手而回再睡过去——这就是惊群。解决办法有三个层次:最省力的做法是让多个线程各持一个epfd,把不同连接分散到不同的epoll实例上,谁管理的连接谁负责唤醒;或者用Linux 4.5引入的EPOLLEXCLUSIVE标志,注册事件时加上它,内核只会唤醒等待队列里的一个线程;再就是配合EPOLLONESHOT,让分发后的fd不再被其他人抢到。我只推荐第一种思路,因为它同时解决了epoll实例本身的并发访问问题,EPOLLEXCLUSIVE虽然好用,但毕竟要你的内核版本支持。
第三个坎其实是EPOLLOUT的注册时机。新手常犯的错是在ADD时就把EPOLLOUT加上,以为这样"读写都关心"。问题在于socket的发送缓冲区几乎永远是空的,也就是永远可写,于是这个fd会持续不断地被报告为可写,事件循环被无效唤醒刷满。正确做法是:只有当你想写而write没有写完(返回EAGAIN)时,才用MOD把EPOLLOUT加上;等可写事件来了,把所有积压数据写完,再用MOD把EPOLLOUT摘掉。让EPOLLOUT变成一种"按需触发"的机制,而不是常驻事件。
6.3 我踩过的几个实际深坑,写出来省得你再踩一遍
第一个坑是LT模式下没处理EOF导致的死循环。很多初版代码只在read返回大于0时处理数据,read返回0就continue。问题来了:对端close之后,read永远返回0,而这个fd在LT模式下一直接着报EPOLLIN,于是epoll_wait每次都立刻返回这个fd,你每次continue,线程就在忙等里转,CPU飙满但什么事也没干。正确做法是read返回0必须close(fd)并从epoll里DEL。我见过一次线上事故,就是这样把一个核打满,症状还是"时不时卡顿",查了半天才定位到是某个连接断开后触发的忙循环。
第二个坑是events数组和maxevents不匹配。我早期图方便,栈上声明了256个元素的数组,maxevents却填了1024,高并发下epoll_wait一返回超过256个事件,数组直接越界,偶发段错误,而且只在流量峰值时出现,复现成本极高。后来我把maxevents和数组大小绑在一个宏里定义,从根上杜绝这个可能。
第三个坑是close fd的时序。ET模式下,你读取到EAGAIN时可能还没意识到对端已经关闭,如果这时直接close而epoll里的注册还没删,下次这个fd编号被新连接复用后,可能残留着上一次的事件状态,导致新连接收到莫名其妙的错误事件。稳妥的流程永远是:先DEL再从epoll实例移除,再close fd。还有个细节,如果你用的是data存指针的模型,close之后必须把对应上下文标记为freed并置空,防止事件的最后一批处理还在引用它。
第四个坑是关于EPOLLRDHUP的。TCP对端正常close时,你既会收到EPOLLIN(read返回0)也会收到EPOLLRDHUP;但对端只关闭写方向(半关闭)时,只有EPOLLRDHUP,read不返回0。如果你只靠read==0判断关闭,半关闭连接就一直挂着,fd泄漏到进程撑不住。监听EPOLLRDHUP之后,还要想清楚业务上要不要继续往这个连接写数据——很多人把EPOLLRDHUP当作"可以删了"的信号,但对端可能还等着你发完剩余数据呢,直接close反而丢数据。这个信号该当"提示"还是"判决",取决于你的协议设计。
最后一个常被忽略的点:epoll_wait返回的事件数量并不代表你要处理的逻辑数量。你可以拿到n个事件,但其中可能有些是同一个连接重复上报的,有些是已经过期状态的(ET模式下更明显)。事件循环的正确姿势是把每个事件当作一次"尝试处理的机会",而不是"必须处理的指令",所有处理都要幂等,都要能在中途发现"情况变了"时安全退出。
我个人建议所有刚接触epoll的人,先把LT模式跑熟,跑出线上业务,把连接状态机、缓冲区管理、EPOLLOUT按需触发这些基本功练扎实,再考虑切ET追求那一点吞吐提升。原因是ET省掉的那些重复唤醒,在绝大多数业务里根本不构成瓶颈,而ET引入的漏读、边界错误,往往是线上最难查的那类故障。真到了需要ET的场景,你自然会知道——那时候你对每个read的返回值都敏感得像条件反射,对每个EAGAIN都了如指掌,再切过去就是水到渠成的事。