IO 多路复用 — select/poll/epoll
IO 多路复用是高性能网络编程的基石。从 select 到 poll 再到 epoll,内核的发展史就是一部”如何用更少的资源管理更多连接”的优化史。
一、为什么需要多路复用
在传统的阻塞 IO 模型中,accept() 和 read() 都是阻塞的——一个连接的处理会阻塞整个线程:
1 | // 传统阻塞模型:一个连接一个线程 |
问题:1000 个连接 = 1000 个线程。每个线程占用 ~1MB 栈空间 + 上下文切换开销。多路复用让一个线程同时监视成百上千个连接。
二、select
1 | fd_set read_fds; |
特点:
| 特性 | 说明 |
|---|---|
| 最大连接数 | 1024(FD_SETSIZE 限制,可重编译但效率下降) |
| 传入传出 | 每次调用都要把整个 fd_set 从用户态复制到内核态 |
| 查找就绪 | O(N) 遍历所有 fd 检查 FD_ISSET |
| 触发模式 | 仅水平触发(Level Triggered) |
三、poll
poll 解决了 select 的 1024 限制,但本质还是轮询:
1 | struct pollfd fds[10000]; |
相比 select 的改进:
- 使用链表存储 fd,不再有 1024 限制
- 事件和返回用不同字段(
events传入,revents传出)
未解决的问题:仍然需要 O(N) 遍历所有 fd 来确定哪些就绪。
四、epoll
epoll 是 Linux 上的终极 IO 多路复用方案,通过”回调通知”彻底告别 O(N) 遍历。
4.1 核心 API
1 | int epfd = epoll_create(1); // 创建 epoll 实例 |
核心差异:events[] 返回的都是真正就绪的 fd,不需要遍历所有注册的 fd。
4.2 触发模式
| 模式 | 行为 | 适用场景 |
|---|---|---|
| LT(水平触发) | 只要缓冲区有数据就不停通知 | 简单可靠,潜在通知风暴 |
| ET(边缘触发) | 只在状态变化时通知一次 | 高性能,但需要非阻塞 IO + 一次读完 |
Netty 默认使用 ET 模式以减少不必要的通知。
4.3 底层实现
epoll 在内核中用红黑树存储所有注册的 fd,用就绪链表存储就绪的 fd:
1 | epoll_create → 创建 eventpoll 对象 |
当网卡收到数据 → 触发中断 → 内核协议栈处理 → 调用 epoll 注册的回调 → 将 fd 加入就绪链表 → 唤醒等待 epoll_wait 的进程。
五、对比总结
| 特性 | select | poll | epoll |
|---|---|---|---|
| 性能(大量空闲连接) | O(N) 线性下降 | O(N) 线性下降 | O(1),仅活跃连接 |
| 最大连接数 | 1024 | 无限制 | 无限制(受系统内存限制) |
| 内核态/用户态拷贝 | 每次完整拷贝 | 每次完整拷贝 | 仅拷贝就绪事件 |
| 触发模式 | 仅 LT | 仅 LT | LT + ET |
| 适用场景 | 连接数少 | 连接数中 | 高并发首选 |
六、在 Netty 中的应用
Netty 在 Linux 上自动选择 epoll,通过 EpollEventLoop 实现:
1 | // Netty 自动选择最优传输 |
七、小结
select/poll 靠轮询找到就绪的 fd(O(N)),epoll 靠回调通知直接拿到就绪列表(O(1))。这个 O(N) 到 O(1) 的跨越,让单台服务器可以承载数十万甚至百万级的长连接。Java NIO 的 Selector 在 Linux 上底层就是 epoll。