
1. 为什么要聊 epoll从 I/O 复用说起1.1 没有 I/O 复用的时候我们拿什么扛并发我记得刚入行做 Linux 后端的时候第一个正式项目是个设备接入服务单机撑个一两千连接就觉得挺吃力的。那会儿最原始的做法是“一个连接一个线程”主线程 accept 到新连接就 pthread_create 开一个线程去处理线程里阻塞在 read 上。这套模型写起来最直观业务逻辑也全是线性流程小规模、内部工具、联调环境里完全够用。但连接数一旦上来问题就变得很现实。Linux 线程默认栈空间 8MB虽然这是虚拟内存、按需分配但几千个线程光创建和销毁就是开销上下文切换更是实打实的 CPU 成本。压测的时候你会看到系统态 CPU 占比莫名其妙地高业务没跑多少OS 调度器倒是忙得不行。换一个思路把 fd 都设成非阻塞主循环挨个 read也就是忙轮询看起来避开了多线程但同样的坑每次都要把所有 fd 从 0 到 N 扫一遍绝大多数 fd 没有事件这一轮轮询就是在白白浪费 CPU。于是“I/O 复用”这个概念才真正变得重要。这里的“复用”指的是让一个线程、一个循环同时管理成千上万个 fd 的读写状态内核替你去判断哪些 fd 就绪了你再针对性地处理。epoll 就是 Linux 上这套方案的事实标准也成了我在后续大多数网络服务里的首选事件模型。1.2 select 和 poll 的“先天不足”讲到 I/O 复用很多人第一反应是 select。select 的核心问题是 fd_set 是个位图上限由 FD_SETSIZE 决定通常只有 1024。你要么改头文件重新编译要么换方案。更麻烦的是它的工作方式每次调用都要把整个 fd 集合从用户态拷贝到内核态内核把所有 fd 扫一遍能就绪的就做标记然后返回。用户拿到返回之后还需要再次遍历 fd 集合把“哪些 fd 有事”这一信息比较出来。这一整套流程的复杂度是 O(n)而且这份拷贝每次调用都逃不掉fd 数量越多越痛。poll 解决了一部分问题它用 pollfd 数组替代位图突破了 1024 的限制也不用再改宏重编译。但 poll 的本质没有变依然要全量拷贝用户态数据到内核依然要内核线性扫描所有 fd。说白了select 和 poll 都还是“轮询”只是把业务自己写 for 循环轮询的活外包给了内核复杂度该是 O(n) 还是 O(n)只是省掉了用户态空转。我后来面试别人时喜欢用这个例子一个饭馆里一千桌客人每个客人都有可能叫人。select 的服务员每轮巡场都要挨个问“要加水吗”问到之后还得回来告诉后厨具体是哪桌。而 epoll 的思路是前台上挂一块小黑板哪个客人需要服务就把桌号写上服务员只需要看黑板不用全场转悠。而且服务完的桌号会被擦掉只有新的需求出现才会重新写上。1.3 epoll 的破局思路事件驱动而不是轮询epoll 把“你要监控哪些 fd、关心哪些事件”这个集合常驻在内核里不再每次调用都重新拷贝一遍。你要加一个 fd用 epoll_ctl 告诉内核要改、要删也是同一个接口。内核把这个监控集合组织成红黑树增删改都是 O(log n) 的复杂度。当某个 fd 上有数据到达或状态变化内核通过回调机制把这个 fd 挂到一条就绪链表上。用户每次调用 epoll_wait拿到的只可能是“已经就绪”的 fd跟总连接数没有关系。也就是说epoll 把业务从“主动查询”变成了“被动通知”把复杂度从 O(n) 降到了 O(就绪事件数量)。没有事件的时候线程可以舒舒服服睡在 epoll_wait 上有事件的时候处理完当前这批下次继续等。这也解释了为什么高并发场景基本默认用它。不过我也想泼一盆冷水epoll 解决的是“如何高效知道哪些 fd 可读可写”的问题事件来了之后怎么读、怎么写、业务怎么处理仍然是你自己的事。很多人以为用了 epoll 就自动高性能其实不是。它只是把“等待”这个环节省掉了业务流量管理、内存管理、线程模型一个都省不了。2. epoll 核心机制拆解三个接口与内核数据结构2.1 epoll_create、epoll_ctl、epoll_wait 的分工epoll 的使用很集中一共就三个接口。首先是 epoll_create1老版本里也有 epoll_create那个 size 参数在 2.6.8 之后其实已经无所谓了传个正数就行。epoll_create1 更推荐因为可以传 EPOLL_CLOEXEC避免 fork 之后子进程意外继承到 epoll fd。然后所有“监控谁、关心什么”的操作都走 epoll_ctl。它的第一个参数是 epoll 的 fd第二个参数是三选一EPOLL_CTL_ADD 增加一条监控、EPOLL_CTL_MOD 修改已有监控、EPOLL_CTL_DEL 删除监控。第三个参数是真正的 fd第四个参数是一个 epoll_event 指针里面塞上你要关心的事件掩码和数据。这个数据字段是个联合体可以放 fd也可以放一个用户自定义结构体指针。项目简单的时候直接放 fd 就行项目复杂了建议放指针指向你自己定义的连接结构体这样事件回调里直接能拿到上下文省得再查一遍表。epoll_wait 是收果实的调用传入一个 epoll_event 数组、数组大小和超时时间返回就绪事件的个数。超时时间单位是毫秒-1 表示一直等待。这里有个容易忽略的点maxevents 只是告诉内核“我最多接受这么多个就绪事件”如果同时有 2000 个连接就绪而你只传了 1024剩余事件不会被丢会留到下一次 epoll_wait 再返回。不要把 maxevents 和能监控的 fd 数量搞混。事件掩码里常见的有 EPOLLIN、EPOLLOUT、EPOLLERR、EPOLLHUP、EPOLLRDHUP。EPOLLIN 表示可读EPOLLOUT 表示可写EPOLLERR 和 EPOLLHUP 表示异常和对端挂断EPOLLRDHUP 则是对端关闭了连接或半关闭。很多人只处理 EPOLLIN 和 EPOLLOUT却忘了 EPOLLERR 和 EPOLLHUP 就算你不想关心内核也会把它们报告给你。这是线上很多 fd 泄漏的根源必须在事件分发里显式处理这几个位。2.2 红黑树与就绪链表epoll 核心模型每创建一个 epoll fd内核里就对应一个 eventpoll 结构体它内部最重要的两个数据结构是红黑树和就绪链表。红黑树存的是你通过 epoll_ctl 注册进去的所有 fd 节点节点里记录着 fd 和事件掩码就绪链表挂的是当前有事件发生、需要通知用户态的 fd。当某个 fd 上来了数据网络协议栈在处理完中断和对应协议流程后会走到 epoll 注册的回调函数把红黑树里对应的节点摘到就绪链表上同时唤醒在 epoll_wait 上睡眠的进程。用户态醒来后epoll_wait 把就绪链表上的节点复制到用户提供的数组里返回数量。整个过程不扫描全部监控集合只处理有事件的那几个节点所以性能不会随着连接数一起退化。如果你想看具体实现Linux 源码的 fs/eventpoll.c 是很好的学习材料重点看 ep_poll_callback 和 ep_send_events_proc 这两个函数。前者负责在 fd 就绪时把节点挂到就绪链表后者负责把就绪链表的内容拷贝给用户。理解了这两个函数基本就理解了 epoll 的工作核心。2.3 LT 与 ET触发模式选错线上容易出事LT 全称是水平触发也是 epoll 的默认模式。它的特点是如果 fd 上有数据没读完epoll_wait 就会一直告诉你“这个 fd 可读”。哪怕你一次只读 1 字节剩下 3KB 留在缓冲区里下次调用 epoll_wait 还是会通知你。好处是编程非常简单不用担心漏事件坏处是同一个数据可能会反复唤醒进程如果你想追求极致性能无效唤醒的比例会高一些。ET 全称是边缘触发它的触发条件是从“无事件”变成“有事件”的那一刻。比如客户端突然发来 4KB 数据缓冲区从空变为非空epoll 会通知你一次。如果你这次只 read 了 1KB剩下 3KB 还在缓冲区里内核不会因为你没读完就再通知一次因为对内核来说状态没有再次发生跳变。所以 ET 模式有两大约束fd 必须是非阻塞的读数据必须循环读到返回 EAGAIN。漏读的后果很隐蔽客户端那边等你的响应服务端这边却再也没有收到任何事件卡死得很冤。还是用类比来说LT 像自动门只要有人站在门口它就一直开着你什么时候进去都行ET 像门铃只有客人按下那一瞬间响一声你错过了就只能等下一次有人按。实际项目里nginx 的 event 模型默认使用 ET因为它在事件循环层面做了非常精细的读就绪管理redis 则更偏爱 LT实现简单可靠在它那个业务模型下性能已经达标。我的建议是新手先用 LT 上手把事件循环、读写状态理清楚再切 ET 去体验一下“从有到无”的坑。3. 动手实现一个基于 epoll 的并发 echo 服务器3.1 环境准备与项目结构说了半天原理不如直接上一个能跑的例子。我这里选一个 echo 服务器来演示客户端发什么服务端原样回什么够简单又能把 epoll 里最关键的逻辑讲透。环境就用普通的 Ubuntu 22.04gcc 编译不需要额外依赖。整个项目就一个 C 文件直接 gcc -O2 -o epoll_echo epoll_echo.c 就能编出来。测试也不用什么高级工具开三个终端nc localhost 9000 挨个连上去看所有连接都能正常收发。如果你想看系统调用层面发生了什么用 strace 跟踪 epoll_wait、accept、read、write 的序列能比读十篇博客更直观地理解 epoll 的工作方式。3.2 主循环监听、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 netinet/tcp.h #include arpa/inet.h #define MAX_EVENTS 1024 #define BUFFER_SIZE 4096 #define LISTEN_BACKLOG 128 static 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 argc, char *argv[]) { int port 9000; if (argc 1) port atoi(argv[1]); int listen_fd socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); if (listen_fd 0) { perror(socket); return 1; } 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 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, LISTEN_BACKLOG) 0) { perror(listen); return 1; } 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); return 1; } struct epoll_event events[MAX_EVENTS]; char buf[BUFFER_SIZE]; printf(echo server running on port %d, pid%d\n, port, getpid()); 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) { int fd events[i].data.fd; if (fd listen_fd) { for (;;) { struct sockaddr_in client_addr; socklen_t client_len sizeof(client_addr); int conn_fd accept(listen_fd, (struct sockaddr *)client_addr, client_len); if (conn_fd 0) { if (errno EAGAIN || errno EWOULDBLOCK) break; perror(accept); break; } set_nonblock(conn_fd); ev.events EPOLLIN | EPOLLRDHUP; ev.data.fd conn_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, ev); printf(accept new connection: fd%d\n, conn_fd); } } else { if (events[i].events (EPOLLERR | EPOLLHUP)) { close(fd); epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); printf(close fd%d (error or hup)\n, fd); continue; } if (events[i].events EPOLLIN) { for (;;) { ssize_t len read(fd, buf, sizeof(buf)); if (len 0) { for (ssize_t off 0; off len; ) { ssize_t w write(fd, buf off, len - off); if (w 0) { if (errno EAGAIN || errno EWOULDBLOCK) break; perror(write); goto next_fd; } off w; } } else if (len 0) { close(fd); epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); printf(close fd%d (eof)\n, fd); break; } else { if (errno ! EAGAIN errno ! EWOULDBLOCK) { perror(read); close(fd); epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); } break; } } } next_fd:; } } } close(epfd); close(listen_fd); return 0; }这段代码虽然讲的是 echo但它已经包含我平时写事件循环的大部分习惯监听连接单独处理、新连接设置非阻塞、对端关闭时显式删除 fd、异常事件单独走一个分支。3.3 读事件与写事件的真正含义先说读事件。对监听 fd 来说可读表示 accept 队列里有已完成握手的连接对业务 fd 来说可读表示接收缓冲区里有数据。事件循环里accept 我用了内层循环来把当次可用的连接全部取走直到返回 EAGAIN这样可以减少一次 epoll_wait 唤醒量。对业务 fd 的读我在 LT 模式下也写了 while 循环读而不是只读一次。这样做不是因为 LT 要求如此而是为了减少唤醒。LT 下如果不读完内核反正还会继续通知但如果你每次都只读 4KB而数据有 40KB那就要反复 sleep-wakeup 十次白白浪费 CPU。读干净到 EAGAIN本质上是用一次循环换取更少的唤醒次数。再说写事件。EPOLLOUT 表示 socket 发送缓冲区有空间可写。这里有一个新手最容易犯的错觉得要发送数据了就把 EPOLLOUT 注册上等可写事件来了再写。问题在于大多数情况下发送缓冲区都有空间EPOLLOUT 会一直就绪epoll_wait 就会不停唤醒你最后变成一个发散的全速循环。正确的做法是需要发送时先尝试直接 write只有当 write 返回 EAGAIN 时才临时注册 EPOLLOUT等可写事件到来后把剩余数据写完再立刻将 EPOLLOUT 从事件掩码里去掉。用一句话记就是EPOLLOUT 是易碎品要用的时候才注册用完了马上撤。3.4 把 LT 改成 ET改动一行的背后是整套约束在上面代码基础上改成 ET就是把新连接的事件从 EPOLLIN 改成 EPOLLIN | EPOLLET就这么一行。但这一行背后代码里另外有三处必须满足的隐式约束第一所有 fd 必须非阻塞否则 read 到没数据时会卡住线程第二业务 fd 的可读处理必须是 while 循环读到 EAGAIN否则会漏数据第三listen fd 的 accept 循环也要一直取到 EAGAIN否则 listen 的事件状态没有完全处理完下一个连接到来时可能无法及时触发新的边缘通知。我把这段代码跑在本地用一个脚本模拟十几个并发连接LT 和 ET 的 echo 结果都能对得上。但肉眼真正看出差别的是 strace用 strace -f -e traceepoll_wait,read,write -p 进程号 之后你会发现 ET 模式下每个连接的数据读取基本都集中在第一次通知里而 LT 模式如果读得不够积极同一个 fd 会反复出现在 epoll_wait 的返回里。这种系统调用层的差异是理解触发模式最好的参照。4. 真实项目中踩过的坑与排查思路4.1 惊群多线程多进程都躲不开的问题如果你的服务是单线程事件循环epoll 用起来是最省心的。可一旦你要利用多核让多个线程同时调用 epoll_wait 去等待同一个 epoll fd惊群就找上门了。所谓惊群就是一个 fd 就绪时内核会把所有等待在这个 epoll fd 上的线程全部唤醒但最后真正拿到这个事件去处理的只有其中一个其他线程被白叫醒一轮徒增上下文切换。解决思路有几个方向。最简单的是给每个线程维护一个独立的 epoll 实例再配合 SO_REUSEPORT 让多个 socket 同时监听同一个端口由内核做四元组的负载均衡。另一种是保留共享 epoll 实例但事件注册时加上 EPOLLEXCLUSIVE 标记这是 Linux 4.5 之后提供的排他唤醒机制会让内核尽量只唤醒一个等待者。从我的实践经验看如果业务里有大量的短连接SO_REUSEPORT 按连接分散更干净如果是长连接且状态都在共享内存里EPOLLEXCLUSIVE 也更可控。4.2 ET 模式数据读不完、业务处理不过来ET 模式下的漏读危机上面已经说过了。但还有一个同样高频的问题如果业务逻辑很重比如要在事件处理里写数据库、调外部接口那直接在事件循环里做肯定不行整个服务会卡住。这时通常会把业务丢到线程池里事件循环只做读数据、封装任务、入队这三件事。这里要小心同一 fd 的共享状态。假设客户端一发数据事件循环读取后放到队列线程池里的 worker 在处理过程中又收到同一连接的下一次事件那两次事件的上下文可能就交叉了。如果任务之间没有隔离很容易出现数据错乱。比较常见的做法是给每个连接一把锁或者用 EPOLLONESHOT 让这个 fd 在处理完之前不再触发任何事件worker 处理完成后通过 EPOLL_CTL_MOD 重新武装再回到事件循环继续监听。这个模式在高并发应用服务器里非常常见。4.3 EPOLLONESHOT 与 EPOLLEXCLUSIVE 的使用边界很多人把 EPOLLONESHOT 和 EPOLLEXCLUSIVE 搞混它们的出发点完全不同。EPOLLONESHOT 是针对单个 fd 的“只触发一次”机制事件触发一次之后即使 fd 仍然就绪epoll 也不会再通知你直到你用 EPOLL_CTL_MOD 手动重新添加。它的价值在于防止多个执行单元同时处理同一个 fd尤其是线程池模型下避免数据竞争。EPOLLEXCLUSIVE 则是针对等待队列的“排他唤醒”机制多个线程或进程在同一个 epoll fd 上等待时内核尽量只唤醒一个等待者。它关心的是“减少无效唤醒”不是“防止并发处理同一个 fd”。这两个机制并不互斥如果你既想排他唤醒又想让一个 fd 在处理完成前不被打扰可以组合使用但必须清楚每个标记解决的是哪个层面的问题。下面是这几个模式的选择参考我整理成了一张表对比项普通模式EPOLLETEPOLLONESHOTEPOLLEXCLUSIVE核心目的持续通知就绪状态只在状态跳变时通知一次触发一次后自动屏蔽唤醒等待队列中的一个线程是否需要非阻塞 fd不强制强制不强制但建议不强制主要风险无效唤醒较多漏读、处理不干净忘记重新武装会停摆使用不当仍可能惊群典型场景简单事件循环nginx 等高性能服务器线程池处理同连接多线程共享 epoll 实例4.4 常见问题速查表平时在群里、面试里遇到 epoll 相关的问题我发现很多现象其实有固定的套路这里直接放一张排查速查表方便你对照实际情况逐个排除。现象可能原因排查方向客户端发数据后服务端一直不返回ET 漏读后续事件不再触发确认读循环是否读到 EAGAINepoll_wait 返回极频繁CPU 高注册了 EPOLLOUT 且未及时移除检查事件掩码的 EPOLLOUT 管理大量连接处于 CLOSE_WAIT服务端收到 EOF 后没有 close fd检查 EPOLLRDHUP / read 返回 0 分支内存持续增长事件处理里动态分配未释放或 fd 泄漏用 valgrind 或统计连接数变化多线程下同一连接数据错乱多个线程同时处理一个 fd考虑 EPOLLONESHOT 或连接级锁压测时 accept 队列溢出listen backlog 过小调大 backlog配合 ss -lnt 观察惊群严重共享 epoll fd 未做排他处理考虑 SO_REUSEPORT 或 EPOLLEXCLUSIVE排查工具方面我常用的三件套是 strace、ss 和 lsof。strace 看系统调用序列ss -tnp 看连接状态和 fd 归属lsof -p 进程号看进程打开的 fd 列表。绝大多数 epoll “诡异”问题在这三件套面前都藏不住。5. epoll 的能力边界与新一代 I/O 模型5.1 epoll 的高并发成绩单为什么能撑住百万连接epoll 常被提起的一个标签是“百万连接支持”。这里要拆开看支持百万连接不代表百万高峰值 QPS它主要是说 epoll 本身的等待和唤醒效率不会因为连接数增长而明显退化。真正决定能不能扛住百万连接的关键其实是内存和 fd 上限。每个 TCP socket 在内核里都有接收缓冲区、发送缓冲区、socket 结构体等以我实测经验看一个空闲连接的内核内存开销通常在几十 KB 量级用户态再为每个连接保存一点上下文百万连接对应的内存占用是相当可观的。这也是为什么真正的百万连接服务都是调过大量内核参数的。另外一个是 fd 数量上限默认的 ulimit -n 通常是 1024必须调大。但别只调用户态系统级的 fs.file-max 和单进程的 limits 都要一起确认。我见过有人组里调了 ulimit但没看 systemd 的 LimitNOFILE最后线上服务在启动后仍然报“too many open files”。这类问题排查起来并不难但往往很耗时间。5.2 io_uring 来了epoll 还值得学吗近几年 io_uring 的话题很热它通过内核与用户态共享环形缓冲区来减少系统调用次数进一步降低 I/O 路径上的开销对存储密集型和部分网络场景提升都很明显。那 epoll 是不是该被淘汰了我的看法正好相反至少在接下来的很长一段时间里epoll 依然值得认真学。首先现有技术栈里大量成熟的高性能组件还是基于 epoll 构建的比如 nginx、redis、很多网关和中间件。你接手一个系统读它的事件循环代码时理解 epoll 是前提。其次epoll 背后的事件驱动思想是理解 io_uring 以及更高层异步框架的底层基础。早年我们调 epoll 的触发模式、就绪列表、惊群问题这些经验放到 io_uring 上依然有迁移价值因为问题本质都是“何时处理、由谁处理、怎么处理共享状态”。先学 epoll再看 io_uring路径其实是比较顺的。5.3 一点个人实战体会最后分享一个我自己的经历。刚转做高性能网络服务那阵子我自认为对 epoll 已经很熟结果第一次在项目里大量使用 LT 模式时还是踩了 EPOLLOUT 的坑。那时为了图方便把每个连接都同时注册了 EPOLLIN 和 EPOLLOUT结果压测脚本一跑服务端 CPU 直接拉满epoll_wait 返回频率高得吓人。后来排查才发现根本不是业务慢而是“可写”这个事件几乎永远成立服务器把所有精力都花在了“发现自己可写”这件事上。从那以后我给自己定了三条规矩EPOLLOUT 只按需注册用完立刻撤异常事件和正常读写事件分开处理每次 epoll_wait 返回后先理清 fd 归属再碰数据。这三条看着简单却替我省下了很多排查时间。如果你也正在写自己的事件循环或者打算把旧服务的 select 换成 epoll希望这篇文章能让你少走几步弯路。毕竟这类问题不是看多少文档就能彻底避免的真正上手跑一轮压测、踩一次坑记得比谁都牢。