
1. 面试官为什么死磕 epoll多路复用的江湖地位上个月面腾讯二面面试官在聊完项目之后突然追问了一句让我手心冒汗的问题“你的在线服务撑过 10 万连接说说看为什么最后选了 epoll它凭什么比 select 和 poll 快”说实话这个问题表面上是考网络 IO 的经典八股实际上是在验证你有没有真正理解一条数据从网卡到用户态进程的完整流转路径。如果你能讲清楚 select/poll 慢在哪、epoll 绕开了哪些成本面试官基本就能判断你平时写网络服务是“会调用 API”还是“吃透了原理”。这篇文章就把我当时现场整理出来的思路完整复述一遍顺便把我后来在压测和线上踩过的坑一起写出来。适合两类人看一类是正在准备后端或基础架构岗位面试的开发者另一类是项目里用了 epoll 但一直没搞清楚它快在哪的同学。我会从网络 IO 模型的演进讲起拆开 select/poll 的瓶颈点再深入 epoll 的内核数据结构最后给出一套可以直接抄作业的 ET 模式回显服务代码。1.1 网络 IO 模型的演进路径C10K 逼出来的选择先回到最原始的场景。一个最简单的 TCP 服务就是一个线程阻塞在 read 上来一个连接就开一个线程处理。连接少的时候完全没问题但连接一旦多起来线程本身就是昂贵的资源而且大部分线程处于“等数据”的状态CPU 空转。这种阻塞式模型最大的问题是资源利用率太低。于是有了非阻塞 IO。你把 fd 设成 O_NONBLOCKread 没有数据时立即返回而不是挂起。但问题也明显如果不阻塞你需要自己去轮询每一个 fd看它是否就绪。一个连接一个系统调用10 万连接就是 10 万次系统调用光是上下文切换就能把 CPU 烧穿。你说是阻塞好还是非阻塞好都不是最优解。多路复用就是在这个背景下被大规模使用的。select、poll、epoll 都属于这类模型一次调用同时监视几百上千个 fd内核帮你判断哪些 fd 就绪你只处理这些就绪的 fd。从“一个连接一个线程”到“一个线程管一万个连接”这是质的飞跃。而 epoll 之所以能成为 Linux 上的终极答案是因为它在 select/poll 的基础上从根本上换了一套事件通知机制。这套机制就是整个面试问题的核心。1.2 面试真正要听的东西从 API 到内核流转面试官问“epoll 为什么比 select 快”想听的不是一句“epoll 效率更高”而是有层次的回答先定义问题场景再指出 select/poll 的瓶颈最后说明 epoll 如何用内核数据结构加事件回调避免瓶颈。很多候选人卡在第二步说不清 select 慢在哪还有一部分人把红黑树、就绪链表背得滚瓜烂熟但一问到“epoll_wait 返回之后数据还在内核缓冲区你怎么把它读出来”就露馅了。真正的理解是select/poll 是“主动查问”每次调用都要把所有 fd 过一遍epoll 是“被动通知”哪个 fd 有事件内核主动告诉你。这个差异看似很小实际是两个时代的设计思路。下面先从“病根”说起看看 select 和 poll 到底慢在哪里。2. select 和 poll 的性能账慢得有理有据2.1 select 的三个硬伤位图、全量拷贝、线性扫描select 的典型用法大家都见过先 FD_ZERO再 FD_SET 把 fd 加进 fd_set然后调用 select。fd_set 本质上是一个固定大小的位图每一位代表一个 fd。Linux 上的 FD_SETSIZE 通常是 1024所以 select 默认最多监视 1024 个 fd这就是“1024 上限”的来历。并不是内核不让你用更多而是这个位图结构在设计之初就固定了容量。第一个硬伤是容量有限。第二个硬伤是拷贝开销。每次调用 select你都要把读、写、异常三个 fd_set 从用户态拷贝到内核态内核处理完还要把结果拷回来。你可以简单估算一下如果监视 1024 个 fd三个 fd_set 大概是 384 字节不大但 select 的调用频率极高每次都要做这轮搬运连接越多、调用越频繁这个开销就越刺眼。第三个硬伤才是致命的内核拿到 fd_set 后需要从 0 开始线性扫描所有 bit逐个检查事件是否发生。注意是扫描你自己注册的那 1024 个 fd而不是扫描“就绪的 fd”。假设 1 万连接里只有 10 个活跃连接select 这轮还是要挨个检查 1 万个 bit。大量 CPU 时间浪费在无事件发生的 fd 上。更坑的是select 返回后内核会修改 fd_set你下次调用前必须重新 FD_ZERO、重新 FD_SET这个“重置成本”很多人容易忽略。2.2 poll 补了一半数量解决了扫描还在poll 的出现首先解决了 1024 的限制。它不再用位图改用 pollfd 数组数组里每个元素包含 fd、events你关心的事件、revents内核返回的实际事件。理论上只要内存够你可以传入任意数量的 fd。另外events 和 revents 分离不需要每次调用前像 FD_ZERO 那样全部重置这是一个重要的改进。但 poll 最核心的问题一点没变每次调用仍然要把整个 pollfd 数组从用户态拷贝到内核态内核拿到数组后还是要暴力遍历所有 fd检查每个 fd 是否有事件返回之后应用层还是要重新遍历整个数组找到 revents 非零的那些项才知道哪些 fd 需要处理。整体复杂度依旧是 O(n)n 是监视的 fd 总数。我把 poll 形容为“家具搬得更整齐了但还是在挨家挨户敲门问有没有事”。它把容量天花板打掉了却没有把“全量遍历”这个成本结构打掉。2.3 单次调用的成本公式无用功占比太高把 select 和 poll 的成本拆开看一次多路复用调用的开销大约是三段相加用户态和内核态之间的数组拷贝成本、内核态的线性扫描成本、应用层的就绪筛选成本。这三段全都和 fd 总数成正比。C10K 问题的本质就在这里1 万连接、几十个活跃连接select/poll 每轮要做 9900 多个 fd 的无用检查无用功占比超过 99%。连接规模越大浪费越离谱。还有一个隐藏开销内核在每次调用时需要把你关注的所有 fd 放入等待队列然后再从等待队列移除。select 和 poll 每次调用都要重新做“挂入队列”和“摘出队列”这两步连接数上来之后这些队列操作本身也很耗时。而这一切在 epoll 里都被刻意设计掉了具体怎么做到的下一章细说。3. epoll 的底层设计三件套与三个关键机制3.1 API 三件套create、ctl、wait 各管一段epoll 的接口非常精简就三个函数。epoll_create 负责在内核创建一个 eventpoll 对象这个对象内部有三个核心结构一棵红黑树用来管理所有注册进来的 fd一个就绪链表用来挂载已经发生了事件的 fd一个等待队列用来让调用 epoll_wait 的进程休眠等待。注意Linux 2.6.8 之后 epoll_create 的 size 参数基本被忽略随便传个正整数就行不用纠结。epoll_ctl 负责往红黑树里添加、修改、删除 fd。每注册一个 fd内核会创建一个 epitem 节点节点里保存 fd、事件掩码、回调函数指针等关键信息。红黑树的查找、插入、删除都是 O(log n)比 select/poll 每次全量扫描高到不知道哪里去了。epoll_wait 则是从就绪链表里往外取事件链表非空就把就绪事件拷贝到用户态数组返回链表为空就让调用进程睡在等待队列上直到某个 fd 有事件把它唤醒。3.2 真正的王牌回调机制 就绪链表只做有用功epoll 最核心的机制藏在 epoll_ctl 的注册过程里。当你把一个 fd 注册进 epoll 时内核除了把它放进红黑树还会做一件关键的事通过 ep_ptable_queue_proc 这个函数把一个回调函数挂到这个 fd 对应的设备驱动等待队列上。以 socket 为例这个回调会被挂在 socket 自身的 sk_sleep 等待队列上。之后当这个 fd 上有数据到达、连接建立、缓冲区可写等事件发生时设备驱动会调用这个等待队列上注册的回调。回调函数ep_poll_callback干的事情很明确把对应的 epitem 从红黑树这个“花名册”里找出来挂到 eventpoll 对象的就绪链表上然后唤醒正在 epoll_wait 的进程。于是整个过程从 select/poll 的“我挨个问一遍谁有数据”变成了“设备有数据时主动通知我”。这就是从轮询到事件驱动的分水岭。所以说到底epoll 快不是因为什么魔法而是它把所有无事件发生的 fd 完全绕开了。select/poll 的成本公式是 O(n)epoll 的单次成本是 O(就绪事件数)同时红黑树让 fd 的增删改查成本保持在对数级别。我经常用一个类比select/poll 像邮差挨家挨户敲门问“你家有没有信”epoll 像每家装了信箱传感器来信了传感器直接打给邮差邮差只跑真正的有信户。十万户里今天只有十封邮件两种模式的效率差距肉眼可见。3.3 LT 与 ET触发模式背后的性能与复杂度权衡epoll 有 Level Triggered水平触发和 Edge Triggered边缘触发两种模式这是 select/poll 完全没有的维度也是面试官最爱深挖的点。LT 模式下只要 fd 上还有未读数据每次 epoll_wait 都会返回该 fd如果数据没读完下次调用还会继续提醒你。ET 模式只在下“无数据”到“有数据”的状态变化时刻返回一次之后就算缓冲区里还有一大半没读的数据epoll_wait 也不会再提醒你。ET 模式下面临一个很现实的问题你需要把这次触发的“所有数据”尽量处理完。怎么做读完一次之后继续循环 read直到返回 EAGAIN。同理accept 新连接时也要一口气把所有 pending 的连接收下来直到 EAGAIN。这就意味着ET 模式使用的 fd 必须是非阻塞的不然最后一次 read 会直接卡死整个线程。这块的代码模板后面第五章我会直接给出完整实现。LT 和 ET 怎么选我的建议是追求极致的吞吐和低延迟比如网关、IM、实时弹幕服务用 ET代码要稳、团队维护压力要小用 LT。LT 不容易漏事件调试起来也简单大多数业务场景完全够用。选了 ET 就要承担漏数据事故的心理准备这一点在团队里要达成共识。3.4 辟谣epoll 快不是因为 mmap网上很多文章会写“epoll 通过 mmap 共享内核和用户空间内存所以快”。这个说法其实很不严谨。epoll_wait 把就绪事件从内核拷贝到用户态数组的这一步仍然存在数据拷贝并没有消失。epoll 真正的性能来源是回调机制让内核只处理就绪事件以及红黑树带来了低成本的 fd 管理。你一旦在面试中说“epoll 快是因为 mmap”面试官大概率会追问细节很可能追问两轮就翻车。那 mmap 在 IO 领域的价值在哪它是 io_uring、sendfile 这类零拷贝方案的核心手段之一属于另一个赛道。如果真想聊零拷贝可以聊 sendfile 用于文件传输、io_uring 用共享环形队列减少系统调用次数。但把这些概念和 epoll 混在一起属于没理解透。4. 数据对比与选型建议什么场景选什么4.1 一张表看清三者边界维度selectpollepoll最大 fd 数受 FD_SETSIZE 限制通常 1024无内置上限受内存/ulimit 限制无内置上限受内存/ulimit 限制核心数据结构fd_set 位图pollfd 数组内核红黑树 就绪链表单次调用成本O(n) 全量扫描 全量拷贝O(n) 全量扫描 全量拷贝O(就绪数) 事件拷贝fd 增删改查位运算O(1)数组遍历O(n)红黑树操作O(log n)触发模式LTLTLT ET跨平台几乎全平台POSIX 系主流Linux 专属这张表基本就是面试时的“标准答案骨架”。我个人建议把它记熟但不要直接背出来而是能解释每一行背后的原因。4.2 连接数 x 活跃连接数最核心的选型指标多路复用有个基本前提连接很多但同一时刻活跃的只是极小一部分。如果 1 万连接同时都在收发数据select/poll 和 epoll 的差距会缩小因为每一个事件最终都要处理瓶颈变成了业务逻辑而不是事件获取。真实互联网服务恰好符合“高连接、低活跃”的特征长连接维持心跳、IM 闲聊、物联网设备上报都是这类型。所以选型时核心指标是两个数同时在线连接数、活跃连接比例。连接数几百、活跃比例高select 完全够用别为了炫技硬上 epoll增加代码复杂度。连接数几千到几万、活跃比例低poll 能做但 epoll 更舒服。连接数超过十万级别Linux 上几乎没有选择就是 epoll。还有一种特殊情况活跃比例极低比如监控系统挂了 50 万个设备连接但每分钟只有几千个上报这时候 epoll 的优势会被放大到极致。另外补充一句epoll 也不是“活跃事件越多越好”。如果突然发生事件风暴比如秒杀场景下几十万连接同时活跃epoll_wait 一次返回大量就绪事件应用层的处理会瞬间成为瓶颈。这时候要考虑用多线程从同一个 epoll fd 里去取事件或者用 EPOLLEXCLUSIVE 把事件分散到多个等待线程。这块后面还会提到。4.3 跨平台怎么办kqueue 与 IOCP 的地图了解 epoll 是 Linux 专属再看跨平台就很有必要了。macOS 和 BSD 系统的多路复用方案是 kqueue设计思路和 epoll 类似也是事件驱动但 API 完全不同。Windows 则是 IOCPIO 完成端口它是真正意义上的异步 IO不仅通知你有事件数据已经由内核搬运完了。很多做跨平台服务端的同学都知道像 Redis 的事件循环 ae在 Linux 上编译用 epollBSD 系用 kqueue最后兜底才用 select。Node.js 的 libuv 也是一样不同平台用不同的 IO 引擎。这告诉我们一个事实不存在真正“万能”的多路复用 API平台特性决定了选型。如果面试官问你“为什么不用 select 实现跨平台”你可以回答在小规模场景下 select 确实最通用但一旦规模上来平台原生的高性能方案才是正解。这个回答能体现出你对工程实践的判断力而不只是会背几个函数名。5. 手写一个 epoll 高并发回显服务ET 模式5.1 完整代码骨架下面这套代码是我常用的 epoll 服务端模板监听 fd 用 LT已连接 fd 用 ET。LT 处理新连接更稳不会因为错过一次 accept 而导致连接滞留已连接 fd 用 ET减少无意义的事件唤醒次数配合非阻塞 IO 把吞吐拉上去。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include fcntl.h #include errno.h #include sys/epoll.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #define MAX_EVENTS 1024 #define BUFFER_SIZE 4096 // 设置非阻塞模式ET 模式必须 static int set_nonblocking(int fd) { int flags fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK); return 0; } int main() { int listenfd socket(AF_INET, SOCK_STREAM, 0); int opt 1; setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_port htons(8080); addr.sin_addr.s_addr INADDR_ANY; bind(listenfd, (struct sockaddr*)addr, sizeof(addr)); listen(listenfd, 128); set_nonblocking(listenfd); int epfd epoll_create(1); struct epoll_event ev, events[MAX_EVENTS]; // 监听 fd 用 LT水平触发更稳 ev.events EPOLLIN; ev.data.fd listenfd; epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, ev); while (1) { int n epoll_wait(epfd, events, MAX_EVENTS, -1); for (int i 0; i n; i) { int fd events[i].data.fd; if (fd listenfd) { // 新连接LT 模式下一次 accept 就好但为了保险也用循环收完 while (1) { int connfd accept(listenfd, NULL, NULL); if (connfd 0) { if (errno EAGAIN || errno EWOULDBLOCK) break; break; } set_nonblocking(connfd); struct epoll_event ev2; // 已连接 fd 用 ET只在状态变化时通知 ev2.events EPOLLIN | EPOLLET; ev2.data.fd connfd; epoll_ctl(epfd, EPOLL_CTL_ADD, connfd, ev2); } } else { // 可读事件ET 必须循环读到 EAGAIN char buf[BUFFER_SIZE]; while (1) { ssize_t nread read(fd, buf, sizeof(buf)); if (nread 0) { if (errno EAGAIN || errno EWOULDBLOCK) break; close(fd); break; } else if (nread 0) { // 对端关闭 close(fd); break; } // 简单回显生产环境要处理部分写、对端慢读等情况 write(fd, buf, (size_t)nread); } } } } return 0; }这个代码能编译能跑直接gcc -o echo_epoll echo_epoll.c就能起一个 8080 端口的回显服务。生产环境还要补充的地方我在下面拆解里说清楚。5.2 逐段拆解accept 循环与读事件的 EAGAIN 边界先看监听 fd 的处理。我注册监听 fd 时用的是 EPOLLIN 而不是 EPOLLET意味着 LT 模式每次有 pending 连接时 epoll_wait 都会返回这个 fd。代码里 accept 依然套了一个 while 循环目的是把积压的连接一次性收完避免高并发下多个连接排队等待。这里即使写成一次 accept 也基本不会丢事件因为 LT 会继续提醒但循环收完显然更高效。已连接 fd 的读处理是 ET 的核心战场。收到 EPOLLIN 事件后我连续 read直到返回 EAGAIN 或 EWOULDBLOCK。EAGAIN 意味着当前数据已经读完缓冲区空了这时才能跳出循环等待下一次事件。如果只读一次就跳出循环剩下的数据可能一直积压在缓冲区而 ET 不会再触发事件提醒这就是典型的“漏数据事故”线上我见过不止一次。再补一个细节write 回写。这个 demo 简化了写事件的场景假设回显的数据量小、发送缓冲区不会满。真实的大流量服务里write 也可能把缓冲区写满这时必须注册 EPOLLOUT 事件等 fd 可写再继续写。忽略这个坑服务在高并发下会出现数据发送不完整、连接异常挂掉的问题。5.3 压测与真实体验从 select 换到 epoll 的直观感受我之前在压测环境做过一次对比测试印象很深。一个简单的网关服务单进程 select 版本在 5000 左右连接时 CPU 就开始明显飙升主要是每次调用都在扫描大量空闲 fd改成 epoll 之后同样的机器保活到 5 万连接CPU 占用反而降下来了。让我直观地感受到了“O(n) 和 O(就绪数)”之间的巨大差距。并不是说 select 一无是处。连接量小的时候select 代码简单、可读性高、性能也不差。但规模一旦上来你会发现 epoll 的收益远远超过它的学习成本。这也是我为什么建议所有做后端的人哪怕平时用 Netty、Go 的 netpoll、Node.js 的 libuv也要亲手写一遍 epoll 代码。框架帮你把细节封装了但底层的正确姿势你不亲手踩一轮坑永远体会不深。6. 面试高频追问与避坑清单6.1 惊群问题与 EPOLLEXCLUSIVE多路复用有一个著名的问题惊群。多个线程或者多个进程同时调用 epoll_wait 监听同一个 epoll fd当某个连接就绪时内核会唤醒所有等待者但最终只有一个线程能处理这个连接其他线程白白被唤醒抢不到事件还要继续睡眠。在早期的内核版本里这个问题相当明显CPU 浪费严重。解法有几个方向。Linux 4.5 之后的内核提供了 EPOLLEXCLUSIVE用它注册事件可以避免同时唤醒多个进程/线程只会唤醒一个。另一个常用做法是每个线程持有独立的 epoll fd主线程 accept 之后按负载均衡策略分发到某个线程它只处理自己那份连接。还有结合 SO_REUSEPORT 让内核在 socket 层直接分发新连接也是常见优化手段。面试中被问到惊群能说出这些方案基本就过关了。6.2 epoll 算不算异步io_uring 的延伸知识这个追问经常出现而且很多候选人会栽这epoll 到底是不是异步 IO不是。epoll 返回的是“有事件发生”这个通知数据本身还在内核缓冲区需要你主动调用 read 把它读出来。所以它是同步非阻塞 IO 模型的一个高效事件驱动实现。真正的异步 IO 是 IOCP 和 io_uring内核不仅通知你事件还帮你把数据从内核搬运到用户缓冲区整个读写流程不需要应用线程阻塞参与。io_uring 是 Linux 5.1 引入的高性能异步 IO 接口核心是用共享内存的环形队列提交请求、收割结果极大减少系统调用次数同时支持真正的异步读写。它并不是来“取代 epoll”的而是用于磁盘 IO、网络 IO 的新一代底座。如果面试官问“epoll 是不是过时了”你可以从 io_uring 展开同时强调 epoll 在当前存量系统和大部分业务里仍是绝对主流。提到这个层次面试官会觉得你对业界最新进展有感知这是明显的加分项。6.3 我踩过的 5 个坑你现在就可以避掉ET 模式下不循环读 EAGAIN只读一次就离开剩余数据可能永远等不到下一次通知。这是 ET 漏数据最常见的原因没有任何回旋余地必须循环读。事件来了之后不判断 EPOLLERR 和 EPOLLHUP连接异常断开、对端 RST 时epoll 会返回这些事件。如果只按 EPOLLIN 分支处理异常连接可能一直没被清理fd 泄漏直到耗尽。监听 fd 也设成 ET 但 accept 没有循环高并发瞬间来了多个连接你只 accept 了一个剩下的连接可能在队列里滞留很久。要么监听 fd 用 LT要么 ET 模式下 accept 循环到 EAGAIN。忘了把 fd 设为非阻塞ET 模式下你会循环 read 到 EAGAIN但如果 fd 是阻塞的最后一次 read 会直接卡死线程。这是新手最容易踩的坑而且排查起来很难受。close(fd) 之后误以为 epoll 里还有它fd 关闭时会自动从 epoll 实例中移除不需要手动 EPOLL_CTL_DEL。但要小心多线程环境里 fd 被重复 close或者 fd 复用后旧事件还在处理导致把新连接误关掉。每次提到这些坑我都能想起线上事故的紧张感。多路复用代码写起来不复杂难就难在这些边界情况经验值就是靠一个又一个事故堆起来的。如果只能分享一条准备面试的经验那就是把三个 API 的底层流转路径亲手画一遍。不要背现成的图而是自己从 select 的 fd_set 位图一路画到 epoll 的红黑树和就绪链表把“数据从网卡到应用层”的路径理顺。我在腾讯那次面试的后半段直接在白板上给面试官画了这个流转过程整个问题板块聊得比想象中顺畅很多。这种底层的理解不止在面试有用后来做网关选型、排查线上连接假死问题时都让我节省了大量时间。希望这篇内容也能帮你把这块最核心的网络 IO 知识补牢。