1. 为什么 Epoll 不是“另一个 select”,而是 Linux 高并发网络服务的底层支点
很多人第一次接触 Epoll,是在某次面试被问到“select/poll/epoll 区别”时临时背下的三行对比:select 有 1024 文件描述符限制,poll 没上限但每次都要遍历全部 fd,epoll 使用红黑树 + 就绪链表,时间复杂度 O(1)。背完就以为懂了——结果一写真实服务,CPU 占用飙到 95%,连接数刚过 3000 就开始丢包,日志里全是accept() failed: Too many open files和epoll_ctl: Bad file descriptor。我当年在某实验室做模拟项目X 的实时数据分发模块时,就栽在这上面:用 epoll_wait 写了个“看似完美”的事件循环,压测时发现每秒只能处理 1.2 万请求,而同样机器上跑的 Nginx 轻松扛住 8 万+ QPS。后来花三天重读内核源码注释、抓包分析系统调用耗时、逐行比对 strace 日志,才真正明白:Epoll 不是一个 API,而是一套围绕 Linux 内核事件通知机制构建的协同协议。它要求用户空间代码必须严格遵循内核的调度逻辑,否则再“高效”的接口也会退化成轮询。关键词不是“epoll_create”“epoll_ctl”“epoll_wait”这三个函数,而是边缘触发(ET)与水平触发(LT)的语义边界、就绪状态的原子性维护、fd 生命周期与事件注册的强一致性、以及内核-用户空间零拷贝就绪队列的消费节奏。这四个点,才是决定你写的服务器是能跑在生产环境,还是只配当教学 Demo 的分水岭。它不解决“怎么写一个 echo server”,它解决的是“当 10 万个 TCP 连接同时发送 64 字节心跳包时,你的主线程如何在 1ms 内完成所有读取、解析、响应,且不漏掉任何一个 fd 的 EPOLLIN 事件”。这不是编程技巧问题,是系统级协同思维问题。如果你现在还在用 while(1) { epoll_wait(...); for(each fd in events) { read(); write(); } } 这种教科书式写法,那恭喜你,已经站在了高并发性能悬崖边上——而这个悬崖,恰恰是绝大多数人根本没意识到自己正站在上面。
2. Epoll 的真实工作流:从内核事件注入到用户空间消费的完整闭环
要理解 Epoll 为什么快,不能只看用户态 API,必须把内核态和用户态当成一个整体来看。我们以一次典型的客户端发包 → 服务端收包 → 服务端回包流程为例,拆解整个事件生命周期:
首先,内核网络栈收到一个 TCP 数据包后,会将其放入对应 socket 的接收缓冲区(sk->sk_receive_queue)。此时,如果该 socket 已被 epoll 实例监控(即已调用 epoll_ctl(EPOLL_CTL_ADD)),内核会检查该 socket 是否处于“可读就绪”状态——判断依据不是“缓冲区非空”,而是“缓冲区中有效数据长度 ≥ 应用层上次 read() 后剩余的可读字节数”。这个细节极其关键:很多开发者误以为只要 recv buffer 有数据,epoll 就会通知,其实不然。内核会维护一个 per-socket 的就绪状态位(sk->sk_epoll_mask),只有当该位被置为 EPOLLIN 且尚未被用户空间消费时,才会将该 fd 加入就绪链表。
接着,当用户调用 epoll_wait() 时,内核并不遍历所有被监控的 fd,而是直接返回就绪链表(rdllist)中的节点。这个链表由内核在数据到达、连接建立、错误发生等事件触发时,通过 __ep_poll_callback() 函数原子地插入。重点来了:这个插入操作发生在软中断上下文(softirq),且全程无锁(使用 lockless list)。这意味着从数据包抵达网卡,到 fd 出现在 epoll_wait 返回数组中,中间没有进程调度、没有上下文切换、没有内存屏障开销——这是 epoll 时间复杂度 O(1) 的物理基础。
然后,用户空间拿到 events 数组后,开始逐个处理。这里出现第一个分叉点:如果是 LT 模式,只要该 fd 的 recv buffer 中仍有数据,下次 epoll_wait 就会再次返回它;而 ET 模式下,内核只保证“至少通知一次”,之后无论缓冲区是否还有数据,都不会再通知,除非该 fd 再次发生新的 I/O 事件(比如又有新包到达)。这就引出了 ET 模式的铁律:必须一次性把当前就绪的 fd 读/写到 EAGAIN 或 EWOULDBLOCK。我见过太多人写 ET 模式代码时,在 read() 返回 n > 0 后就直接 break,结果后续数据永远卡在内核缓冲区,epoll 再也不通知——因为内核认为“你已经知道它可读了,你自己没读完,怪不了我”。
最后,当用户调用 close() 关闭一个被 epoll 监控的 fd 时,内核会自动从红黑树中删除该节点,并从就绪链表中移除(如果存在)。但这里有个经典陷阱:如果在 epoll_wait 返回后、处理该 fd 前,另一个线程或信号处理函数提前 close() 了它,那么后续对该 fd 的 read/write 将触发 EBADF 错误,而 epoll_ctl(EPOLL_CTL_DEL) 可能因 fd 已失效而失败。这就是为什么所有健壮的 epoll 服务都必须在事件处理前加一层 fd 有效性校验,或者采用引用计数 + 延迟回收机制。
提示:不要依赖 epoll_wait 的 timeout 参数做“定时任务”。它的精度受系统 HZ 影响,且在高负载下可能严重漂移。真正的定时器应使用 timerfd_create() 配合 epoll,让内核在指定时间点主动向 epoll 就绪链表注入一个事件。
3. ET 模式下的生死线:一次性读写到底的硬核实现与边界处理
ET(Edge Triggered)模式是 Epoll 性能的命脉,也是绝大多数崩溃和丢包的源头。它的设计哲学很朴素:内核只负责“通知事件发生了”,不负责“通知事件还持续着”。这就像门铃——按一下,你听见了,进去开门;如果门没关严,风又把它吹开了,门铃不会自动再响一次。你得自己盯着门缝,或者等下一次有人按。
所以,ET 模式下处理一个就绪 fd 的唯一正确姿势,就是用循环读/写,直到系统调用返回 EAGAIN(Linux)或 EWOULDBLOCK(POSIX 兼容)。但“循环读”远比听起来复杂。我们以读取一个 TCP socket 为例,写出工业级安全的 ET 读取函数:
ssize_t safe_read_et(int fd, void *buf, size_t count) { ssize_t total = 0; ssize_t n; char *ptr = (char *)buf; while (total < count) { n = read(fd, ptr + total, count - total); if (n > 0) { total += n; } else if (n == 0) { // 对端关闭连接,这是合法的 EOF return total > 0 ? total : 0; } else { if (errno == EINTR) { continue; // 被信号中断,重试 } else if (errno == EAGAIN || errno == EWOULDBLOCK) { // 真正的“读完”标志,退出循环 break; } else { // 真实错误,如 ECONNRESET, EIO 等 return -1; } } } return total; }这段代码看着简单,但藏着三个必须死磕的细节:
第一,EINTR 的处理不是可选项,是必选项。在 Linux 下,任何阻塞式系统调用(包括 read)都可能被信号中断。如果不处理 EINTR,一次 SIGALRM 就可能导致本该读 1024 字节的请求,只读了 32 字节就跳出循环,后续数据永远滞留在内核缓冲区,epoll 再也不通知——因为 ET 模式下,内核认为“我已经告诉过你有数据了”。
第二,必须区分 EAGAIN/EWOULDBLOCK 和其他错误。很多新手会写if (n < 0 && errno != EAGAIN) return -1;,这在大多数情况下没问题,但忽略了 EINTR。更危险的是,有些网络库(如旧版 libevent)会把 EINTR 当作普通错误抛出,导致连接被意外关闭。
第三,read() 返回 0 的含义必须精确理解。它只代表对端执行了 close() 或 shutdown(SHUT_WR),不代表本端不能继续写。但在 HTTP/1.1 Keep-Alive 场景下,如果读到 0,意味着请求体已完整接收,可以立即构造响应并 write() 回去。我曾经在一个长连接网关项目中,因为没正确处理 read()=0,导致客户端发送完 POST 请求后,服务端迟迟不回包,最终超时断连。
写操作同理,但更隐蔽。ET 模式下 write() 也可能只写部分数据就返回 EAGAIN。此时你不能丢弃剩余数据,必须缓存到该连接的 output buffer 中,并在下次该 fd 变为 EPOLLOUT 就绪时继续发送。这就引出了“写就绪”的触发条件:只有当 socket 发送缓冲区(sk->sk_write_queue)有足够空间容纳待发送数据时,内核才会置位 EPOLLOUT。所以,一个常见的优化是:在连接刚建立时(accept 后),先尝试 write() 一次;如果返回 EAGAIN,再 epoll_ctl(EPOLL_CTL_MOD, EPOLLOUT);否则,等真正需要发数据时再注册。这样避免了为每个连接都默认监听 EPOLLOUT,节省内核事件表空间。
注意:不要在 ET 模式下对同一个 fd 同时监听 EPOLLIN 和 EPOLLOUT。这会导致大量虚假的 EPOLLOUT 事件(因为发送缓冲区大部分时间都是有空间的),极大增加 epoll_wait 的返回事件数,抵消 ET 的性能优势。正确的做法是:只在有数据要发且上次 write() 返回 EAGAIN 时,才临时注册 EPOLLOUT;发完立刻 EPOLL_CTL_DEL 或 MOD 回只监听 EPOLLIN。
4. 生产环境的七宗罪:那些让 Epoll 服务器在凌晨三点崩溃的隐藏陷阱
理论再完美,也架不住生产环境的毒打。我在某跨平台系统中维护一个基于 Epoll 的设备管理服务,上线半年,遇到过七类几乎无法复现、但每次爆发都导致大面积设备失联的“幽灵故障”。它们都不在教科书里,却真实存在于每一行 C 代码的缝隙中:
第一宗罪:惊群效应(Thundering Herd)的变种。你以为只有 accept() 才有惊群?错。在多线程模型中,如果多个工作线程同时阻塞在同一个 epoll fd 上,当一个新连接到达时,内核会唤醒所有线程,但只有一个能成功 accept(),其余线程在 accept() 时会返回 EAGAIN。这本身没问题,但问题在于:这些被唤醒又失败的线程,会立即重新进入 epoll_wait(),形成高频空转。在 32 核机器上,这会导致 CPU 占用率瞬间拉满,而真正干活的线程反而抢不到调度权。解决方案不是加锁,而是用 eventfd 创建一个“任务分发管道”,主线程 accept 后,write() 一个整数到 eventfd,由 epoll_wait 监控该 eventfd 的工作线程来领取任务。
第二宗罪:TIME_WAIT 连接吞噬端口。当服务作为客户端频繁连接后端时,本地端口会大量进入 TIME_WAIT 状态(默认 60 秒)。一个端口只能属于一个 socket,65535 个端口很快耗尽。net.ipv4.ip_local_port_range可以扩大范围,但治标不治本。真正有效的方案是:在 connect() 前,对 socket 设置SO_LINGER选项,linger.l_onoff=1, linger.l_linger=0,强制发送 RST 终止连接,跳过 TIME_WAIT。当然,这会牺牲 TCP 的可靠性保证,只适用于你完全信任后端服务可用性的场景。
第三宗罪:大文件传输时的 sendfile() 与 epoll 冲突。sendfile() 是零拷贝利器,但它的行为与 epoll 事件不兼容。当你对一个 socket 调用 sendfile() 传输一个 100MB 文件时,内核会直接从文件页缓存复制到 socket 发送缓冲区。但如果发送缓冲区满了,sendfile() 会阻塞,而此时 epoll 并不会通知 EPOLLOUT——因为 sendfile() 是同步阻塞调用,它绕过了 epoll 的事件注册机制。结果就是:主线程卡死,所有其他连接无人处理。正确做法是:用 splice() 替代 sendfile(),或者将大文件切分成 64KB 块,用普通 write() + EPOLLOUT 事件驱动发送。
第四宗罪:SIGPIPE 信号的无声扼杀。当对端已关闭连接,你还往 socket write(),默认会触发 SIGPIPE 信号,进程直接退出。很多服务没设置signal(SIGPIPE, SIG_IGN),结果半夜某个异常断连的客户端,就把整个服务干掉了。这不是 bug,是设计——但生产环境必须显式忽略。
第五宗罪:mmap() 内存映射文件与 epoll 的页错误竞争。如果用 mmap() 映射一个大日志文件,然后在 epoll_wait() 期间访问未加载到内存的页,会触发 page fault,进而导致进程被挂起等待磁盘 IO。此时 epoll_wait() 也跟着挂起,所有网络事件停滞。解决方案:在 mmap() 后立即调用madvise(addr, len, MADV_WILLNEED),预加载页面。
第六宗罪:UDP socket 的“伪就绪”。UDP 没有连接概念,一个 recvfrom() 调用就能读取整个数据报。但 epoll 对 UDP 的就绪判断,是基于“接收缓冲区中是否有至少一个完整的数据报”。如果客户端发来一个 2000 字节的 UDP 包,而你的 recvfrom() 缓冲区只有 1024 字节,那么这次调用会截断数据,但 epoll 仍认为该 fd “已就绪”,下次还会返回它——因为你没读完“一个完整数据报”。这会导致无限循环。必须用MSG_TRUNC标志先探测包长,再分配足够缓冲区。
第七宗罪:epoll_ctl() 的内存屏障缺失。在多线程环境下,如果线程 A 正在修改某个连接的业务数据结构,线程 B 同时调用 epoll_ctl(EPOLL_CTL_DEL) 删除该 fd,而两者之间没有内存屏障,可能导致线程 A 读到脏数据或崩溃。标准解法:用 pthread_mutex_t 保护 fd 的整个生命周期,或者用 RCU(Read-Copy-Update)机制,但这需要深入内核知识。
提示:用
ss -s命令随时查看 socket 统计,重点关注TCP: inuse(当前 TCP 连接数)、orphan(孤儿 socket 数)、tw(TIME_WAIT 数)。当tw接近inuse的 2 倍时,就要警惕端口耗尽风险。
5. 从单线程到百万连接:Epoll 架构演进的四个真实阶段
一个 Epoll 服务的成长,不是一蹴而就的,而是随着业务压力,被迫经历四次痛苦的架构重构。我在模拟项目X 的三年迭代中,完整走过了这四个阶段,每个阶段都伴随着一次线上事故和一周的深度复盘:
阶段一:单线程 Reactor(< 5000 连接)
这是最经典的模型:一个线程,一个 epoll fd,一个事件循环。所有 accept、read、parse、write 都在这个线程里串行执行。优点是简单、无锁、调试方便;缺点是 CPU 成为绝对瓶颈。当解析一个 JSON 请求需要 50us,而 QPS 达到 2 万时,仅解析就吃掉 100% CPU。这个阶段的代码,核心就是一个 while 循环:
while (!shutdown_flag) { nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1); for (i = 0; i < nfds; i++) { if (events[i].data.fd == listen_fd) { handle_accept(); } else { handle_client_event(events[i].data.fd, events[i].events); } } }此时,handle_client_event()必须是纯计算型,不能有任何阻塞 IO(如数据库查询、HTTP 调用)。一旦引入,整个服务就雪崩。
阶段二:半同步/半异步(5000 ~ 5 万连接)
为了解耦计算和 IO,引入工作线程池。主线程只负责 epoll_wait 和 accept,将新连接或就绪事件封装成 task,放入无锁队列(如 ring buffer),由 worker 线程消费。这里的关键突破是:worker 线程不再持有 epoll fd,它们只做业务逻辑,完成后通过 pipe 或 eventfd 通知主线程 write() 响应。这样,主线程的 epoll_wait() 不会被业务耗时拖慢。但问题随之而来:task 队列的生产和消费速度不匹配。当突发流量到来,队列积压,内存暴涨。解决方案是:在主线程中加入“背压”机制,当队列长度超过阈值,临时停止 accept(),并返回503 Service Unavailable,而不是让队列无限增长。
阶段三:多 Reactor(5 万 ~ 50 万连接)
单个 epoll fd 的性能天花板在 30~50 万事件/秒(取决于 CPU 主频和内核版本)。要突破,必须横向扩展。主流方案是:一个 Acceptor 线程(只 listen/accept),N 个 IO 线程(每个持有一个独立的 epoll fd,负责一组连接的 read/write),M 个计算线程(处理业务逻辑)。连接分配策略至关重要。我们测试过三种:
- 轮询分配:简单但不均衡,短连接多的业务会导致某些 IO 线程过载;
- Hash 分配(fd % N):稳定但扩容困难;
- 一致性 Hash:最优,但实现复杂。
最终选择了“fd % N” + 运行时热迁移(当某线程负载持续 > 80%,将部分连接的 fd 通过 epoll_ctl(EPOLL_CTL_MOD) 迁移到其他线程)。这需要所有 IO 线程共享一个全局连接池(用 RCU 保护),并实现跨线程的 fd 传递协议。
阶段四:DPDK + 用户态协议栈(> 50 万连接)
当内核协议栈成为瓶颈(如 SYN Flood 攻击下,内核 conntrack 表耗尽),就必须绕过内核。这时,Epoll 本身已无意义,因为网络包根本不到内核。我们接入 DPDK,用轮询方式从网卡收包,在用户态解析 TCP/IP,自己维护连接状态机,再将就绪的“虚拟连接”事件,通过 shared memory + ring buffer 推送给业务线程。Epoll 在这里退化为一个“内部事件总线”的消费者,只负责协调用户态各模块间的通信。这个阶段,代码量爆炸式增长,但 QPS 从 50 万提升到 200 万,延迟从 100us 降到 15us。代价是:失去所有内核网络功能(如 iptables、conntrack、TCP 重传),所有协议栈逻辑必须自己实现和调优。
这四个阶段没有优劣之分,只有是否匹配当前业务规模。强行跳过阶段二上阶段三,只会让代码变成一团无法维护的意大利面条。Epoll 的威力,不在于它多快,而在于它清晰地暴露了系统瓶颈所在——当你发现 epoll_wait() 的平均耗时从 0.1us 涨到 10us,你就该知道,是时候重构了。
6. 调试 Epoll 的终极武器:strace、eBPF 与自定义内核探针
写好 Epoll 代码只是第一步,让它在生产环境稳定运行,靠的是强大的可观测性。我整理了一套经过千锤百炼的调试组合拳,覆盖从开发到线上应急的全场景:
第一层:strace —— 系统调用的 X 光片
这是最基础也最有效的工具。启动服务时加上:strace -T -tt -e trace=epoll_wait,epoll_ctl,accept,read,write,close -p <pid> 2>&1 | grep -E "(epoll|accept|read|write)"
-T 显示每次系统调用耗时,-tt 显示微秒级时间戳。你会看到类似:epoll_wait(3, [{EPOLLIN, {u32=5, u64=5}}], 1024, -1) = 1 <0.000012>read(5, "GET /health HTTP/1.1\r\n", 8192) = 22 <0.000003>
如果发现某个 epoll_wait() 耗时长达 10ms,而返回事件数为 0,那基本可以断定:你的事件循环里有阻塞操作(如 sleep、数据库查询);如果 read() 耗时 50ms,说明内核缓冲区数据量巨大,或者磁盘 IO 拖累(如 mmap 了大文件)。
第二层:eBPF —— 内核内部的实时监控
strace 只能看到用户态视角,而 eBPF 可以深入内核。用 bpftrace 写一个脚本,监控所有 epoll_wait 的返回值分布:
#!/usr/bin/env bpftrace uprobe:/lib/x86_64-linux-gnu/libc.so.6:epoll_wait { @epoll_wait_count = count(); @epoll_wait_latency = hist(arg2); // arg2 是返回耗时(纳秒) }运行后,你会得到一个直方图,清楚看到 99% 的 epoll_wait 耗时在 1us 以内,但有 0.1% 超过 100us——这就是性能毛刺的来源。再结合bpftrace -e 'kprobe:tcp_v4_do_rcv { @bytes = hist(args->skb->len); }',可以确认是否是大包导致处理延迟。
第三层:自定义内核探针 —— 定向爆破
当 eBPF 也无法定位时,就得祭出终极手段:在内核源码中添加 printk。比如,你想确认某个 fd 为什么没被加入就绪链表,就在fs/eventpoll.c的ep_poll_callback()函数开头加:
printk(KERN_INFO "ep_poll_callback: fd=%d, revents=0x%x, state=%d\n", ep_item_to_file(epi)->f_inode->i_ino, revents, sk->sk_state);然后重新编译内核模块(无需重装内核),用insmod加载。这种方法侵入性强,但精准无比。我曾用它发现一个 bug:当 socket 处于 TCP_CLOSE_WAIT 状态时,内核错误地清除了 sk->sk_epoll_mask,导致即使对端发 FIN,epoll 也不通知。
第四层:火焰图 —— 全局性能热点
用 perf record 采集 60 秒:perf record -e cpu-clock -g -p <pid> -- sleep 60
然后生成火焰图:perf script | stackcollapse-perf.pl | flamegraph.pl > epoll_flame.svg
在图中,如果epoll_wait节点下面堆着大量的json_parse或mysql_query,那就说明业务逻辑阻塞了事件循环——这是架构层面的问题,不是 Epoll 的锅。
注意:线上环境禁用 strace -p,因为它会暂停目标进程,导致服务不可用。应使用
perf或ebpf这类低开销工具。对于关键服务,建议在启动时就集成 eBPF 监控模块,将关键指标(epoll_wait 平均耗时、最大事件数、ET 模式下 EAGAIN 次数)暴露为 Prometheus metrics,实现分钟级告警。
7. 我的 Epoll 开发清单:一份写了十年、仍在更新的避坑手册
这份清单,是我从 2014 年第一次用 Epoll 写聊天服务器,到今天维护百万级设备连接网关,累计踩过的所有坑、熬过的所有夜、改过的所有 bug,浓缩成的 12 条血泪准则。它不讲原理,只告诉你“什么必须做,什么绝对不能做”:
永远用 EPOLLET,永远不用 EPOLLLT。LT 模式看似简单,实则是性能黑洞。它让你产生“我处理完了”的错觉,而内核在背后默默积累就绪事件,直到某次 epoll_wait 返回上千个重复 fd,CPU 瞬间拉满。ET 是唯一生产环境选择。
每个 fd 必须设置 non-blocking。
int flags = fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK);。这是 ET 模式的前提,没有商量余地。accept() 返回的 new_fd,必须立即设置 non-blocking,再立即 epoll_ctl(ADD)。顺序不能错。如果先 ADD 再 set non-blocking,中间窗口期可能导致内核在 fd 还是 blocking 状态时就触发 EPOLLIN,read() 阻塞。
ET 模式下,read() 和 write() 必须循环到 EAGAIN/EWOULDBLOCK。写一个宏:
#define READ_UNTIL_EAGAIN(fd, buf, len) do { ... } while(0)
所有读操作都用它,杜绝手误。绝不在线程间直接传递 fd。fd 是进程级资源,跨线程使用必须确保其生命周期被正确管理。要么用引用计数,要么用消息队列传递 fd 的“逻辑标识”,由目标线程自己 open() 或 dup()。
epoll_ctl(DEL) 失败时,不要 panic,也不要 retry。如果返回 EBADF,说明 fd 已关闭,这是正常现象;如果返回 ENOENT,说明本来就没注册,忽略即可。retry 可能导致竞态。
为每个连接分配独立的读写缓冲区,大小至少 64KB。小缓冲区会导致频繁的 EAGAIN,增加 epoll_wait() 调用次数;大缓冲区则减少系统调用,但增加内存占用。64KB 是 Linux 默认 socket 缓冲区大小,最稳妥。
在 fork() 子进程前,必须先 close() epoll fd。否则子进程会继承该 fd,导致父进程 epoll_wait() 时,子进程也在等,事件被随机分发,逻辑彻底混乱。
用 SO_REUSEPORT 替代 SO_REUSEADDR。当需要多进程监听同一端口时,SO_REUSEPORT 让内核在多个 socket 间负载均衡,而 SO_REUSEADDR 只是允许 bind() 重用地址,实际仍由一个进程处理所有连接。
记录每个连接的创建时间、最后活跃时间、已读字节数、已写字节数。这些数据不用于业务,只用于诊断。当出现连接泄漏时,按“最后活跃时间”排序,一眼就能看出是哪个模块忘了 close()。
在程序启动时,用 getrlimit(RLIMIT_NOFILE, &rlim) 检查并设置 ulimit -n。不要假设系统默认值够用。
rlim.rlim_cur = rlim.rlim_max = 1048576; setrlimit(RLIMIT_NOFILE, &rlim);这行代码,应该出现在 main() 的第一行。最后,也是最重要的一条:Epoll 不是银弹,它是把双刃剑。它给你极致的性能,也要求你承担极致的责任。当你写出一个完美的 Epoll 服务器时,你不是在调用一个 API,你是在和 Linux 内核签订一份契约——你承诺严格遵守它的事件语义,它承诺给你 O(1) 的就绪通知。违背契约的代价,不是程序崩溃,而是你在凌晨三点,对着 1000 行 strace 日志,试图理解为什么一个 fd 的 EPOLLIN 事件,被内核悄悄吞掉了。
这份清单,我每年都会删掉几条,加上几条。删掉的,是已经被内核修复的旧 bug;加上的,是新业务场景挖出的新陷阱。Epoll 的世界,没有终点,只有不断逼近内核真相的旅程。