如何理解 select、poll、epoll?
三者都是IO 多路复用的系统调用:把一批 fd 交给内核,让内核替你等,有事件了再返回。
区别在数据结构和实现方式:select 用位图、有 1024 上限、每次都要把整份 fd 集合拷进内核并线性扫描;
poll 换成数组去掉了上限,但仍要全量拷贝与遍历;epoll 把 fd 注册进内核的红黑树,
就绪的 fd 由回调放进就绪链表,epoll_wait 只取链表,复杂度从 O(n) 降到 O(就绪数)。
1. 对比
| 维度 | select | poll | epoll |
|---|---|---|---|
| 数据结构 | fd_set 位图 | pollfd 数组 | 红黑树 + 就绪链表 |
| fd 上限 | FD_SETSIZE(默认 1024) | 无硬上限(受内存) | 无硬上限 |
| 每次调用拷贝 | 全量 fd 集合进出内核 | 全量数组 | 仅 epoll_ctl 时注册一次 |
| 时间复杂度 | O(n) 遍历 | O(n) 遍历 | O(1) 取就绪 + O(log n) 增删 |
| 就绪事件返回 | 修改位图,需自己再查一遍 | 改 revents,需遍历 | 直接返回就绪数组 |
| 触发模式 | 只有水平触发 | 只有水平触发 | 水平触发 LT / 边缘触发 ET |
| 跨平台 | 都支持 | 基本都支持 | Linux 专有(BSD 有 kqueue) |
select: 用户态 fd_set ──拷贝──► 内核遍历 n 个 fd ──► 修改位图 ──拷贝回──► 用户再遍历
poll: 用户态数组 ──拷贝──► 内核遍历 n 个 fd ──► 改 revents ──拷贝回──► 用户再遍历
epoll: epoll_ctl 注册一次 ──► 内核红黑树
设备就绪 ──回调──► 就绪链表 ──► epoll_wait 只返回链表(数量 = 真正就绪的)
2. epoll 内部
eventpoll
┌──────────────┐
│ rbr(红黑树)│ 所有被监听的 fd,按 fd 号排序,增删 O(log n)
│ rdllist │ 就绪链表,设备回调时把 epitem 挂进来
│ wq(等待队列)│ 调用 epoll_wait 的线程睡在这里
└──────────────┘
每个 fd 对应一个 epitem,注册时绑定回调 ep_poll_callback
数据到达 → 软中断 → 回调把 epitem 插入 rdllist → 唤醒 wq 上的线程
因为"就绪判断"发生在事件到达时(回调),而不是"每次调用时扫描",所以监听 10 万个 fd
但只有 10 个活跃时,epoll 的开销只跟 10 有关。
3. 代码:水平触发与边缘触发
int ep = epoll_create1(EPOLL_CLOEXEC);
struct epoll_event ev = {.events = EPOLLIN | EPOLLET, .data.fd = fd};
epoll_ctl(ep, EPOLL_CTL_ADD, fd, &ev); // 只注册一次
for (;;) {
struct epoll_event evs[64];
int n = epoll_wait(ep, evs, 64, -1);
for (int i = 0; i < n; i++) {
if (evs[i].events & EPOLLIN) {
for (;;) { // ET 必须读到 EAGAIN 为止
ssize_t r = read(evs[i].data.fd, buf, sizeof buf);
if (r > 0) { handle(buf, r); continue; }
if (r == 0) { close_conn(evs[i].data.fd); break; } // 对端关闭
if (errno == EAGAIN || errno == EWOULDBLOCK) break; // 读干净了
if (errno == EINTR) continue;
close_conn(evs[i].data.fd); break;
}
}
}
}
# 观察一个高并发服务是否在靠 epoll 工作
cat /proc/1234/fdinfo/5 2>/dev/null | head
strace -c -e trace=epoll_wait,epoll_ctl -p 1234
ss -s # 当前 socket 与内存概况
4. 常见追问
Q:LT 和 ET 怎么选?
LT(水平触发)只要缓冲区还有数据就反复通知,写起来简单、不容易漏事件,默认选它;
ET(边缘触发)只在状态变化时通知一次,必须配非阻塞 fd 并循环读到 EAGAIN,否则会丢数据,
但系统调用次数更少,Nginx/Redis 用 ET。
Q:select 的 1024 限制从哪来?
fd_set 是固定长度位图,FD_SETSIZE 编译期定为 1024,且 fd 值不能超过它。
改大需要重新编译内核与所有使用方,所以实际不可行。
Q:epoll 一定比 select 快吗?
连接少且都活跃时差别不大,select 甚至更快(没有红黑树开销)。epoll 的优势在"大量连接、少量活跃"。
另外 epoll_wait 返回的事件需要用户态自己维护 fd 到上下文的映射(data.ptr)。
Q:什么是惊群?怎么解决?
多个线程/进程同时 epoll_wait 同一个监听 fd,新连接到来时全被唤醒,只有一个能 accept,其余白跑。
解决:EPOLLEXCLUSIVE 让内核只唤醒一个;或每个 worker 用自己的 epoll + SO_REUSEPORT;
或在应用层加锁后再 accept。
小结
select 是"每次全量拷贝 + 全量扫描 + 1024 上限",poll 去掉了上限但没改扫描方式, epoll 用"注册一次 + 回调入链表 + 只取就绪"把复杂度降到与活跃数相关。 答题时把三者数据结构与复杂度讲清,再补 LT/ET 的编程差异和惊群处理,基本就是满分答案。