BIO、NIO 与多路复用
一个服务要扛一万个连接。BIO 的写法是一个连接一个线程,一万个线程,每个都在干等数据来。光是线程栈就吃掉几个 GB,CPU 忙着在它们之间倒手,业务逻辑没跑几行。NIO 和多路复用解决的就是这笔账,演进的主线只有一个问题:等待这件事,谁来干。
BIO:线程干等
BIO 的 accept、read、write 全是阻塞的。线程调 read 就挂在那,数据不来不动窝,来了才醒。模型简单直观,代价是「一个连接一个线程」,线程数跟着连接数走。连接大部分是闲的,线程大部分在睡,睡也是要占内存、要调度的。
NIO:自己轮询
Java NIO 给了三件套,Channel(通道)、Buffer(缓冲区)、Selector(选择器)。连接设成非阻塞之后,read 没数据立刻返回,线程可以去问下一个连接。Selector 就是「一次问一圈」的工具,一个线程管理成千上万个 Channel,谁就绪了处理谁。
账变了,线程数不再跟连接数走。但 Selector 底下的实现在操作系统层面有个演进过程,这决定了「问一圈」的效率。
多路复用:让内核代看
select 和 poll 是第一代。每次调用都把整个连接列表从用户态拷给内核,内核挨个看一遍谁就绪,返回后程序再挨个找。一万连接看一圈是 O(n),大部分连接还是没数据,白看。
epoll 是第二代。连接注册一次,之后内核只把「就绪的」推给你,看几个拿几个,O(活跃数)。连接再多,活跃的就那么些,账就平了。
Netty 站在这层肩膀上。它把 Reactor 模式(事件来了分发给处理者)封装好,加上线程模型、缓冲区管理和协议编解码,让你写业务而不是写 Selector 循环。你在用的中间件,消息队列客户端、网关、SSE 流式输出,底下大都是这条路。
亲手看账单
下面用一段 Java 起一个单线程的 Selector 服务,一百个客户端连接各发一条消息,看一个线程怎么扛下全部读写。直接运行 java IoBill.java(JDK 11+)。
// 直接运行:java IoBill.java
// 一个 Selector 线程扛 100 个连接,对照 BIO 的一线程一连接。
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.concurrent.atomic.AtomicInteger;
public class IoBill {
public static void main(String[] args) throws Exception {
Selector selector = Selector.open();
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.bind(new InetSocketAddress("127.0.0.1", 0));
ssc.configureBlocking(false);
ssc.register(selector, SelectionKey.OP_ACCEPT);
int port = ((InetSocketAddress) ssc.getLocalAddress()).getPort();
AtomicInteger handled = new AtomicInteger();
// 唯一的 reactor 线程:accept + read + echo
Thread reactor = new Thread(() -> {
try {
while (!Thread.currentThread().isInterrupted()) {
selector.select(100);
Iterator<SelectionKey> it = selector.selectedKeys().iterator();
while (it.hasNext()) {
SelectionKey key = it.next();
it.remove();
if (key.isAcceptable()) {
SocketChannel ch = ((ServerSocketChannel) key.channel()).accept();
ch.configureBlocking(false);
ch.register(selector, SelectionKey.OP_READ);
} else if (key.isReadable()) {
SocketChannel ch = (SocketChannel) key.channel();
ByteBuffer buf = ByteBuffer.allocate(64);
if (ch.read(buf) > 0) {
buf.flip();
ch.write(buf);
handled.incrementAndGet();
} else {
ch.close();
}
}
}
}
} catch (IOException e) {
// 关闭时退出
}
}, "single-reactor");
reactor.start();
// 100 个客户端,各发一条消息并等 echo
Thread[] clients = new Thread[100];
for (int i = 0; i < clients.length; i++) {
clients[i] = new Thread(() -> {
try (SocketChannel ch = SocketChannel.open(new InetSocketAddress("127.0.0.1", port))) {
ch.write(ByteBuffer.wrap("ping".getBytes()));
ByteBuffer buf = ByteBuffer.allocate(8);
ch.read(buf);
} catch (IOException e) {
// 演示不处理
}
});
clients[i].start();
}
for (Thread t : clients) {
t.join();
}
System.out.println("单一 reactor 线程处理了 " + handled.get() + " 次读写");
System.out.println("BIO 对照:同样 100 个连接要 100 个阻塞线程,栈内存与切换开销乘 100。");
reactor.interrupt();
selector.wakeup();
}
}
记住一句话:IO 模型的演进,是「谁在等」的演进,从线程干等,到程序自己看,再到内核代看。
适用边界
这一篇管 IO 模型的账。epoll 的惊群和 LT/ET 触发模式、Netty 的线程模型细节,以及虚拟线程对 BIO 的复兴(JDK 21 之后写阻塞代码也能高并发),是另外的题。演示用的是单线程 Selector,真实服务是 Reactor 主从多线程,账的量级不变。跑完演示再想那句「一万个连接」,选型时就知道自己的服务站在哪一层。
本文深入剖析BIO、NIO与多路复用(select/poll/epoll)三种IO模型的本质差异,聚焦于‘等待主体’的演进:从线程阻塞等待,到用户态轮询,再到内核代为监控就绪事件。重点对比其时间复杂度、资源开销及适用场景,并指出Netty基于epoll的Reactor封装如何支撑高并发网络服务,为数据库连接池、网关、消息队列等中间件提供底层基础。
2963

被折叠的 条评论
为什么被折叠?



