做Linux服务端开发这些年,有一个绕不开的话题就是I/O复用,而Linux下的I/O复用,最终几乎所有高并发方案都收敛到了同一个答案:epoll。不少刚入行的朋友背过epoll的面试题,知道它比select、poll强,但要真问一句“强在哪、怎么用才不会踩坑”,很多人就讲不清楚了。这篇我结合实际开发和线上排查经验,把epoll模型从原理到底层设计、从API到触发模式、从单线程到多线程的常见问题一次性讲透。
文章适合正在写Linux网络服务的开发同学,也适合准备面试想真正理解“事件驱动”本质的人。我会尽量少讲空洞的结论,多给能落地的经验和判断依据。
1. 为什么非用它不可:select的三大硬伤与事件驱动雏形
1.1 I/O多路复用到底在解决什么
先捋一下基础。一个连接对应一个socket fd,最简单的模型是开一个线程去阻塞读这个fd。这种模型在只有几百个连接的时候没问题,一旦连接数涨到几千几万,线程数会失控:线程创建、上下文切换、内核调度开销全部爆炸,CPU时间大量花在切换而不是业务处理上。
I/O多路复用的思路是:一个线程等着,内核帮你盯着成百上千个fd,任何一个fd有事件了,就告诉这个线程“有货了”。select、poll、epoll都属于这个思路,区别在于“怎么盯”和“盯完怎么通知”。
1.2 select的问题:数量受限与全量扫描
select的模型是:把一堆fd放到一个fd_set位图里,调用select时把这个位图整体拷给内核,内核逐个遍历检查有没有事件,然后把结果再拷回用户态。这个方案的硬伤有三个。
第一是fd数量被FD_SETSIZE限制,通常就是1024。你程序里文件描述符再多,select能一次监视的数量也止步于此。虽然可以通过修改FD_SETSIZE重新编译,但治标不治本。
第二是每次调用都要全量拷贝、全量扫描。不管你的1024个fd里只有1个有事件,内核也要从头到尾检查一遍。更麻烦的是,select返回后你并不知道具体哪个fd有事件,得自己再遍历一遍fd_set去判断。这个O(n)的复杂度在连接数达到几万时非常致命。
第三是fd_set位图在返回时会原地修改,如果你想循环处理多个事件,每次调用前都得重新把fd加入集合,代码里就到处是FD_SET、FD_CLR,维护起来很痛苦。poll用pollfd数组替代了位图,突破了1024数量限制,但每次调用依然要全量扫描、依然不会告诉你“具体哪个有事件”,复杂度还是O(n)。
1.3 epoll为什么被称为“事件驱动”
epoll的模型和select、poll有本质区别。select、poll是“我主动去问”,每次调用都在做全量检查;epoll是“你先登记,有事我再叫你”——fd注册一次之后,内核维护好这个fd的状态,等真正有IO事件发生,通过回调机制把对应的fd塞进一个就绪队列,线程调用epoll_wait取走这个就绪列表就行。
这个区别意味着两件事:
- 连接数再多,epoll_wait每次只处理“真正就绪”的fd,而不是把所有监听fd都翻一遍;
- fd集合不用每次调用都在用户态和内核态之间来回拷贝,注册和管理在epoll_ctl里完成。
所以它的复杂度大致是O(K),K是就绪事件数量,而不是O(N),N是监听fd总数。在大量空闲连接、少量活跃连接的典型服务端场景里,这个差距就是几百倍甚至上千倍。
2. epoll的内核设计:红黑树、回调与就绪队列怎么配合
2.1 一次epoll_create之后,内核里多了什么
每次调用epoll_create或epoll_create1创建一个epoll实例时,内核会分配一个struct eventpoll结构体。你可以把它理解成一个“事件管理器”,里面最关键的两个成员:一棵红黑树和一个就绪链表。
红黑树用来存放所有通过epoll_ctl注册进这个实例的fd对应的节点epitem。一个epitem代表一个被监听的fd及其关注的事件类型,红黑树按fd大小排序,方便插入、查找、删除。
就绪链表存放已经发生事件的epitem。某个fd一旦有事件,内核通过回调把这个epitem挂到就绪链表末尾。epoll_wait要做的事情非常简单:检查就绪链表是否有元素,有就拷贝到用户空间,让用户直接拿到就绪候选项。
这里有个容易混淆的点:红黑树里的fd是全量注册列表,就绪链表里的fd是“此刻有事”的子集。epoll_wait只跟就绪链表打交道,不碰红黑树。
2.2 为什么管理fd的是红黑树而不是哈希表
很多人问过这个问题。fd的增删改查需要支持快速定位,哈希表平均O(1)不是更香吗?
现实中内核选择红黑树有几个实际考虑。
红黑树是有序数据结构,时间复杂度稳定在O(log n),最坏情况下也不会退化。哈希表虽然平均性能好,但要处理哈希冲突和动态扩容,代价并不低。另外fd的操作模式里删除和修改很频繁,哈希表在删除、遍历、碰撞处理上的整体成本未必比红黑树低。
红黑树最大的优势是稳定、现成、成熟。Linux内核里本来就大量使用红黑树,基础设施完善,不需要为epoll单独搞一套复杂的数据结构。对于实际场景,几十万fd在一个红黑树上做插入删除,每次操作几十次比较而已,这个开销完全不是瓶颈。
2.3 回调机制才是epoll性能的真正源头
红黑树只是解决了“fd怎么管理”的问题,epoll真正快的原因在于事件回调。
epoll_ctl注册fd的时候,内核会为这个fd构建一个等待队列项,并且把回调函数设置为ep_poll_callback。当这个fd上有IO事件发生时,比如网卡收到数据、协议栈处理完毕,底层驱动会通过wake_up机制唤醒这个fd的等待队列,此时ep_poll_callback被调用,做的事情就是把对应的epitem加入到所属epoll实例的就绪链表中,并唤醒正在epoll_wait上阻塞的进程。
换句话说,内核不需要“主动检查”每个fd有没有数据,每个fd有事件时会自己上报。这就是“事件驱动”四个字的含义。
我用一个生活化的类比:select像是你去食堂柜台一个个掀开锅盖看菜好了没;epoll是厨师炒好菜喊一声“21号”,轮到你了再起身。炒好的菜越多,select消耗越大,epoll几乎不增加额外成本。
2.4 关于mmap和“零拷贝”的说法,别被带偏
网上很多文章强调epoll通过mmap实现用户态和内核态共享内存,所以快。这个说法要打个问号。
epoll的早期实现确实用mmap映射过事件表,但现代内核里,epoll_wait把就绪事件返回给用户态仍然需要数据拷贝。真正让epoll在性能上胜出的,是“只关心就绪fd”和“回调通知”这两个特性,不是靠mmap省掉的那点拷贝。
理解了这一点,你在面试或者和别人讨论时就不容易被“零拷贝”这种似是而非的结论带偏。epoll快在于减少了无效遍历和无效拷贝的次数,而不是消除了所有拷贝。
3. 核心API与一份能跑的C服务端骨架
3.1 三个API的行为细节,网上教程经常漏掉
epoll的API只有三个:epoll_create/epoll_create1、epoll_ctl、epoll_wait,但每个都有值得注意的细节。
epoll_create的size参数在Linux 2.6.8之后被内核忽略了,但你传0会有兼容性风险,传一个正数最稳妥。生产环境建议直接用epoll_create1(EPOLL_CLOEXEC),好处是子进程fork时不继承这个fd,避免意外泄漏。
epoll_ctl的行为最容易出问题的是错误码。对一个已经注册过的fd再执行EPOLL_CTL_ADD,会返回EEXIST;对一个没注册过的fd执行EPOLL_CTL_DEL或EPOLL_CTL_MOD,会返回ENOENT。很多线上bug都是add和mod没分清,或者连接关闭后忘记从epoll里删除fd,导致epoll内部残留悬垂引用。
epoll_wait的timeout参数有几种常见取值:-1是永久阻塞直到有事件;0是立即返回;大于0是等待指定毫秒数。返回0表示超时,返回-1且errno为EINTR表示被信号中断,此时应该重新调用,而不是当作错误退出。
还有一个新手常踩的坑:epoll_wait返回的是“事件个数”,不是“fd个数”。一个fd可以同时返回EPOLLIN和EPOLLOUT,你需要遍历events数组,逐个判断event.events里的标志位。
3.2 一段最小但完整的事件循环代码
下面这段代码是一个典型的高并发服务端骨架,核心功能包括:创建监听socket、注册到epoll、accept新连接、读取数据。我故意保留了必要的细节,方便直接改成自己的框架。
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <errno.h> #include <fcntl.h> #include <sys/epoll.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #define MAX_EVENTS 1024 #define PORT 8080 static int set_nonblocking(int fd) { int flags = fcntl(fd, F_GETFL, 0); if (flags == -1) return -1; return fcntl(fd, F_SETFL, flags | O_NONBLOCK); } int main(void) { int listen_fd = socket(AF_INET, SOCK_STREAM, 0); if (listen_fd < 0) { perror("socket"); return 1; } int reuse = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); 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)) < 0) { perror("bind"); return 1; } if (listen(listen_fd, 128) < 0) { perror("listen"); return 1; } set_nonblocking(listen_fd); int epfd = epoll_create1(EPOLL_CLOEXEC); if (epfd < 0) { perror("epoll_create1"); return 1; } struct epoll_event ev; ev.events = EPOLLIN; // 监听可读事件 ev.data.fd = listen_fd; if (epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev) < 0) { perror("epoll_ctl_add_listen"); return 1; } struct epoll_event events[MAX_EVENTS]; while (1) { int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1); if (nfds < 0) { if (errno == EINTR) continue; perror("epoll_wait"); break; } for (int i = 0; i < nfds; i++) { int fd = events[i].data.fd; if (fd == listen_fd) { // 有新的连接到达,循环accept直到EAGAIN while (1) { int conn_fd = accept(listen_fd, NULL, NULL); if (conn_fd < 0) { if (errno == EAGAIN || errno == EWOULDBLOCK) break; if (errno == EINTR) continue; break; } set_nonblocking(conn_fd); ev.events = EPOLLIN | EPOLLRDHUP; ev.data.fd = conn_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &ev); } } else { if (events[i].events & (EPOLLERR | EPOLLHUP | EPOLLRDHUP)) { // 对端关闭或出错,主动关闭连接并清理 close(fd); epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); continue; } if (events[i].events & EPOLLIN) { char buf[4096]; // 循环读,直到读不到更多数据为止 while (1) { ssize_t n = read(fd, buf, sizeof(buf)); if (n > 0) { // 处理业务,此时可以走进线程池或业务队列 } else if (n == 0) { close(fd); epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); break; } else { if (errno == EAGAIN || errno == EWOULDBLOCK) break; close(fd); epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); break; } } } } } } close(epfd); close(listen_fd); return 0; }这段代码有几个关键点要说明。
listen_fd也设置成非阻塞,是因为accept返回EAGAIN时我们可以跳出内层循环继续处理其他事件,不用一直阻塞等待新连接。
每个conn_fd都单独注册EPOLLIN,配合EPOLLRDHUP可以及时感知对端关闭,避免等EPOLLIN触发才知道连接断开。
读数据用了“循环读到EAGAIN”的模式。这段代码虽然没开EPOLLET,但循环读的优势是能处理当次事件的批量数据,减少epoll_wait的重复返回;同时非阻塞IO下读到EAGAIN就自然退出,不会卡住线程。
3.3 代码里最容易埋雷的三个位置
第一个雷是accept之后忘了设置非阻塞。如果监听socket是阻塞模式,accept返回的conn_fd默认也是阻塞模式,在后续处理时一旦read没有数据就会把整个事件循环卡死。
第二个雷是连接关闭后没有从epoll里删除fd。在LT模式下,fd关闭后如果没执行EPOLL_CTL_DEL,内核里的epitem可能仍然指向这个已经被回收的fd,后续事件触发时用到悬垂引用,轻则事件混乱,重则程序崩溃。标准做法是close前先删,或者close之后马上从自己的数据结构中清理。
第三个雷是没有处理EPOLLERR和EPOLLHUP。socket出现错误或对端异常断开时,如果不主动处理这两个事件,epoll_wait会反复返回这个fd,让你的事件循环空转,CPU占用率直接飙升。
4. 水平触发与边缘触发:ET高效,但坑也全在这里
4.1 LT和ET究竟差在哪个时间点
epoll的触发模式有LT(水平触发,Level Triggered)和ET(边缘触发,Edge Triggered),注册fd时没有指定EPOLLET,默认就是LT。
LT模式的逻辑是:只要fd还有数据没读完,每次调用epoll_wait都会返回这个fd的可读事件。这次没读完,下次还会继续通知,相当于“持续提醒,直到你处理完”。
ET模式的逻辑是:只在状态发生变化的那一刻通知一次。缓冲区从“没有数据”变成“有数据”时通知一次,如果这次没读完,内核不会因为“缓冲区里还有数据”而再次通知,除非新的数据到来产生新的状态变化。
可以这样理解:LT是高水位触发,水位线以下都算有事;ET是跳变沿触发,只看从无到有、从有到无的变化瞬间。
4.2 为什么ET模式必须配非阻塞IO
ET模式之所以必须在非阻塞IO下使用,是因为它要求“一次事件通知内把能读的都读完”。你无法预知这次还有多少数据,只能不断read,直到返回EAGAIN表示“当前没有更多数据了”。
如果socket是阻塞的,最后一次read数据恰好读空缓冲区,这个read会阻塞在那里,导致整个线程卡死,后面所有fd的事件都无法处理。所以业界铁律是:使用ET模式时,所有socket都必须设置非阻塞标志。
4.3 读事件要“读到EAGAIN”才算完,写事件同理
ET模式下的读操作代码范式是:
while (1) { n = read(fd, buf, sizeof(buf)); if (n > 0) { 处理数据,注意数据可能被分多次才能完整拼包; } else if (n == 0) { 对端关闭,清理fd; break; } else { if (errno == EAGAIN || errno == EWOULDBLOCK) { 数据读完了,退出读取循环; break; } else { 真正的错误,清理fd; break; } } }写事件的逻辑很容易被忽视。conn_fd刚建立的时候,写缓冲区通常是空的,也就是“可写”状态会立即触发。如果你在注册时同时关注了EPOLLIN和EPOLLOUT,或者单独注册了EPOLLOUT,epoll可能会立刻反复通知可写,把你的CPU时间大量浪费在空转上。
常见做法是平时只注册EPOLLIN,当你有数据要发送但一次send没写完时,才临时通过EPOLL_CTL_MOD添加EPOLLOUT关注;等缓冲区可写、数据写完,再把EPOLLOUT去掉。这套机制在LT和ET下都适用,也是很多异步网络框架处理写事件的常规思路。
4.4 一个把CPU吃满的线上问题还原
我之前遇到过一次线上CPU告警,现象是单个worker进程CPU打满。用strace观察,发现epoll_wait几乎一瞬间就返回,而且返回的始终是同一个fd的EPOLLIN事件。
排查后发现,代码用ET模式监听一个socket的可读事件,但处理函数里只read了一次就退出,没有循环读到EAGAIN。数据量不大时没问题,一旦某个连接持续有数据流入,由于ET只在状态变化时通知,本该通知的时机被浪费在“只读一部分”上,剩余数据一直躺在缓冲区,内核认为“还有数据”,但这个连接又没有新数据进来,所以迟迟不再通知。真正的问题反而是一个长时间没有新流量的连接上,因为状态变化的边缘不够多,导致服务端对数据的响应延迟。那次CPU告警则是另一个连接的写方向出了类似问题,写事件被反复触发,处理函数又没能及时把数据写掉,导致忙等。
这类问题更常见的是另一个现象:LT模式下,如果你在事件回调里做太多耗时的业务逻辑,fd的数据一直没读完,epoll_wait就会一直返回这个fd,CPU被打满。这个在LT下很好排查,因为每次epoll_wait都返回同一个fd,基本能猜到是业务处理太慢没来得及消费数据。
4.5 到底该用LT还是ET
我的建议是:如果你的代码是自己维护的,从LT开始写,先把功能跑通,再考虑性能优化。LT模式代码简单、容错性强,即便偶尔忘记读完也不会丢掉事件通知,只是可能会重复触发,顶多做点无用功。
ET模式的最大优势是减少系统调用和事件通知次数,在高吞吐、大流量场景下确实有收益。但代价是你必须严格遵守“读到EAGAIN”和“非阻塞IO”这套纪律,任何一个环节没做好都可能引入隐蔽bug。
nginx、redis这类项目以ET为主,是因为它们的网络层大量采用非阻塞事件循环且有成熟的read/write策略。如果你在写一个业务服务,我不建议一上来就上ET,先压测LT,看看性能是否真的成为瓶颈,再决定要不要换。
5. 多线程与多进程:惊群、事件竞争与EPOLLONESHOT
5.1 单线程reactor的天花板
单线程epoll可以提供很大的并发连接数,但不代表能扛住很大吞吐量。因为事件处理是串行的,如果某个连接的业务逻辑耗时较长(比如数据库查询、计算密集任务),后续所有fd的事件都会被阻塞等待,延迟直线上升。
解决思路是reactor + worker:主线程只做epoll_wait和事件分发,真正耗时的逻辑交给线程池去处理。分发时把fd和事件封装成任务丢进队列,由worker线程消费。
5.2 accept惊群为什么没我们想象中严重
多线程或多进程同时epoll_wait同一个listen_fd,当新连接到达时,理论上多个等待者都会被唤醒,但只有一个能accept成功,这就是惊群问题。
早期Linux内核的accept惊群确实严重,但从2.6.18开始,内核在socket等待队列上引入了互斥唤醒机制,accept的等待项被标记为互斥项,唤醒一个后就不再唤醒其他进程。所以现代内核上多进程accept的惊群问题已经没有传统文章描述的那么夸张。
不过epoll自身的wait行为有所不同。如果你在多个线程里对同一个epoll实例调用epoll_wait,等待项不是互斥的,内核会唤醒所有等待者,最终只有一个线程能处理某个事件。Linux 4.5之后提供了EPOLLEXCLUSIVE标志,可以让注册到epoll实例上的等待项具备互斥唤醒属性,一定程度上缓解多个epoll实例监听同一fd时的惊群。
但在工程上,我更推荐直接绕开这类问题:如果需要多进程处理连接,用SO_REUSEPORT让内核在多个进程间分发新连接,每个进程维护自己的epoll实例,避免共享epoll带来的竞争。
5.3 EPOLLONESHOT的适用场景
当同一个fd被多个线程拿到事件并同时处理时,会出现两个线程同时read同一个socket的问题,数据被分割消费,业务逻辑错乱。
EPOLLONESHOT可以解决这个问题。注册fd时加上EPOLLONESHOT后,事件触发一次,内核自动把该fd从epoll的关注队列里摘除。处理完当前业务后,需要你手动重新执行EPOLL_CTL_MOD把它加回来。这样从机制上保证了同一时刻只有一个线程在处理这个fd。
它的代价是每次事件处理完都要一次epoll_ctl调用,在高频小包场景下会有额外开销。所以通常建议在连接生命周期较长、业务处理耗时相对明显的场景下使用,而不是无脑套用。
5.4 生产环境常用的进程模型
我见过比较稳的架构有两种。
一种是单进程主从reactor:一个主线程只accept,把新连接分发到多个子线程,每个子线程有自己独立的epoll实例负责网络IO。子线程处理连接后把业务丢给线程池。
另一种是多进程SO_REUSEPORT + 单epoll:每个worker进程独立listen同一个端口,内核来负载均衡新连接,每个worker自己跑一套完整的事件循环。这种方式部署简单、故障隔离好,也是很多网关类服务的选择。
这两种架构的共同点是:尽量避免多线程共享同一个epoll实例处理同一个fd,从根源上减少锁竞争和数据竞争,比事后用EPOLLONESHOT去补救更省心。
6. 压测与线上排查:从strace到内核参数的一次复盘
6.1 先用strace看epoll_wait在干什么
服务端出现性能问题,我第一件事是用strace跟踪一下epoll_wait行为:
strace -p <pid> -e trace=epoll_wait,epoll_ctl,accept,read,write -f重点关注几个信号:
- epoll_wait返回的fd数量很多且持续不断,说明事件处理速度跟不上事件产生速度;
- epoll_ctl频繁出现ADD/DEL,说明连接在快速建立和断开,可能没做连接复用;
- epoll_wait返回EINTR很频繁,说明系统信号较多,可能会干扰事件循环;
- epoll_wait返回0(超时)非常频繁,说明事件循环经常空转,timeout参数可能设置得不合理。
6.2 epoll也能监听定时器和事件fd
除了socket,epoll可以监听eventfd、timerfd、signalfd这类“非网络fd”,这是很多新手不了解的实用技巧。
例如你想在事件循环里加一个定时任务,不需要单独开一个定时器线程,用timerfd_create创建一个定时器fd,然后把它注册进epoll,每次定时器到期,这个fd就会变成可读状态,epoll_wait自然返回。整个进程只需要一个事件循环就能同时处理网络IO、定时任务和信号,结构非常干净。
我主导过的一个网关项目就是用timerfd实现心跳超时检测,省掉了原来单独跑的定时扫描线程,系统复杂性下降不少。
6.3 修改max_user_watches与fd上限
epoll监听项的数量并不是无限的,有一个内核参数会限制单个用户总共能添加到所有epoll实例的fd数量:
cat /proc/sys/fs/epoll/max_user_watches这个值通常根据内存自动配置,表示每个用户可注册的“文件描述符监视项”总数。当你需要支持几十万上百万连接时,如果这个值偏小,epoll_ctl会返回ENOSPC错误,进程日志里能看到“Failed to add file descriptor to epoll”之类的报错。
调大的方式:
sysctl -w fs.epoll.max_user_watches=2000000同时还要调整进程的fd上限,否则epoll能监听再多也没用:
ulimit -n 1000000另外需要注意,游戏服务器或长连接网关通常会把listen backlog调大,同时确认net.core.somaxconn,避免连接堆积在accept队列里被内核丢弃。
6.4 常见异常事件:EPOLLERR、EPOLLHUP与悬垂fd
epoll_wait返回的事件标志位里,EPOLLERR和EPOLLHUP最容易被人忽略,但它们恰恰是线上故障的一个重要来源。
EPOLLERR表示fd发生错误,比如socket的读端被关闭而写端还在,或者底层网络异常。EPOLLHUP表示读写两端都挂断。还有EPOLLRDHUP,表示对端关闭了写方向,自己这端还能写但不能读了。在TCP长连接里,对端进程崩溃或网线异常断开时,这些事件经常出现。
处理原则是:只要events里出现了EPOLLERR或EPOLLHUP,就别再尝试对fd做常规IO操作了,直接清理关闭。很多线上连接泄漏问题,就是只关注EPOLLIN,忽略这些异常事件,导致一个已经死掉的socket始终留在epoll里,不断触发事件又处理不掉。
另外一个隐蔽的问题是悬垂fd。如果一个fd已经从epoll里删除,但你的事件处理代码还拿着旧的fd号去read,可能读到的是另一个后来被复用为相同编号的fd,这种现象叫fd复用导致的串号。解决办法是保证连接对象的生命周期与fd的epoll注册状态强绑定,在回调里通过data.ptr拿连接对象,而不是只存一个裸fd。
6.5 与kqueue、io_uring怎么选
一句话总结:在Linux上用epoll,是稳定性与性能兼顾的最稳选择。kqueue是BSD/macOS上的等价物,IOCP是Windows上的异步模型,跨平台框架(如libevent、libuv)底层会封装这些差异。
io_uring这几年热度很高,它是Linux的异步IO接口,性能和灵活性上限更高,特别适合存储和高性能网络场景。但它对内核版本有要求,使用复杂度也远高于epoll,普通业务服务贸然迁移收益未必明显。我的建议是:常规网络服务继续用epoll,如果哪天压测发现性能瓶颈真在事件循环本身,再考虑io_uring也不迟。
我在实际项目里体会最深的一点是:epoll本身很简单,复杂的是你对“事件”语义边界和连接生命周期的掌控。代码里提前把EPOLLERR、EPOLLHUP、EAGAIN这些边界情况处理干净,比迷信ET模式、追求零拷贝带来的收益实在得多。如果你准备在生产环境大规模使用epoll,建议先用一个小工具把所有事件类型都模拟一遍,观察一下每个标志位下的行为,再上真实业务,能少踩非常多坑。