IO 多路复用是什么?

2026年 阅读约 undefined 分钟 undefined

一个线程怎么同时盯住成千上万个连接?本文讲清 IO 多路复用的原理与 select、poll、epoll 三代实现的区别,epoll 的就绪通知机制、LT 与 ET 模式、为什么 Redis 和 Nginx 都用它,附 Java NIO 示例与面试高频问答。

一句话总结

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,不是全部

中级深入

三代实现对比

selectpollepoll
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,连拷贝都交给内核、完成才回调。