一句话总结
IO 多路复用让一个线程同时监控多个文件描述符,哪个可读可写了再通知应用去处理,避免为每个连接开一个线程。三代实现:select(遍历全部 fd,上限 1024)、poll(去掉数量限制但仍要遍历)、epoll(内核维护就绪链表,只返回活跃的 fd,支持百万连接)。Redis、Nginx、Netty 都基于 epoll,它是高并发网络的基石。
初级理解
不用多路复用会怎样
# 方案1:一个连接一个线程(BIO)
# 1 万个连接 = 1 万个线程,每个线程默认栈 8MB → 光栈就 80GB
# 且绝大多数连接在等待,线程上下文切换开销拖垮 CPU
# 方案2:一个线程轮询所有连接
# 每次都要遍历所有 fd 问"你有数据吗",99% 是白问,CPU 空转
# 方案3:IO 多路复用(正解)
# 把"监控哪些 fd 可读可写"委托给内核,内核只汇报就绪的那些
# 一个线程搞定成千上万连接,C10K 问题的标准答案
一次 epoll 调用的生命周期
# 1. epoll_create() 创建 epoll 实例,内核分配 eventpoll 结构
# 2. epoll_ctl(ADD) 把监听 socket 和各个连接注册进去
# 3. epoll_wait(timeout) 阻塞等待,内核把就绪的 fd 填进 events 数组
# 4. 应用遍历 events(通常只有几个)逐个 recv/send
# 5. 连接关闭 → epoll_ctl(DEL) 移除
# 关键:epoll_wait 返回的只有"活跃"的 fd,不是全部
中级深入
三代实现对比
| select | poll | epoll |
| fd 上限 | 1024 | 无 | 受系统内存限制(百万级) |
| 每次调用是否遍历全部 fd | 是 | 是 | 否,只返回就绪的 |
| 工作方式 | 轮询 | 轮询 | 事件回调(就绪通知) |
| fd 集合复制 | 每次调用都要拷入内核 | 同左 | 注册一次长期保存 |
| 跨平台 | 好 | 好 | 仅 Linux(其他平台用 kqueue/iocp) |
连接数少时三者差别不大;连接量大且活跃比例低时,epoll 的优势是数量级的。
epoll 为什么快
# 1. 注册时把 fd 挂到内核的红黑树,并注册回调 ep_epoll_callback
# 2. 网卡收到数据 → 中断 → 协议栈 → 回调把 fd 加入就绪链表 rdllist
# 3. epoll_wait 只需要检查 rdllist 是否为空:
# - 空:挂起当前进程(加入等待队列,不空转)
# - 非空:把就绪的 fd 拷给用户态,O(就绪数) 而不是 O(总连接数)
# 对比 select:每次调用都要把 100 万个 fd 的描述符从用户态拷到内核,
# 再逐个问一遍"你就绪了吗" —— 两次全量遍历
LT 与 ET
# LT(水平触发,默认):fd 就绪且数据没读完,下次 epoll_wait 还会通知
# 编程简单,不易漏数据;代价是可能被反复通知
# ET(边缘触发):状态变化时只通知一次,必须一次把数据读完(循环 recv 到 EAGAIN)
# 效率更高,每个 fd 通知次数最少;漏读就不会再提醒,易踩坑
# Nginx 用 ET,Redis 用 LT —— 追求极限性能选 ET,求稳选 LT
高级进阶
同步 IO 模型的四个阶段
# 一次 read 分两段:等待数据就绪 + 从内核拷贝到用户态
# 阻塞 IO:两段都等(线程挂起)
# 非阻塞 IO:两段都不等,轮询(烧 CPU)
# IO 多路复用:select/epoll 等"就绪"这一段,recv 时数据已就绪基本不阻塞
# 异步 IO(AIO/Linux io_uring):连拷贝都交给内核,完成才通知 —— 真正的异步
# 注意:IO 多路复用本质是同步 IO(recv 还是要自己拷),常被误叫"异步 IO"
reactor 模式与典型应用
# Reactor 模式 = IO 多路复用 + 事件分发
# Reactor(epoll_wait)收到事件 → 分发给对应 Handler
# 单 Reactor 单线程:Redis(6.0 前)
# 单 Reactor 多线程:Netty(IO 线程 + 业务线程池)
# 主从 Reactor:Nginx(master accept,worker 处理连接)
# Java:Selector(Linux 上就是 epoll 的封装)→ SelectionKey 分发
# Go:netpoll,goroutine 遇到阻塞 IO 时挂起,由 runtime 用 epoll 统一调度
实战场景
# 场景1:epoll_wait 返回后该怎么写循环
while (true) {
int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < n; i++) {
if (events[i].data.fd == listen_fd) { accept 并注册新连接 }
else {
while (true) { // ET 模式必须读到 EAGAIN
int len = recv(fd, buf, sizeof(buf), 0);
if (len > 0) { 处理数据 }
else if (len == 0) { 关闭连接并 epoll_ctl(DEL); break }
else if (errno == EAGAIN) break; // 读完了
}
}
}
}
# 场景2:Java NIO 快速上手
Selector selector = Selector.open();
ServerSocketChannel server = ServerSocketChannel.open();
server.configureBlocking(false); // 必须非阻塞
server.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
if (selector.select() == 0) continue;
for (SelectionKey key : selector.selectedKeys()) {
if (key.isAcceptable()) { 注册新连接到 selector }
else if (key.isReadable()) { ((SocketChannel) key.channel()).read(buf) }
}
selector.selectedKeys().clear(); // 别忘了清空
}
# 场景3:线上连接数暴涨怎么排查
ss -s # 看总体连接统计
ss -tan | awk '{print $1}' | sort | uniq -c # 各状态连接数
ls /proc/PID/fd | wc -l # 进程打开的 fd 数(逼近 ulimit -n 就有风险)
# TIME_WAIT 过多:短连接太多,改长连接或调 tcp_tw_reuse
面试模拟
面试官:select、poll、epoll 的区别?
你:select 有 1024 的 fd 上限且每次调用都要把 fd 集合拷入内核再逐个遍历;poll 去掉了数量限制但仍是全量遍历;epoll 注册一次长期保存在内核红黑树里,fd 就绪时由回调加入就绪链表,epoll_wait 只返回活跃的 fd,复杂度从 O(总连接数) 降到 O(就绪数),所以能扛百万连接。
面试官:epoll 的 LT 和 ET 有什么区别?
你:LT 是水平触发,fd 就绪且数据没读完,下次还会通知,编程简单不易漏读;ET 是边缘触发,只在状态变化时通知一次,必须一次性把数据读到 EAGAIN,漏读就不会再提醒。LT 是默认模式,Nginx 用 ET 追求性能,Redis 用 LT 图省心。
面试官:IO 多路复用一定是异步的吗?
你:不是。IO 分等待就绪和数据拷贝两个阶段,多路复用只让"等待就绪"这一段可以同时等很多 fd,recv 时还是要应用自己把数据从内核拷到用户态,本质还是同步 IO。真正的异步 IO 是 Linux 的 AIIO/io_uring,连拷贝都交给内核、完成才回调。