阻塞式 vs 非阻塞式 I/O 总结

从线程模型到 Selector 多路复用,彻底理解 I/O 模型的演进

阻塞 I/O 非阻塞 I/O Selector 多路复用 线程模型 NIO

目录导航(点击跳转)

一、一句话区别

1 核心定义
  • 阻塞:调用 accept()/ read()后,线程原地卡死,数据不来永远等着,什么都干不了。
  • 非阻塞:调用 accept()/ read()后,线程立刻返回,有就处理,没有就接着干别的。

二、阻塞 vs 非阻塞对比表

1 行为对比
阻塞(默认) 非阻塞
accept() 没人连 → 卡死不动 没人连 → 立刻返回 null
有人连 → 返回 SocketChannel 有人连 → 返回 SocketChannel
read() 没数据 → 卡死不动 没数据 → 立刻返回 0
有数据 → 返回读到的字节数 有数据 → 返回读到的字节数

三、为什么阻塞式 1 个连接就要 1 个线程?

1 线程被 read() 绑架了

线程一旦进入 read(connA),就卡死在里面,根本没机会回去调下一次 accept()。后面的客户端 B 连上了也无人理睬。

要解决只能:每 accept 一个连接,立刻丢给一个新线程去慢慢 read,主线程赶紧回去继续 accept。

2 阻塞模式线程分配图
acceptA →
new Thread-1
acceptB →
new Thread-2
acceptC →
new Thread-3
继续 accept…
Thread-1 Thread-2 Thread-3
read(A) — 卡住等数据 ————→ 数据到了
read(B) — 卡住等数据 ————→ 数据到了
read(C) — 卡住等数据 ————→ 数据到了
3 资源消耗分析

1000 个连接 → 1000 个线程 → 每个线程栈默认 1MB → 光栈内存就 1GB,线程切换也超级费。

这就是阻塞模型的致命缺陷:连接数 = 线程数,根本无法支撑高并发场景。

四、为什么非阻塞 1 个线程能管所有连接?

1 一根棍子巡视全场

所有调用都不卡,线程可以在 N 个连接之间飞速跳来跳去:

accept? 没有
read(A)? 0
read(B)? 有!处理
read(C)? 0
accept? 有D!加入
...

← 一轮循环,全照顾到了,谁都不耽误 →

2 阻塞 vs 非阻塞:线程模型对比
阻塞模式(1:1)
Thread-1
卡在connA
read()
Thread-2
卡在connB
read()
Thread-3
卡在connC
read()
非阻塞模式(1:N)
Thread-1
轮询 A → B → C → D  |  谁有数据就处理谁

五、三层演进:棍子比喻

1 棍子转得再快,99% 都是白费

非阻塞轮询就像一根飞速转的棍子,只要转得够快,每个连接有数据的瞬间都能被"拦截"到。

但棍子转得再快,99% 的时间你问每个连接「有数据吗?」,回答都是「没」,CPU 在空转,大部分转圈是白费的。

所以有了第三层 — Selector

2 三层模型对比
棍子怎么转 CPU 消耗 延迟
阻塞 不转,蹲一个人面前不走 低(线程挂起) 其他连接延迟无限大
非阻塞轮询 自己疯狂转,挨个问 高(大部分白问) 取决于棍子转多快
Selector(多路复用) OS 帮你转,有事才叫你 低(没事件就睡) 低(事件到达立刻通知)
3 三层演进流程图
阻塞
蹲门口死等一人
→ 演进 →
非阻塞轮询
来回跑挨个问
→ 演进 →
Selector
OS 替你转棍子

第一步解决"能管多个" → 第二步解决"不白费 CPU"

六、Selector 一句话

1 三种模式的生动比喻
阻塞模式 非阻塞轮询 Selector
你站门口死等一个人 你来回跑挨个房间问 所有人给你发微信
其他人完全不管 CPU 累死,大部分白问 谁有动静你才去看谁,平时躺着

操作系统当你的「秘书」,在内核层面帮你同时盯着所有连接,有事件才通知你。你不用自己转棍子了。

2 Selector 工作机制图
你的线程 Selector OS 内核
1 注册所有 Channel 到 Selector
2 委托 OS 内核监听
Selector 在内核帮你盯着所有连接
3 select()— 没事件就睡,不烧 CPU
4 某连接有数据到达
OS 通知 Selector
5 select()返回,拿到就绪的 Key 集合
6 遍历 selectedKeys,处理就绪事件

七、核心代码对比

1 阻塞模式
// ========== 阻塞模式 ==========
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.bind(new InetSocketAddress(8080));
while (true) {
    SocketChannel conn = ssc.accept();        // ← 卡死
    new Thread(() -> {
        conn.read(buf);                       // ← 卡死
    }).start();
}
2 非阻塞模式
// ========== 非阻塞模式 ==========
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.configureBlocking(false);
ssc.bind(new InetSocketAddress(8080));
while (true) {
    SocketChannel conn = ssc.accept();        // ← 立刻返回(null 或连接)
    if (conn != null) {
        conn.configureBlocking(false);
        int n = conn.read(buf);              // ← 立刻返回(0 或有数据)
    }
}
3 Selector 模式
// ========== Selector 模式 ==========
Selector sel = Selector.open();
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.configureBlocking(false);
ssc.register(sel, SelectionKey.OP_ACCEPT);   // 让 OS 帮你盯着
while (true) {
    sel.select();                             // ← 没事件就睡,不烧 CPU
    for (SelectionKey key : sel.selectedKeys()) {
        if (key.isAcceptable()) { /* 新连接 */ }
        if (key.isReadable())   { /* 有数据 */ }
    }
}
4 三种模式代码差异对比
特性 阻塞模式 非阻塞轮询 Selector
配置方式 默认 configureBlocking(false) configureBlocking(false)+ register(sel, ops)
accept 行为 卡死等连接 立刻返回 null 或连接 Selector 通知有新连接
read 行为 卡死等数据 立刻返回 0 或字节数 Selector 通知可读
线程模型 1 连接 : 1 线程 1 线程 : N 连接 1 线程 : N 连接
CPU 消耗 低(挂起) 高(空转轮询) 低(事件驱动)
适用场景 连接少、逻辑简单 不推荐(过渡方案) 高并发、生产环境

八、核心思想总结

阻塞 vs 非阻塞 I/O 核心要点

Selector 多路复用的优势

  • 单线程管理成千上万连接,线程开销趋近于零
  • 事件驱动,CPU 只在有数据时工作,空闲时休眠
  • 延迟极低——数据到达立刻通知,无需等待轮询周期
  • Netty、Redis、Nginx、Tomcat NIO 底层全是这套机制

注意事项

  • Selector 编程模型比阻塞复杂,需要处理事件分派
  • 单线程处理所有连接,单个处理不能耗时过长(会阻塞后续事件)
  • 需要理解 SelectionKey 的就绪状态和迭代清理机制
  • 实际生产环境通常用 Netty 等框架封装,不直接操作 Selector
一句话总结

阻塞是线程被 read() 绑架了,不拿到数据不放人;非阻塞是线程随时脱身,一根棍子巡视全场;Selector 是 OS 替你转棍子,省下 CPU 干正事。

Netty、Redis、Nginx、Tomcat NIO 能支撑几万并发连接,底层全是这套 Selector 机制。