如何理解 select、poll、epoll?
一则或许对你有用的小广告
欢迎加入小哈的星球,你将获得:专属的实战项目(4个项目都能学) / 1v1 提问 / 简历修改 / Java 学习路线 / 社群讨论 / 学习打卡 / 每月赠书
《Spring AI 项目实战(问答机器人、RAG 智能客服、联网搜索)》已完结,基于
Spring AI + Spring Boot 3.x + JDK 21...,查看介绍《从零手撸:仿小红书(微服务架构)》 已完结,基于
Spring Cloud Alibaba + Spring Boot 3.x + JDK 17...,查看介绍;演示链接:http://116.62.199.48:7070/《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接:http://116.62.199.48/
新开坑项目:《从零手撸:秒杀系统高并发优化实战》 正在更新中...,查看介绍
截止目前,星球内专栏累计输出 150w+ 字,讲解图 5110+ 张,还在持续爆肝中.. 后续还会上新更多项目,已有 4700+ 小伙伴加入学习,欢迎点击围观
面试考察点
-
IO 多路复用的本质理解:面试官不仅仅是想知道三个 API 的区别,更是想看你是否理解 “一个线程监听多个 socket” 这个需求是怎么一步步演化解决的。
-
性能瓶颈的分析能力:select 为什么慢?慢在哪?poll 改了什么、没改什么?epoll 又解决了什么?这条演化线能不能讲清楚,直接暴露你的功力。
-
联系实际框架:Java NIO 的
Selector、Netty、Redis、Nginx 底层都靠这套机制。能不能把八股文落到实际项目,一聊就知道了。
核心答案
先说结论:select、poll、epoll 都是 IO 多路复用的实现方案,核心目标就一个:让单个线程同时监听多个文件描述符(socket),谁的内核数据准备好了就通知谁,避免为每个连接开一个线程干等。
三者是演进关系,一张表看懂:
| 对比项 | select | poll | epoll |
|---|---|---|---|
| 发布时间 | 1983 年(4.2BSD) | 1986 年(System V) | 2002 年(Linux 2.5.44,2.6 正式可用) |
| 跨平台 | ✅ 几乎所有平台 | ✅ Unix 系 | ❌ Linux 专属(对应 macOS 的 kqueue、Windows 的 IOCP) |
| fd 数量上限 | FD_SETSIZE = 1024 |
无硬限制(链表存储) | 无硬限制(受限于系统内存) |
| fd 传递方式 | 每次调用全量拷贝到内核 | 每次调用全量拷贝到内核 | epoll_ctl 注册一次,内核长期维护 |
| 内核检测方式 | 线性扫描所有 fd | 线性扫描所有 fd | 事件回调,只返回就绪的 fd |
| 时间复杂度 | O(n) | O(n) | epoll_wait 对就绪集合接近 O(1) |
| 每次调用后 | 必须重置 fd_set |
不需要重置 | 不需要重置 |
| 适用场景 | 连接数少、跨平台 | 连接数中等 | 连接数巨大、活跃比例低(万级连接常态) |
一句话总结演进逻辑:select 有数量上限还有全量拷贝的开销,poll 解决了数量上限,epoll 把 “全量拷贝 + 全量扫描” 两个瓶颈全干掉了。
深度解析
一、为什么需要 IO 多路复用
先补个背景,不然这三个东西不好理解。
假设你写了一个 TCP 服务器,同时有 1000 个客户端连上来。最朴素的做法:一个连接配一个线程,每个线程调 read() 阻塞等着读数据。
问题立马来了:
- 1000 个连接就是 1000 个线程,内存和上下文切换开销都顶不住
- 大部分时间这些连接根本没数据,线程全在那干等,纯浪费
那能不能一个线程同时盯着 1000 个 socket?谁有数据了我就处理谁,这就是 IO 多路复用。select、poll、epoll 就是实现这个目标的三个方案,一个比一个强。
二、select:元老级方案
select 是 1983 年随 4.2BSD 发布的老将,思路很直接:
int select(int nfds,
fd_set *readfds, // 关注可读事件的 fd 集合
fd_set *writefds, // 关注可写事件的 fd 集合
fd_set *exceptfds, // 关注异常事件的 fd 集合
struct timeval *timeout);
它用一张 fd_set 位图(bitmap)来记录要监听的 fd,操作位图的宏你肯定见过:FD_ZERO、FD_SET、FD_CLR、FD_ISSET。
一次 select 调用的流程:
- 应用程序把
fd_set从用户空间全量拷贝到内核空间 - 内核线性扫描每个 fd,检查它的数据是否就绪
- 有就绪的就返回,没有就阻塞(或超时返回)
- 返回后,
fd_set已被内核改写(没就绪的位被清掉了),应用程序要线性扫描找出哪些 fd 就绪了 - 想继续监听?把
fd_set重新设置一遍,再来一轮
这套流程有三个致命伤:
- 1024 上限:
fd_set是固定长度的位图,FD_SETSIZE默认 1024,想改只能重新编译 glibc,实际没人这么干 - 重复的全量拷贝:哪怕 1000 个 fd 里只有 1 个活跃,每次调用都要把整张位图拷进内核
- 重复的线性扫描:内核扫一遍,用户态还得再扫一遍,两头都是 O(n)
还有一个容易忽略的坑:因为内核会修改传入的 fd_set,每次调用前都得用 FD_ZERO + FD_SET 重新构建一遍,写起来特别啰嗦。
三、poll:select 的改良版
poll 出现在 System V 里,针对 select 的位图结构做了改良:
struct pollfd {
int fd; // 要监听的 fd
short events; // 关注的事件
short revents; // 内核填写的实际发生事件
};
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
关键改进有两点:
- 换成了 pollfd 数组:数组长度自定义,突破了 1024 的数量上限
- 事件和结果分离:
events由用户设置,revents由内核填写,两者分开存。这样每次调用后不用重新设置关注的事件,比 select 舒服多了
但说实话,poll 只动了皮毛。每次调用依然要把整个数组全量拷贝进内核,内核依然要线性扫描所有 fd,O(n) 的本质一点没变。连接数一上去,开销照样爆炸。所以 poll 更像是个过渡方案,真正的革命者是 epoll。
四、epoll:Linux 的杀手锏
epoll 是 Linux 2.5.44 引入(2002 年)、2.6 内核正式可用的方案,Redis、Nginx 的高并发底气全靠它。跟 select/poll 的 “一次调用干所有事” 不同,epoll 拆成了三个系统调用,各管一段:
// 1. 创建 epoll 实例,返回一个 epfd
int epoll_create(int size);
// 2. 注册/修改/删除要监听的 fd(ADD / MOD / DEL)
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// 3. 等待事件发生,只返回就绪的 fd
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
整个流程画出来是这样:
上图是 epoll 的完整工作流程,拆开看关键设计:
- 注册与等待分离:
epoll_ctl把 fd 注册进内核的红黑树,信息在内核里长期保存。之后每次epoll_wait不需要重新传入监听列表,全量拷贝的问题直接消失 - 红黑树管理 fd:增删改 fd 都是 O(log n),查找高效,还能天然去重
- 事件回调代替轮询:数据到达时,内核通过中断触发挂在该 socket 上的回调
ep_poll_callback,把就绪的 fd 挂到就绪链表(rdllist)上。内核不用再挨个问 “你有数据吗” - 只返回就绪的 fd:
epoll_wait拿到的就是就绪列表本身,应用程序不用再从 10000 个 fd 里筛出那 10 个活跃的
这里必须辟个谣:网上很多文章说 “epoll 用 mmap 共享内存减少拷贝”,这是错的。epoll 并没有用 mmap,epoll_wait 返回时就绪事件依然要从内核拷贝到用户空间,只是拷贝量从 “全部 fd” 变成了 “就绪 fd”。这一点面试官要是追问,答错了会很减分。
五、三者性能对比
用一张图直观感受一下,假设 10000 个连接、其中 100 个活跃:
两边的差距一目了然:
- select/poll 的工作量跟 总连接数 挂钩,连接越多越惨,而且每次调用都要重复付出
- epoll 的工作量跟 活跃连接数 挂钩,10 万连接里 1000 个活跃,开销就只有这 1000 的量级
这也解释了为什么说 “epoll 适合连接数多但活跃少的长连接场景”(比如即时通讯、推送系统)。反过来,如果连接数不多但几乎全活跃,epoll 相比 poll 的优势就不明显了——毕竟红黑树和回调机制本身也有维护成本。
六、LT 与 ET:epoll 的两种触发模式
这是面试官最爱挖的深水区。epoll 有两种工作模式:
| 模式 | 全称 | 触发时机 | 特点 |
|---|---|---|---|
| LT(水平触发) | Level-Triggered | 只要缓冲区还有数据,每次 epoll_wait 都会通知 |
默认模式,可以分次读写,编程简单 |
| ET(边缘触发) | Edge-Triggered | 仅在状态变化那一刻通知一次(比如新数据到达) | 必须一次把数据读写干净(循环处理到 EAGAIN),配合非阻塞 fd 使用 |
打个比方:LT 像一个贴心的助理,只要你桌上还有没处理的文件,他每次路过都会提醒你;ET 像一个高冷的助理,只在 “新文件放上来” 那一瞬间喊你一嗓子,处理没处理干净他不管。
ET 模式减少了重复通知的次数,效率更高,但编程复杂度也上去了:必须搭配非阻塞 fd,循环 read 直到返回 EAGAIN,否则残留数据再也不会被通知,就丢事件了。Nginx 用的就是 ET 模式。
还有个相关的知识点:Redis 6.0 之前一直是单线程处理命令,但照样扛住 10 万+ QPS,靠的就是 epoll(LT 模式)+ 事件驱动那套 “单线程 Reactor” 模型。
七、和 Java 体系的关系
作为 Java 工程师,光懂 C 接口还不够,得知道这套东西怎么落到我们身边:
- Java NIO:
Selector就是 IO 多路复用的 JDK 封装。JVM 会根据操作系统选实现:Linux 2.6+ 用 epoll,Windows 上用select(WinSock 实现),macOS 用 kqueue。你写的是同一套Selector.open()、select()、selectedKeys(),底层自动适配 - Netty:除了 NIO 的
NioEventLoopGroup,还提供了EpollEventLoopGroup,直接走 JNI 调用 Linux 原生 epoll,省掉 JDK 层的封装开销,还能用到SO_REUSEPORT等特性。Linux 生产环境用 Netty,强烈建议换成 Epoll 系列 - Redis / Nginx:Redis 的 ae 事件循环在 Linux 上就是 epoll;Nginx 的高并发就是靠 epoll + ET 模式撑起来的
面试时把 select/poll/epoll 讲完,再主动带一句 “Java NIO 的 Selector 底层就是这套机制”,主动权就到自己手里了。
面试高频追问
- 追问一:epoll 一定比 select/poll 快吗?
不一定。连接数少且几乎全活跃的场景下,epoll 的回调机制和红黑树维护反而是额外开销,select/poll 表现不差。epoll 的主场是 “连接数巨大 + 活跃比例低” 的长连接场景。
- 追问二:epoll_wait 为什么快?
因为它不做扫描,只检查就绪链表 rdllist。链表非空就拷贝就绪事件返回,空就阻塞。工作量只和就绪 fd 数量相关,跟总连接数无关。
- 追问三:select 的 1024 限制能改吗?
能改 FD_SETSIZE 后重新编译 glibc,但没人这么干——位图全量拷贝和线性扫描的 O(n) 开销还在,改了上限也扛不住大连接数,不如直接换 epoll。顺带说一句,这个 1024 是 glibc 用户态的定义,内核本身没做这个限制,知道这一层算加分项。
- 追问四:ET 模式为什么必须配非阻塞 fd?
ET 只通知一次,你必须循环读直到读完。如果用阻塞 fd,最后一次 read(缓冲区已空)会直接阻塞线程,整个事件循环卡死。非阻塞 fd 空读会返回 EAGAIN,循环才能正常退出。
- 追问五:水平触发和边缘触发,Nginx 和 Redis 分别用哪个?
Nginx 用 ET(效率优先,自己控制读写循环),Redis 用 LT(编程简单可靠,事件循环单线程不宜复杂化)。
常见面试变体
- 变体一:“IO 多路复用和 BIO、NIO、AIO 的关系是什么?”
- 变体二:“epoll 用了什么数据结构?为什么选红黑树?”
- 变体三:“Reactor 模式和 epoll 是什么关系?单 Reactor 单线程、多线程、主从 Reactor 分别怎么配?”
- 变体四:“Netty 的
EventLoop和 epoll 是什么关系?”
记忆口诀
三代演进:select 位图限一千,poll 数组破上限,epoll 红黑树上挂回调。
epoll 三板斧:create 建实例、ctl 挂红黑树、wait 收就绪链表。
两个模式:LT 没读完就一直喊,ET 只喊一嗓子(记得一口气读完)。
总结
一句话:select、poll、epoll 都是 IO 多路复用方案,让一个线程盯住海量连接。select 有 1024 上限且两头全量扫描,poll 破了上限但没破 O(n),epoll 用红黑树 + 就绪链表 + 事件回调把开销降到只和活跃连接数相关,是 Linux 高并发的基石。再把 LT/ET 和 Java NIO、Netty 的关系串起来,这道题你就赢麻了。
