epoll vs select/poll:从内核红黑树到事件驱动模型的完整拆解
如果你准备过腾讯的面试或者翻过几篇面经大概率会注意到一个现象网络 IO 这道题几乎成了二面的钉子户而问题核心就落在一个点上—— epoll 为什么比 select/poll 快很多朋友能背出IO 多路复用红黑树回调机制这几个词但被追问回调是谁注册的内核怎么知道哪个 fd 就绪了就绪事件是怎么从内核搬到用户态的往往就卡住了。这篇文章就把这套底层逻辑完整拆一遍从 select/poll 的瓶颈讲到 epoll 的内核设计最后聊聊我在实际项目里踩过的坑以及面试时应该怎么分层作答。先说个结论放在前面epoll 的关键优势不是用了更高级的 API而是把每次调用都全量扫描所有 fd这个笨办法彻底换成了注册一次、等待通知、只取就绪事件的事件驱动模型。理解了这个转变整道题就通了。1. 同样叫多路复用select/poll 慢在哪里面试里最容易踩的第一个误区是把 select/poll 和 epoll 当成三个并列的方案。实际上 select 和 poll 是同一类思想epoll 是另一套思想。搞清楚前者慢在哪才算真正理解了后者快在哪。1.1 select 的三步操作和线性扫描宿命select 的原型长这样int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);fd_set 本质上是一个位图每一位代表一个文件描述符。流程是用户态把 readfds、writefds、exceptfds 三个位图拷贝到内核态内核遍历整个位图逐个检查每个 fd 是否可读、可写或有异常把结果写回这三个位图再拷贝回用户态用户程序从头到尾再遍历一遍位图靠 FD_ISSET 宏找出哪些 fd 就绪了。这里有两个非常容易忽略的坑都是我在实际写代码时踩过的。第一个坑select 的三个 fd_set 是输入输出参数。调用完一次 select 之后内核会把位图内容改掉——就绪的 fd 对应位保留未就绪的位被清掉。所以每次循环调用前都必须重新用 FD_SET 把所有要监视的 fd 填回去。很多初学者第一次写循环 accept 服务发现第二次 select 什么事件都收不到就是因为位图已经被上一轮调用改写了。这不是什么冷门细节但面试时主动提出来面试官会觉得你真有实战经验。第二个坑fd_set 的容量被 FD_SETSIZE 限制默认是 1024。注意这里说的是默认值内核编译时其实可以改但绝大多数线上系统不会为 select 调整这个值。也就是说 select 天然不适合高并发场景连接数一过千就得换方案。即便如此select 最大的性能问题还不是 1024 上限而是那个全量扫描的宿命。假设你有 10000 个连接先不讨论 fd_set 放不放得下但其中只有 10 个活跃select 依然要把 10000 个 fd 全部查一遍而且这次调用结束、下次调用开始一切归零还得再查一遍。大量 CPU 时间花在了确认没事发生这件事上。1.2 poll 的改进去掉 1024 上限但没有去掉根本问题poll 的出现就是为了解决 fd 上限。它不用位图而用 pollfd 数组struct pollfd { int fd; short events; // 用户传入的关注事件 short revents; // 内核返回的触发事件 }; int poll(struct pollfd *fds, nfds_t nfds, int timeout);poll 相比 select 有一个被低估的好处events 和 revents 分开了内核不会修改用户传入的关注事件所以不需要像 select 那样每轮循环重新填充整个数组。这个改进让 poll 用起来比 select 舒服不少。但 poll 的性能模型没有本质变化用户态要把整个 pollfd 数组拷贝到内核内核要遍历全部 nfds 个 pollfd逐个检查状态内核把 revents 写回数组拷贝到用户态用户态要再次遍历整个数组靠检查 revents 非零来确定哪些 fd 就绪。连接数从 1 万涨到 100 万poll 的开销是线性上涨的因为无论活跃连接有多少它永远要过一遍所有人。1.3 连接数增长时两条曲线的分道扬镳我早年优化过一个内部网关当时的连接量大概两万左右用的是 poll。线上问题表现就是连接数涨到某个阈值之后CPU 利用率突然飙升到接近 100%但业务吞吐量反而往下掉。查看热点cpu 全部消耗在内核的 poll 循环和用户态对 pollfd 数组的遍历上。这个现象背后的道理很简单并发连接数 N 和活跃连接数 M 是两个完全不同的量。select/poll 的性能只跟 N 有关跟 M 无关——哪怕 M 恒定为 10N 从 1 千涨到 10 万它们每轮的扫描成本都会同步放大。而网络服务恰恰是连接基数大、活跃比例小的典型场景这就导致 select/poll 在 C10K 问题面前几乎无解。顺带说一句很多人问过 C10K 是不是过时了。单台机器扛 10 万连接在今天确实不是稀奇事但连接数的增长永远快于活跃连接数的增长这决定了基数大、活跃少依然是常态。所以 epoll 的设计思想在今天依然没有过时。2. epoll 的三个核心机制逐个拆开看epoll 的核心思路概括成一句话就是把查改成等。怎么等靠内核里的一棵红黑树、一个就绪链表以及每个 fd 上挂的一个回调函数。下面分三步拆开讲。2.1 epoll_create 不是创建池子而是创建了三个结构调用 epoll_create(1) 或 epoll_create1(0) 之后内核创建一个 struct eventpoll里面最重要的两样东西红黑树用来存放所有通过 epoll_ctl 注册的 fd 节点。红黑树的优势是插入、删除、查找都是 O(logN)连接数再多也不会退化。就绪链表rdllist用来存放当前已经有事件发生的 fd 节点。我第一次理解这两个结构的时候脑海里出现的画面是一个接待处红黑树是花名册上面登记了所有你关心的 fd就绪链表是已到达队列真正有事儿的 fd 才被请进去排队。epoll_wait 只需要看这个队列里排了几个人而不是对着整本花名册挨个问你有事吗。注意epoll_create 的入参 size 在 2.6.8 之后被忽略了内核会动态扩容所以传什么数字都行。但建议直接用 epoll_create1顺手加上 EPOLL_CLOEXEC避免 exec 之后子进程意外继承这个句柄。这个习惯在写守护进程或 fork 业务时非常重要。2.2 epoll_ctl把 fd 托管 给内核并种下回调int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);op 有三种EPOLL_CTL_ADD、EPOLL_CTL_MOD、EPOLL_CTL_DEL。每次 ADD 或 MOD内核会为这个 fd 创建一个 epitem 节点挂到红黑树上同时做一件关键的事调用这个 fd 对应的 poll 操作把 epoll 自身的回调函数ep_poll_callback注册进去。这里就是 epoll 和 select/poll 分道扬镳的起点。内核里每个文件类型都实现了一组 file_operations其中有一个 poll 成员。socket 文件的 poll 实现会在自己的等待队列上登记调用者用于后续事件到达时唤醒。select/poll 调用的时候也是把当前线程挂到所有 fd 的等待队列上但它们是临时挂一下轮完就摘掉epoll 是长期的——你在 epoll_ctl 里就把回调挂好了之后这个 fd 上只要有事件发生比如 socket 接收缓冲区来了数据协议栈驱动路径会触发这个回调回调干的事情只有两件如果这个 fd 当前不在就绪链表里就把它的 epitem 追加到 rdllist 尾部如果当前有线程阻塞在 epoll_wait 上就唤醒它。回调机制解释清楚了很多面试追问就都能接上内核知道哪个 fd 就绪了不是因为 epoll_wait 去问了而是 fd 自己主动跑到就绪链表里报了到。这个机制在内核里叫等待队列 回调唤醒本质上是把反复巡逻换成了事中通知。2.3 epoll_wait直接从就绪链表取事件而不是找事件int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);调用 epoll_wait 时内核检查 rdllist 是否为空。不为空就把链表里每个 epitem 记录的事件信息填到用户传入的 events 数组里拷贝回用户态。用户程序拿到的是这 100 个 fd 发生了哪些事而不是这 10 万个 fd 包含了哪些 fd。这里的时间复杂度是 O(K)K 是就绪事件数量跟总连接数无关。所以 epoll 在万连接、百活跃场景下的效率曲线几乎是平的而 select/poll 是斜的。还有一点值得说明拷贝事件数量 maxevents 要设置得合理。如果没传回完整的就绪事件剩余事件不会丢失会留在就绪链表里下次 epoll_wait 继续取。但如果你一直设置过小的 maxevents就绪事件就会排队拖延多轮 epoll_wait 才能处理完延迟会显著上升。实际工程中events 数组一般按最大 fd 数的 1/4 到 1/2 开或者直接用压测数据调。2.4 网上常说的 mmap 共享内存到底有没有用面试里经常有人听到一个说法epoll 用 mmap 共享内存减少了内核态到用户态的数据拷贝所以快。这句话在很多教材和博客里流传很广但严格来说它是有争议的。现代内核里epoll_wait 把就绪事件填到用户传入的 events 数组走的是 copy_to_user 的路径也就是一次常规拷贝。epoll 内核初始化确实会用内核内存分配机制申请一块区域但这跟mmap 共享内存让用户态直接访问内核结构不是一回事。以我自己的面试经验提起这个争议是加分项——它证明了你不只是背了mmap三个字母而是真的翻过实现。更重要的是epoll 性能提升的主来源不是省了单次拷贝而是前面说的两个数量级变化一是扫描范围总 fd 数 变成 就绪数二是调用模式每次重建位图并全量拷贝 变成 注册一次、长期生效。这几条加在一起才实现了碾压级的性能差距。就算把拷贝次数拉到同样水平select/poll 的全量遍历也救不回来。3. 性能差距不是玄学是数据结构和工程设计的差距很多面试者讲到这里就停了会背O(1) 复杂度但解释不清这个 1 指的是什么。这节从几个不同维度把差距量化一下方便你在对话里给出有信息量的回答。3.1 复杂度对比从 O(n) 到 O(就绪数)维度selectpollepollfd 上限FD_SETSIZE 默认 1024无取决于系统可打开文件数用户态传递给内核的数据每次调用全量拷贝三个位图每次调用全量拷贝 pollfd 数组只在 epoll_ctl 时传单个 fd 和事件内核检查方式遍历所有 fd 检查状态遍历所有 pollfd 检查状态通过回调把就绪 fd 挂到链表wait 时只遍历就绪链表用户态结果查找遍历位图 FD_ISSET遍历整个数组找 revents 非零项直接拿到就绪事件数组无需二次扫描时间复杂度O(n)n 为监视 fd 数O(n)注册 O(log n)取就绪 O(k)k 为就绪事件数这里有一个值得强调的细节epoll 的O(1)经常被误解为不管多少连接都是常数时间。准确说是单次 epoll_wait 的开销与就绪事件数相关与总连接数基本无关。连接数翻十倍等量就绪事件的前提下单次开销几乎不变。面试时纠正这个说法是很自然的加分表述。3.2 调用次数这个隐藏变量复杂度表说的是单次调用的成本但实际工程中还要看调用频率和调用模型。select/poll 每次调用都要重复拷贝全部 fd 列表 → 内核扫描 → 拷贝回结果 → 用户态二次扫描这条链路。这意味着 CPU 开销会随着 fd 总数和调用频率乘性增长。典型的高频场景像网关、代理服务accept 和 read 事件都是每秒成千上万次触发的select/poll 等于每秒都在反复做大规模集合复制和遍历。epoll 把登记和等待分开了。登记epoll_ctl虽然也要进入内核但只在连接建立、关闭、改属性时发生是低频操作高频操作 epoll_wait 只需要从就绪链表里摘节点。打个比方select/poll 是每顿饭把全公司的人数一遍再去食堂打饭epoll 是饭做好了挨个叫号叫到你才起身。3.3 压测里看到的真实差距我这边做过一轮对比压测单机 10 万连接模拟反向代理场景随机让其中 0.1% 的连接产生收发事件。select/poll 的表现是 CPU 占用接近满核请求延迟频繁抖动到几十毫秒级别相同负载下 epoll 的 CPU 占用大概在 20%~30%P99 延迟稳定在个位数毫秒。这个数据仅供参考不同内核版本、不同网卡驱动、不同线程模型下会差很多但量级差距是稳定的。还有一点值得注意当活跃连接比例变高比如接近 100% 时epoll 的优势会缩小。因为 select/poll 扫描全部 fd 的开销和 epoll 逐个处理就绪事件的开销在全员活跃的极端场景下差距不再悬殊。这个结论面试官问epoll 是不是永远快时可以用上——知道 epoll 的适用边界比只知道 epoll 快更显深度。3.4 触发模式LT 和 ET又一个分水岭epoll 还多了一个 select/poll 完全没有的维度触发模式。epoll_ctl 传入的 events 里加上 EPOLLET就切换成边缘触发Edge Triggered不加则是水平触发Level Triggered默认。水平触发逻辑和 select/poll 一脉相承只要 fd 上还有数据没读完每次 epoll_wait 都会返回这个 fd。边缘触发则只在状态变化的那一刻通知一次比如缓冲区从空到有数据通知一次这一批数据没读完下一批数据到达前它不会再通知。为什么说这是个分水岭因为 ET 模式下处理 fd 的代码模式完全不同。LT 模式你可以想起来就 read 一下没读完下次 wait 还会叫你去ET 模式必须一次把数据尽量读完配合 while 循环 read 到 EAGAIN否则后续数据到达时由于状态没有变化缓冲区仍然是非空不会再触发通知结果就是丢事件。ET 模式下还强制要求 fd 是非阻塞的否则最后一次 read 会卡住整个线程。工程上我的建议是没有充分理由默认用 LT。ET 适合对单次唤醒开销要求极高的场景但代价是你的事件处理循环必须写得非常仔细。这个话题在腾讯的实际面试中经常往下深挖后文专门讲我踩过的坑。4. 面试怎么答工程怎么用这道题背后的完整素养最后这部分把面试考察的层次和工程里的真问题放一起说。毕竟腾讯二面问这道题不只是为了让你背 API而是想看你对高性能网络服务有没有完整的认知框架。4.1 两分钟回答的分层结构我建议你准备一个两分钟版本的回答思路是三条分层第一层API 差异。select 用位图、有 1024 上限、位图会被内核修改poll 用数组、无上限、events 和 revents 分离。这一层证明你真的写过。第二层内核模型差异。select/poll 是每次调用的临时全量扫描epoll 是长期注册 回调 就绪链表把复杂度从与连接总数挂钩降为与活跃连接数挂钩。这一层证明你理解设计。第三层工程权衡。epoll 不是银弹ET 模式该怎么处理、惊群怎么缓解、EPOLLONESHOT 什么时候用、多线程下该怎么玩。这一层证明你踩过坑。如果面试官只给你一分钟保留第二层砍掉第一层的细节第三层点一下 ET/惊群即可。通常腾讯这边的追问节奏是先看你 API 用得对不对然后立刻往内核数据结构上钻最后给一个如果你来处理 10 万连接你还会考虑什么的开放题。一上来就背epoll 是事件驱动而没有展开的基本在这道题上拿不到高分。4.2 我踩过的 ET 模式和 EPOLLOUT 的坑第一次把网关从 poll 改到 epoll 时我犯过一个低级错误注册了 EPOLLET但 forgot 把 fd 设成非阻塞。结果就是 epoll_wait 通知了一次可读事件read 把已有数据读完后又阻塞在内核里等新数据整个事件循环被卡死其他连接全部饿死。这个问题的本质是 ET 意味着只唤醒一次而阻塞 read 破坏了整个事件循环的非阻塞假设。还有个高频坑是 EPOLLOUT。很多新手会把 EPOLLIN 和 EPOLLOUT 一起注册结果发现 epoll_wait 疯狂返回可写事件——因为对一个正常可写的 socket 来说写缓冲区永远有空位所以它一直可写每次都进就绪链表。正确做法是只在需要发送数据时通过 EPOLL_CTL_MOD 临时注册 EPOLLOUT等这个可写事件触发一次把数据写完立刻再 MOD 掉。否则就是白白烧 CPU 的 busy loop。另外提醒一个所有用 ET 的人都要记住的模式每次触发读事件要用循环 read 到 EAGAIN把一次通知能读的数据尽量读完。很多现成的网络库比如 Reactor 框架封装了这套逻辑但自己写的时候非常容易漏。4.3 惊群、EPOLLONESHOT 和线程模型多线程或多进程同时在同一个 epoll fd 上调用 epoll_wait是常见的生产配置。但这个配置有一个经典问题惊群。比如 8 个线程都在 wait一个连接请求到达内核可能唤醒全部 8 个线程但最终只有 1 个线程 accept 成功其他 7 个白醒一趟。高并发下这 7 次无效唤醒叠加起来会明显拉高 CPU 和调度延迟。旧方案里Nginx 用 accept_mutex 之类的用户态锁来控制同一时刻只有一个 worker 在 accept。Linux 内核 4.5 之后提供 EPOLLEXCLUSIVE 标志用来在事件分发时只唤醒一个等待线程这是更底层的解法。如果面试聊到这里能主动提到 Nginx 的 accept_mutex 和 EPOLLEXCLUSIVE 两个方案说明你不是只会调 API 的选手。EPOLLONESHOT 是另一个常用标志。它告诉内核某个 fd 的事件被通知一次之后自动从监听集合里摘下来不再通知直到你手动 EPOLL_CTL_MOD 重新激活。这个设计是为了避免多个线程同时处理同一个 fd 的事件——比如一个连接的数据被两个 worker 各自读到一半状态就乱了。典型场景是主线程 epoll_wait 拿到 fd 事件后把这个 fd 的処理交给线程池里的某个线程同时 mod 掉监听线程处理完再重新 add 回来。这样能保证同一个 fd 同一时刻只被一个线程处理。4.4 几个实战中值得记住的细节再补几个我在生产环境里踩过、以及帮朋友排查过的问题这些一般面试不会直接问但代表你真的做过。第一close fd 时要不要先从 epoll 摘除。常规说法是 close 时内核会自动把 fd 从 epoll 里移除这没错。但要注意顺序如果你先 epoll_ctl DEL 再 close多一次系统调用如果直接 close在 fd 上还有 pending 事件没处理完时就 close可能会丢掉最后一批数据。我的习惯是业务层面保证数据收尾完成再 close关闭后不用特意 DEL。第二fork 之后 epoll fd 会被子进程继承。如果父子进程都往同一个 epoll fd 上注册 fd或者都去 wait行为会非常难以排查。所以 fork 之前要想清楚 epoll fd 的归属服务类进程一般都在 fork 之后各自创建自己的 epoll 实例。第三跨线程唤醒事件循环最优雅的方式不是直接往 epoll 里写个假 fd而是用 eventfd。eventfd 专门为这种场景设计往写端写一个 8 字节整数就能让读端可读配合 EPOLLIN 挂到 epoll 上用来在业务线程和 IO 线程之间传递有新任务的信号。这个组合我在多个网络服务里都用过比 pipe 空转少、语义更清楚。第四epoll_wait 的 timeout 参数不要拍脑袋设。设 0 会变成纯非阻塞轮询忙等消耗 CPU设 -1 会无限阻塞如果一个事件都没有你无法周期性执行一些定时任务。实际中常用做法是设定一个心跳周期的超时比如 50~100ms让事件循环兼顾事件驱动和定时任务检查。这道题背后的真正收获聊到这儿epoll 这套东西其实已经不只是面试题了。我后来带团队、做网关、排查线上性能瓶颈只要涉及高并发网络服务底层全在注册、通知、就绪队列这几个概念里打转。select/poll 和 epoll 的对比本质上是一次思维转变从定期检查所有可能到让事件主动通知你。这个思路放到任何需要处理大量空闲资源、少量活跃事件的系统里都适用——数据库连接池、任务调度器、消息队列消费者底层逻辑全是这一套。我个人最深的体会是背下epoll 有红黑树和回调很容易真正值钱的是你能从一次线上抖动出发讲清楚是事件积压了还是唤醒次数太多了然后一步步追到内核里那个就绪链表。腾讯二面问这道题问的其实就是这种从表象追到根因的思维方式。所以如果你正在准备面试别只背结论找一个能跑通的 echo server把 select 版本改成 poll 版本再把 poll 版本改成 epoll 版本亲手感受一下三种写法的差异比看十篇博客都管用。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →