一次网络 read 调用要经历「等数据就绪」和「把数据从内核拷贝到用户态」两个阶段,BIO、NIO、AIO 的全部分歧就在于线程在这两个阶段分别等什么、由谁来等。本文从操作系统视角串起三种模型的演进逻辑,重点拆解 select/poll/epoll 的原理差异与性能拐点、Reactor 模式,以及 Netty 线程模型的工程定位。

它解决什么问题

早期的网络服务器用 BIO:每来一个连接就配一个线程,线程阻塞在 read 上等数据。连接数少时这套模型简单可靠,但连接数上到万级就撑不住了——一个 Java 线程默认吃掉约 1MB 栈空间,几万个线程意味着几十 GB 内存,再加上线程间频繁上下文切换,CPU 大量时间花在调度而不是干活上。这就是经典的 C10K 问题:如何用少量线程照看成千上万个连接。

要理解解法,先建立一个两阶段框架。以读操作为例,无论哪种模型,数据都要走两步:第一步,内核等待网卡把数据收进内核缓冲区(数据就绪);第二步,内核把数据从内核空间拷贝到用户空间(完成拷贝)。「阻塞还是非阻塞」说的是第一阶段线程是否挂起等待;「同步还是异步」说的是第二阶段由调用线程自己完成,还是由内核代劳后通知。BIO 两个阶段都阻塞;NIO 第一阶段靠多路复用集中等待、第二阶段仍是线程自己拷贝;AIO 两个阶段都交给内核,完成后回调通知。

核心原理

BIO 的问题本质上是「等待」的成本太高:每个连接的等待都独占一个线程。朴素的改进是把 read 设为非阻塞,线程拿不到数据就轮询——但空转轮询烧 CPU,本质上是用应用层的忙等换掉了内核的挂起,得不偿失。

真正的出路是 IO 多路复用:把「挨个询问哪些连接就绪」这件事从用户态循环搬进内核,一次系统调用批量查询。线程阻塞在 select/epoll_wait 上,内核负责监控一大堆文件描述符,哪个就绪了就返回哪个,线程再对就绪的连接发起 read。等待被收敛到一个线程里,连接数与线程数解耦,C10K 甚至 C1000K 才成为可能。Java NIO 的 Selector 就是对这套机制的平台无关封装,Linux 上底层是 epoll。

AIO(Java 7 的 NIO.2)则更进一步,连拷贝阶段都不用自己管,read 调用直接返回,操作完成后通过 Future 或 CompletionHandler 拿结果。但要注意:Linux 上 JDK 的 AIO 网络实现底层仍构建在 epoll 之上,用线程池模拟异步语义,并没有内核级的真异步网络 IO;Windows 的 IOCP 才是原生的完成端口模型。这也是 Netty 曾尝试 AIO 又放弃的原因——在 Linux 上换不来性能收益,反而多绕一层。

深入机制

select、poll 与 epoll 的原理差异

三者的目标相同:监控一批 fd,返回其中就绪的。差别全在数据结构和交互方式上。

select 用固定大小的位图(fd_set)表示关注的 fd 集合,默认上限 1024(FD_SETSIZE)。每次调用都要把整份位图从用户态拷进内核,内核 O(n) 轮询所有 fd,返回时就绪标记直接改写在这份位图上——所以下次调用前必须重新填充。用户拿到结果后还得自己再扫一遍才知道谁就绪。

poll 把位图换成 pollfd 结构体数组,摘掉了 1024 的数量上限,但其余问题原样保留:每次调用依然全量拷贝、内核依然 O(n) 扫描、返回后依然要逐个检查 revents 字段。

epoll 换了思路,拆成三个系统调用:epoll_create 在内核建一个 eventpoll 实例;epoll_ctl 增删改关注的 fd,内核用红黑树管理这份兴趣列表,fd 只在注册时拷贝一次;epoll_wait 直接取走就绪链表。就绪链表是怎么来的?fd 注册时内核会给它挂上回调,数据到达触发中断后由回调把 fd 挂进就绪链表。于是 epoll_wait 的开销只与「活跃连接数」相关,而与「总连接数」解耦——这正是它和 select/poll 的本质差距。

epoll 还有两种触发模式:LT(水平触发,默认)只要缓冲区里还有未读数据就会反复通知,编程容错高;ET(边缘触发)只在「不就绪变就绪」的边沿通知一次,必须配合非阻塞 fd 循环读到 EAGAIN 为止,漏读一个字节这个事件就永远丢了。ET 通知次数更少、效率更高,Netty 的 epoll native transport 走的就是 ET。

性能拐点在哪里? 连接数少且大部分都很活跃时,select 的位图扫描甚至更有优势(位图紧凑、缓存友好);连接数上万、活跃比例低(典型的长连接推送、IM 场景),epoll 的优势是决定性的。经验上拐点出现在千级连接,之后 select/poll 的 O(n) 全量扫描和逐次拷贝开始显著拖后腿。

Reactor 模式:多路复用的编程范式

把「Selector 等事件、按事件类型分发处理」这个循环抽象出来,就是 Reactor 模式:Reactor 负责事件分发,Acceptor 负责接入新连接,Handler 负责业务读写。常见三种形态:

  • 单 Reactor 单线程:所有事件在一个线程里处理完,实现最简单,Redis 6.0 之前的网络模型就是这个形态;瓶颈是单核,业务慢会拖住所有连接。
  • 单 Reactor + 业务线程池:IO 读写仍在 Reactor 线程,耗时的业务计算丢给线程池,充分利用多核。
  • 主从 Reactor:mainReactor 只管 accept,建好的连接分给一组 subReactor 处理读写,职责进一步拆分,是工业级网络框架的标准形态。

Netty 的线程模型定位

Netty 就是主从 Reactor 的工业化实现:boss EventLoopGroup 对应 mainReactor 处理 accept,worker EventLoopGroup 对应 subReactor 处理已建立连接的读写。每个 NioEventLoop 绑定一个线程、跑一个 selector 循环;一个 Channel 从生到死只注册在同一个 EventLoop 上,其所有 IO 事件和任务都串行执行,天然规避了锁竞争。这个「Channel 与线程终身绑定」的设计是 Netty 高吞吐的关键之一,也是它选择 NIO 而非 AIO 的原因之一:模型已经足够高效,AIO 在 Linux 上又无内核红利。

工程实践

  • 连接数几百以内、逻辑简单的内部服务:BIO 加一个有界线程池完全够用,别为用不上的并发度引入复杂度。
  • 高并发长连接场景直接用 Netty:裸写 NIO 要自己处理 epoll 空轮询 bug、半包粘包、OP_WRITE 状态翻转和堆外内存管理,每一项都是坑。
  • 调优先看操作系统参数:文件描述符上限(ulimit -n)、TCP 全连接队列长度(somaxconn)常常是比代码更早撞到的天花板。
  • 别神化非阻塞:多路复用省的是「等待阶段」的线程占用,数据拷贝阶段的耗时依然存在,业务逻辑慢照样拖垮全局。

对比与权衡

维度 BIO NIO(多路复用) AIO
就绪等待 每连接线程各自阻塞 单线程 Selector 集中等待 内核完成全部工作后回调
数据拷贝 线程同步完成 线程同步完成 内核代劳(异步语义)
线程模型 一连接一线程 连接与线程解耦 连接与线程解耦
编程复杂度 最低 高(事件驱动) 中(回调/Future)
Java 载体 Socket、ServerSocket SocketChannel + Selector AsynchronousSocketChannel
典型场景 低并发短连接 高并发长连接 依赖平台支持,应用有限
机制 fd 数量上限 每次调用的拷贝 扫描复杂度 就绪事件返回方式
select 1024(位图) 全量 fd 集合 O(总数) 改写原集合,需自行遍历
poll 无(数组) 全量 fd 集合 O(总数) 检查每个 pollfd 的 revents
epoll 无(内核维护) 仅注册时一次 O(活跃数) 直接返回就绪链表

面试视角

BIO、NIO、AIO 的核心区别是什么? 用两阶段框架回答:BIO 在「等就绪」和「拷数据」两阶段都阻塞线程;NIO 用 Selector 把第一阶段的等待收敛到单线程,第二阶段仍是同步拷贝;AIO 两阶段都交给内核,完成后回调。答出两阶段模型,比背定义高一个档次。

epoll 为什么比 select 快? 三点:fd 集合只在 epoll_ctl 注册时拷贝一次,不用每次调用全量传递;内核通过中断回调维护就绪链表,epoll_wait 只返回就绪 fd,开销与活跃数而非总数挂钩;兴趣列表用红黑树管理,增删改是 O(log n)。

epoll 的 LT 和 ET 有什么区别? LT 是默认模式,只要缓冲区有数据没读完就会持续通知,编程容错高;ET 只在状态翻转的边沿通知一次,必须配非阻塞 fd 循环读到 EAGAIN,漏读即丢事件,换来的是更少的通知次数。Netty 的 native epoll 传输用的是 ET。

Netty 为什么放弃 AIO? Linux 没有内核级的异步网络 IO,JDK 的 AIO 在该平台底层仍基于 epoll 加线程池模拟,相比 NIO 没有性能收益还多一层抽象;Windows 的 IOCP 虽是真异步,但 Netty 的主战场在 Linux,于是统一回到 NIO/epoll 路线。

小结

  • 判断 IO 模型用两阶段框架:等数据就绪谁等、拷贝数据谁拷,BIO/NIO/AIO 一目了然。
  • 多路复用把「遍历询问就绪状态」搬进内核一次系统调用,是连接数与线程数解耦的关键。
  • epoll 赢在 fd 集合一次注册、就绪链表只回活跃事件,开销与总连接数解耦;千级连接以上优势确立。
  • Reactor 是多路复用的编程范式,Netty 的主从 Reactor 加「Channel 与线程终身绑定」是其工程化的标准答案。
  • AIO 在 Linux 上没有内核红利,Netty 弃用它是平台现实,不是异步模型本身的失败。