1. 从一次线上故障说起:阻塞模型为什么撑不住高并发
我第一次被IO多路复用逼到认真研究,是因为一个网关项目在并发冲到800左右的时候开始频繁超时。当时排查了一周,调线程池、改socket超时参数,全部按下葫芦浮起瓢。后来把阻塞accept和阻塞read改成select轮询,才第一次理解一个道理:连接能不能扛住,不取决于你开多少线程,而取决于你用什么方式等待IO事件。
如果你也在写推送服务、聊天后端、网关这类高并发程序,迟早会遇到同样的问题。这篇不是教科书式复述,而是把我从select一路走到epoll的完整经历、三种机制背后的设计取舍,以及实际开发中踩过的坑都摊开讲一遍。看完之后,你至少能清楚回答三个问题:这三种机制各自是什么、为什么epoll被捧上神坛、以及你自己的场景到底该选哪个。
1.1 阻塞IO:一个线程只能陪一个连接
先看最原始的阻塞模型。服务端调用accept等待客户端连接,一旦接受了连接,read就进入阻塞状态等对方发数据。每个socket都需要一个线程全程陪着,整个线程唯一的任务就是从这一个连接上读数据、写数据。
在并发只有几十、几百的情况下,这种方式没问题。但连接数一多,麻烦立刻出现。假设一个线程栈默认8MB,开一万个线程就需要80GB内存,这不现实。即便内存扛得住,CPU也会被线程上下文切换吃掉:一旦线程数超过CPU核数,系统就要不停地在线程之间保存恢复寄存器、切换页表、刷新缓存,大量CPU时间花在了这些“转场动作”上,真正干活的资源所剩无几。这就是典型的每连接一线程模型,也叫Thread-Per-Connection。
更有意思的是,这些线程里绝大多数都在睡眠。连接建立之后,客户端可能几秒甚至几分钟才发一条数据,线程就这么干等着。一万个连接里真正活跃的也许只有几十个,但你的操作系统得为那一万个睡眠线程买单。
1.2 多线程与“非阻塞+忙等”为什么都不划算
人们很快想到了改进办法:既然阻塞会让线程闲置,那把socket设为非阻塞,读的时候如果没有数据就立刻返回错误,主线程用一个循环不停地轮询所有连接,谁可读就处理谁。
这就是“非阻塞IO+忙等”模型。它确实解决了一个线程陪一个连接的问题,但带来了更严重的浪费:主线程必须拿着循环把所有连接挨个问一遍“你好了吗”,不管有没有数据都全量扫描一遍。在数千个连接里,大部分都是安静的,这种忙轮询会把CPU顶到接近100%,但真正有效的工作寥寥无几。
可以把阻塞IO想成一个服务员坐在某张桌子旁边一动不动,只伺候这一桌客人;非阻塞+忙等则是服务员在餐厅里疯狂转圈,路过每张桌子都问一句“要点菜吗”,哪怕那一桌压根没人举手。第一种方式人员成本过高,第二种方式服务员自己先累垮了。
1.3 IO多路复用的核心思想:把“等”这件事交给内核
IO多路复用的思路完全不同:让一个线程把一批socket的等待事件统一登记给内核,然后自己阻塞在内核提供的等待函数上,比如select、poll、epoll_wait。内核一旦发现某个socket上有数据到达、连接可接受、或者可以写入,就把这个事件告诉我们,我们只需要处理那些就绪的连接。空闲连接不产生任何工作量,CPU不会空转,线程也不用一对一地陪连接。
这时服务员的做法变成:他站在餐厅门口,手里拿着一张写着全部桌号的清单,让前台(内核)帮他盯着。任何一桌客人举手叫服务,前台就告诉他“3号桌、7号桌有需求”,他只需要去这两桌。这就是IO多路复用最本质的价值:从“主动去问”变成“别人通知你”。
现在后端领域常说的Reactor模型,本质就是在这个基础上演化出来的。无论是Redis的单线程事件循环,还是Netty、Nginx的IO模型,底层都依赖这套“事件通知”机制。接下来我们逐个剖析select、poll、epoll,你就会发现它们其实是同一思想下的三代产品,演进的主线无非是如何在连接越来越多时依然保持高效。
2. select:被1024和O(n)双重绑架的老前辈
select是最老的IO多路复用接口,1983年就出现在BSD Unix上。它不是靠定时器扫描全局,而是每次调用让内核去遍历你提供的fd集合,看哪些fd满足了条件。凡是涉及网络编程的教科书,几乎都要从select讲起,因为它最简单,也最容易让人理解“多路复用”到底是什么。
2.1 select的API与fd_set位图结构
select的原型长这样:
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);三个fd_set分别表示关心可读、可写、异常事件的文件描述符集合。使用前要通过一组宏来操作这个集合:
FD_ZERO(&readfds); // 清空集合 FD_SET(fd, &readfds); // 把fd加入集合 FD_ISSET(fd, &readfds); // 判断fd是否在集合中fd_set在历史实现上就是位图:每一位代表一个文件描述符是否在集合里。位图放在用户态内存里,调用select时,由内核把这些数据从用户态拷进去,处理完后,再把这个位图拷回来,只不过拷回来时位图里只保留了满足条件的fd。第一参数nfds是“最大fd号+1”,相当于告诉内核“你只需要扫到这一位为止”。
最经典的误用是:select会修改你传入的fd_set,只留下就绪的fd。如果下一次循环还想监控同一批fd,必须重新FD_ZERO、重新FD_SET,或者事先保留一份原始副本。很多初学Linux网络编程的人第一次写select,总会莫名丢掉一部分连接,多半就是因为没搞懂这个“用完即毁”的特性。
2.2 三大痛点:两次遍历和一次全量拷贝
select的问题是结构性的,不是调参能解的。
第一个痛点是每次调用都要把整个fd_set从用户态拷贝到内核态。连接数少的时候无感,可一旦fd数量达到几千,这个拷贝量就很可观。而且无论fd有没有就绪,它都会被完整拷一遍。
第二个痛点是内核要线性扫描全部fd,一个接一个地检查状态。O(n)的时间复杂度无法避免。
第三个痛点是select返回后,用户程序并不知道具体哪些fd就绪了,它必须再遍历一遍整个集合,对每个fd调用FD_ISSET判断。这么说吧,一次select完整流程 = 全量拷贝 + 内核全量扫描 + 用户态全量扫描,三次操作全都是O(n)。监控的fd越多,系统浪费越严重。
还有一个不易察觉的细节:timeval参数在Linux上会被内核修改,返回时它保存的是剩余时间。如果你在一个循环里反复使用同一个timeval,第二次调用时timeout可能已经被改成0,导致select变成忙轮询,直接吃满CPU。正确做法是每次进入循环前重新初始化timeval。
2.3 被广泛误读的“1024限制”,以及select的适用场景
说到select,大家都听过“最多1024个fd”。其实准确说法是:fd_set位图大小由编译期常量FD_SETSIZE决定,Linux上通常是1024,意味着select最多监控0到1023号fd。想放大这个限制不是不行,但需要同时调整内核与用户空间的宏并重新编译,生产环境没人这么干,因为改完的兼容性和风险不可控。
那么select还有存在价值吗?有,而且不少。它最大的优势是跨平台,Windows、Linux、macOS都支持,如果写的是单进程管理少量socket的小工具,比如串口调试器、简单的局域网通信客户端,select完全够用。判断标准很简单:同时监控的连接数如果长期不超过几百个,而且活跃度不极端,select的O(n)开销根本算不上瓶颈,反而因为代码简单、可读性好而更合适。
我自己维护过一个监控程序,监控几十个设备连接状态,用select一点问题没有。真正扛不住select的是连接过千、并且存在大量长连接闲挂的场景,这时候每秒钟全量扫几千个fd,CPU时间就被白白浪费掉了。这正是poll和epoll登场的背景。
3. poll:解决了一个大问题,但依然逃不开线性扫描
poll诞生于System V Unix,从使用方式上看它像是select的改良版。设计上最明显的变化是把fd集合从位图改成了数组,不再受FD_SETSIZE限制,代码写法也因此发生了变化。
3.1 pollfd结构:events和revents为什么要分开
poll的原型:
int poll(struct pollfd *fds, nfds_t nfds, int timeout);每个fd用一个pollfd结构体描述:
struct pollfd { int fd; // 要监控的fd short events; // 关心的事件:POLLIN、POLLOUT等 short revents; // 内核返回的实际事件 };这个结构看起来平淡无奇,但events和revents分离是个极其重要的设计决策。select是用同一个位图既当输入又当输出,内核会把集合改写成只含就绪fd,导致下次使用必须重建。poll则把“你关心什么”和“内核反馈了什么”分开存放,revents字段由内核填充,events保持原样,因此同一个pollfd数组可以反复使用,不用像select那样每次重建集合。这算是poll在工程易用性上的一次明显进步。
使用上,你只需要填充fds数组,调用poll,然后遍历fds检查revents是否有POLLIN、POLLOUT、POLLERR等标志。代码结构清晰,也更容易扩展。
3.2 poll与select的现实差异
真实开发中,poll与select最直观的区别体现在两点。
第一点是fd数量上限。poll没有内置的上限,能监控多少fd只取决于操作系统对进程打开文件数的限制,也就是ulimit -n,以及可用内存。原先被1024卡死的问题消失了。
第二点是timeout精度。select的timeval可以精确到微秒,poll的timeout是整数毫秒。很多场景下微秒级定时确实用不上,但如果你关心高精度超时控制,select反而更细。
从内核工作方式看,poll与select本质是同一种模型:把整个fd数组拷进内核,内核线性扫描每个fd检查事件,然后返回。返回后用户程序还要再次遍历整个fds数组看哪些fd的revents被置位。依然是全量拷贝、内核O(n)扫描、用户态O(n)遍历的旧组合。
还有一个容易忽视的差异:select等待期间,同一fd集合里某个fd关闭导致的事件处理,在不同平台上行为不统一,而poll在这类边界情况下的表现相对规范一些。当然这些都是工程细节,性能的关键还是那个O(n)。
3.3 为什么poll在大规模连接面前依然无力
假设你维护着一万台连接,但每秒真正收发数据的只有几十个。用poll的话,每次调用都要把一万个pollfd结构拷入内核,内核再逐一扫描这一万个fd,检查各自的状态,返回后用户程序还得遍历这一万个fd确认谁是就绪的。
一万个fd可能看起来不多,但网络服务往往是高频循环:每轮事件循环都要做一次poll扫描。如果每10毫秒轮询一次,每秒就是100次全量扫描,一次扫一万个fd,CPU时间大量消耗在徒劳的检查上。连接里那些相对空闲的长连接越多,浪费越明显。
这就是poll的瓶颈所在:它把“监控规模”和“执行开销”强行绑定了。监控的fd越多,每次调用越慢。真正理想的做法是,内核只告诉你“哪些fd有新事件”,而不是每次都把整个集合过一遍。这个诉求直到epoll出现才被真正解决。
4. epoll:红黑树挂事件、就绪链表交答卷
epoll是在Linux 2.6内核中引入的事件通知机制,它在设计上和select、poll有本质区别。nginx、Redis、Memcached这类高并发服务在Linux上几乎都跑在epoll之上。理解epoll,关键是理解它的两个内核数据结构:红黑树和就绪链表。
4.1 三个系统调用,各自管一档事
使用epoll只需要掌握三个函数:
int epoll_create(int size); int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);epoll_create负责创建一个epoll实例,旧版内核要求size参数大于0,它只起一个提示作用,看命名就知道,现代内核已经不怎么用它决定什么了,调用时传个正数即可,后续版本甚至可以直接传任意正数。
epoll_ctl负责维护内核中的监控列表,op可选EPOLL_CTL_ADD、EPOLL_CTL_MOD、EPOLL_CTL_DEL,分别用来添加、修改、删除一个fd的监控事件。每个fd只需要在你关心的那一刻登记一次、变更一次或删除一次,不需要像select和poll那样每次调用前把整个集合全部交给内核。内核用一棵红黑树存放这些登记过的fd,因此增删改操作的时间复杂度是O(log n),而不是O(n)。
epoll_wait是真正的阻塞等待点。它在内核里查看就绪链表,只要有fd产生了事件,就把这些事件拷贝到用户态提供的events数组中,然后返回发生事件的个数。注意,用户态需要处理的仅仅是有事件的fd,而不是全部fd。假设监控一万个fd,某轮只有10个有事件,epoll_wait就把这10个返回给你,剩下9990个安静的fd完全不会出现在结果里。
一个典型的epoll服务端主循环如下:
int epfd = epoll_create(1); struct epoll_event ev; ev.events = EPOLLIN; ev.data.fd = listen_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev); struct epoll_event events[256]; for (;;) { int n = epoll_wait(epfd, events, 256, -1); for (int i = 0; i < n; i++) { if (events[i].data.fd == listen_fd) { // 处理新连接,通常循环accept直到EAGAIN } else if (events[i].events & EPOLLIN) { // 处理可读数据 } } }这段代码虽然短,但已经覆盖了epoll应用的全部骨架。真正复杂的地方,一个是正确管理各种事件标记,另一个是理解LT与ET的差异,这两点后面专门讲。
4.2 复杂度是怎么从O(n)降下来的
为什么epoll能做到高效?根子在事件通知机制上。
先理解一下socket上的事件是怎么触发的。当一条TCP数据到达网卡,协议栈处理完毕后把数据放进对应socket的接收队列,然后会去唤醒所有等待在这个socket上的等待队列项。传统的阻塞read就是把自己挂在socket等待队列上,数据来了被唤醒。
epoll的做法是:在epoll_ctl注册fd时,把一个带有回调函数指针的特殊等待队列项挂到socket的等待队列上。一旦socket上有事件发生,这个回调就会被执行。回调做的事很简单,就是把这个fd塞进epoll实例维护的就绪链表。于是,内核不再需要主动线性扫描所有fd,而是让每个fud在事件发生时主动报到,这就是“事件驱动”四个字的真正含义。
实现可以这样理解:epoll_ctl操作的是红黑树,管理“我关心哪些fd”;epoll_wait只读就绪链表,得到“现在哪些fd有事”。就绪链表里只有活跃fd,所以epoll_wait从内核返回的事件数量是O(k),k是就绪fd个数,而不是总监控数n。当连接总量很大、活跃连接很少时,这个差别是数量级的。
增删fd时,红黑树查找是O(log n),也比select/poll每次全量操作O(n)要好得多。综合下来,epoll把一次事件循环从“全量拷贝+全量扫描”优化成了“只处理真正就绪的fd”。连接越多、空闲越多,优势越明显。
4.3 水平触发与边缘触发:ET为什么必须要配非阻塞IO
epoll支持两种触发模式:水平触发(Level-Triggered)和边缘触发(Edge-Triggered)。刚接触这两个概念的人,十个里有八个会被绕晕。我换个方式解释。
水平触发是select和poll的默认行为:只要fd处于可读或可写状态,epoll_wait每次都会返回它。比如socket缓冲区里来了50字节,应用一次只读了10字节,还有40字节在那里,那么下一次epoll_wait依旧会把该fd上报,直到你把缓冲区读空为止。名字叫“水平”,可以理解为事件在持续的高电平上,检测器一直能感知。
边缘触发则只看变化:只有当fd的状态发生跳变时,比如从无数据变成有数据,内核才通知一次。同样缓冲区有50字节,应用只读10字节,还剩40字节,但因为没有“新数据到来”这个跳变事件,epoll_wait不会再上报这个fd。新数据再来时,它才会再次触发。
边缘触发的代价随即显现:它通知你一次,你就必须把该fd上的事情处理干净,通常要用循环把缓冲区读干净,直到非阻塞读返回EAGAIN。这就引出了ET模式的两条铁律:
- 必须使用非阻塞IO,否则最后一次read没有数据时会阻塞整个线程,而阻塞触发的边界行为也是未定义的。
- 读到EAGAIN才算本轮读事件处理完毕,否则剩下那40字节会一直躺在缓冲区里,如果后续没有新数据到达,它就永远不会被处理,你可能就“丢数据”了。
核心循环长这样:
int flags = fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK); char buf[1024]; ssize_t n; while ((n = read(fd, buf, sizeof(buf))) > 0) { // 处理数据 } if (n == -1 && errno != EAGAIN) { // 读失败,需要关闭或处理错误 }边缘触发效率更高,是因为它避免了同一fd在持续可读的状态下被反复上报,减少了无谓的上下文切换和系统调用开销。代价是编程复杂度上升,要求开发者对EAGAIN有精确的控制感。
这里也顺便解释一个经典现象:Redis在Linux上虽然用epoll,但默认是水平触发模式。原因很简单,水平触发让代码更安全更易写,时不时都有新事件再次上报,不容易因为漏读而丢掉通知。Nginx则默认边缘触发,因为它在事件处理上做了精细的多阶段调度,对“只处理真正状态变化”更有经验。选LT还是ET,说到底不是绝对优劣,而是你对代码精细度的掌控程度。
5. 三种模型一张表说清:差异、代价与选型判断
把三种模型放在一起对比,很多模糊的地方会立刻清楚。我整理了一份自己常用的对照表,基本覆盖日常开发和面试里最关键的维度。
5.1 核心维度对比
| 对比维度 | select | poll | epoll |
|---|---|---|---|
| 底层数据结构 | fd_set位图 | pollfd数组 | 红黑树 + 就绪链表 |
| 最大监控fd数 | 受FD_SETSIZE限制,Linux上通常1024 | 基本无上限,受进程fd限制(ulimit -n) | 基本无上限,受进程fd限制和内存 |
| 每次调用是否全量拷贝 | 是,拷贝整个fd_set | 是,拷贝整个pollfd数组 | 否,只在epoll_ctl时增量登记或变更 |
| 内核检查方式 | 线性扫描全部fd,O(n) | 线性扫描全部fd,O(n) | 回调事件,就绪链表O(k),k为就绪fd数 |
| 返回后用户态确认方式 | 遍历整个fd_set,FD_ISSET逐个判断 | 遍历整个pollfd数组查revents | 只需遍历就绪事件数组,直接拿到fd |
| 触发模式 | 仅水平触发 | 仅水平触发 | 水平触发 + 边缘触发 |
| 跨平台性 | Windows、Linux、macOS均支持 | Linux支持较普遍 | Linux专属 |
| 事件输入输出是否分离 | 不分离,fd_set会被内核改写 | 分离,events与revents分字段 | 分离,epoll_wait单独返回事件数组 |
| 编程易用性 | 简单但要反复重建fd_set | 结构清晰,fd集合可复用 | API简洁,但ET模式复杂度高 |
| 典型场景 | 少量连接、跨平台小工具 | 中等连接数、事件频率不高 | Linux高并发长连接服务 |
5.2 根据业务场景选择模型而不是盲目追新
很多人一听说epoll最强,就在任何项目里强行上epoll。这其实没必要。我见过一些桌面端管理工具,同时打开的socket不超过几十个,用select反而最顺手,代码短、依赖少、跨平台,Windows上也能直接跑。
结合场景给出我自己的选型建议:
- 连接数长期在几十到几百,并且有跨平台需求,优先select。它简单、可移植,性能不会成为瓶颈。
- 连接数中等,几百到上千,且运行环境明确是Linux,可以考虑poll或者epoll的LT模式。poll代码比epoll更容易理解,能少踩很多坑。
- 连接数几千起步,大量长连接闲置,事件稀疏,且跑在Linux服务器上,直接用epoll,最好尽早吃透ET模式。
- 如果做网关、IM、物联网接入层这类C10K级别服务,除了epoll,别无选择。主流网络库、Nginx、Redis在Linux上都构建在epoll之上,这是经过反复验证的路线。
连接模型本身也会影响选择。假如服务是线程池模型,每个线程独立处理一批fd,那么epoll配合EPOLLONESHOT可以避免多个线程同时收到同一个fd事件;select这种模型实现同类控制就麻烦得多。这属于框架层面的考量,但提前想清楚可以少走弯路。
5.3 别把epoll当成万能药:跨平台与资源成本
epoll的一个明显限制是它只在Linux上提供。如果你的服务需要同时跑在Windows、macOS、Linux上,直接写epoll代码在非Linux平台上就没法编译。工业界的解法通常抽象一层事件库,Node.js和Netty就在底层屏蔽了这些差异:Linux上使用epoll,macOS和BSD使用kqueue,Windows使用IOCP或select。所以你如果只是写业务应用,大可不必手撕epoll,选一个成熟的Reactor框架就行。只有在做嵌入式、做自己的网络库、或者需要精细控制性能时,才值得直接面对这套系统调用。
另外要泼一盆冷水:epoll只是把“事件监听”的成本降低了,它不会帮你解决业务处理慢的问题。某个fd的数据处理回调里如果出现耗时操作,同一个epoll_wait循环里的其他连接同样会被拖累。很多人以为换了epoll就高性能,结果依然是把阻塞数据库查询写进了事件循环,该卡还是卡。高性能服务从来不是靠一个API点亮的,它需要事件模型、线程模型、业务逻辑整体配合。
6. 实战踩坑清单:ET死循环、惊群与其他幺蛾子
理论说再多,真正决定你能不能交付的是那些细碎的实坑。这里把我这些年踩过的、看别人踩过的典型问题集中列出来,每一条都值得记下。
6.1 边缘触发模式的经典死循环:EAGAIN处理
ET模式最大的坑就是读不干净。前面已经说了,数据到达触发一次通知,你只读了一部分,后续没有新数据就不会再通知。但实际踩坑时还有个更隐蔽的版本:把数据读完了,但读循环没有处理好EAGAIN,导致进程卡住或忙循环。
正确做法是:每次收到EPOLLIN后,循环调用read,直到read返回0表示对端关闭,或者返回-1并且errno为EAGAIN表示当前缓冲区没数据了,才退出本轮读循环。如果你在read返回-1时直接把错误当成异常关闭连接,就会频繁断开正常连接。因为非阻塞模式下没有数据可读时返回-1是预期行为,不代表连接出错。
另外一个常见问题出现在写事件上。ET模式下如果发送缓冲区满,写事件同样只通知一次,你必须循环写入到写完或EAGAIN,同时配合用户态应用层缓冲,把没写完的数据暂存起来等下次可写事件。很多新手只注意了读的ET,忽略了写也遵循同样规则。
6.2 惊群效应:一包数据唤醒一堆线程
如果服务端有多个线程或者多个进程,都同时对同一个listen fd调用了epoll_wait,那么一个连接请求到达时,内核可能把所有等待线程全部唤醒,但最终只有一个线程能accept成功,其余线程空转一圈再回去休眠。连接请求量越高,这种无效唤醒越明显。早期版本的Linux内核没有专门处理这个问题,直到后来才引入了EPOLLEXCLUSIVE这一类事件通知的可选项,它可以确保一个事件只唤醒一个等待者。
实际工程里的解法分几个层次:
- 最简单且最稳妥的:只有主线程负责epoll_wait并accept,accept之后把连接fd交给工作线程去读写。也就是单Reactor多Worker模型。
- 多Reactor模型里,每个线程有自己的epoll实例,各自只监听自己负责的那批连接fd,互不干扰,也就不存在listen fd竞争。
- 多进程监听同一个端口时,可用SO_REUSEPORT让内核在端口层面做负载均衡,每个进程有自己的listen fd,从根上避免同时唤醒。
我自己倾向于主线程accept加工作线程业务处理的模型,简单、可控、坑少。除非确实需要多核accept吞吐,否则不必为了追求极致的并行accept引入多余的复杂度。
6.3 那些不起眼但能让你折腾半天的细节
有几个细节问题,几乎每个写过epoll的人都遇到过。
第一个是epoll_ctl的返回码。ADD一个已经存在的fd会返回EEXIST,MOD一个没有注册过的fd会返回ENOENT。很多连接管理疏漏,都是因为没判断这些返回码,导致某个fd重复注册后事件异常。
第二个是accept的EINTR问题。进程收到信号时,accept、epoll_wait这类系统调用可能返回-1,errno设为EINTR。如果代码不处理这个返回码,在高频信号环境下服务会出现偶发卡顿或虚假异常。强烈建议对这类错误码显式处理:遇到EINTR就继续下一次循环。
第三个是listen fd本身就是一种特殊的事件源。当listen fd上出现EPOLLIN事件时,并不代表每次accept都能成功,因为三次握手完成的连接可能已经被对端RST掉。稳妥的做法是循环调用accept,一直到返回EAGAIN为止,同时忽略由于并发连接过多产生的EMFILE错误。
第四个是超时参数。epoll_wait的timeout传0就是立即返回,通常用于非阻塞式检查。如果在while循环里把timeout设为0又没有任何事件,CPU会被打满。反过来把timeout设成-1就要注意:一旦fd上没有事件,线程会无限期阻塞,想退出事件循环必须依靠额外手段。
第五个是事件数组大小。epoll_wait第3个参数maxevents决定了内核最多向用户态拷贝多少事件。一个活跃连接非常多的时刻,就绪链表里的事件数可能远超这个值,剩下的事件会留到下一轮epoll_wait返回。如果业务上需要严格的一轮全部处理完,就得用更大的数组或者增加循环批次,避免因为数组太小而延迟后续事件的处理。
我在实际做项目的时候还有个习惯:所有涉及epoll的模块,我会单独封装一层,把fd注册、事件分发、错误处理集中在一个文件里,业务代码不直接接触epoll_ctl和epoll_wait。这样调试一个事件模型问题时,不需要在整个项目里搜索系统调用。后面你再接新连接类型、加超时管理、升级线程模型,都只动这一层就够了。
从select到poll再到epoll,整个演进逻辑其实特别清晰:先是去掉1024硬限制,然后解决每次全量拷贝问题,最后用回调把复杂度从O(n)降到O(k)。对正在选型的你,我的建议是:别迷信某个API,先数清楚你的连接量级和活跃比例,再去对照使用。至于ET还是LT,先写出正确水平触发的版本,踩过一轮坑后再挑战边缘触发也不迟。网络上关于这套机制的高深讨论很多,但把上面这些坑提前避开,你的高并发服务已经能稳稳跑起来了。