阻塞、非阻塞、同步和异步 I/O 有什么区别?
简化版
阻塞/非阻塞关心“调用时数据没准备好,当前线程是否等待”;同步/异步关心“I/O 完成由调用方主动执行和确认,还是提交后由系统在完成时通知”。非阻塞不等于异步:Java SocketChannel 非阻塞读仍由应用线程主动调用,而 AsynchronousSocketChannel 才提供完成回调或 Future。
详细版
可以把两组概念拆成两个维度:
- 阻塞:调用暂时不能完成时,当前线程停在调用处等待。
- 非阻塞:调用暂时不能完成时,立即返回当前结果,例如返回 0 字节,应用程序之后再试。
- 同步 I/O:应用程序在 I/O 就绪后主动执行
read/write,并在调用返回后获得处理结果。 - 异步 I/O:应用提交 I/O 请求后继续工作,完成时通过回调、
Future等机制获取结果。
常见 Java 对应关系:传统 InputStream.read() 通常是同步阻塞;非阻塞 SocketChannel + Selector 是同步非阻塞的事件驱动用法;AsynchronousSocketChannel 和 AsynchronousFileChannel 是 Java NIO.2 的异步通道抽象。
完整版教学
一、先区分“等待就绪”和“复制数据”
以网络读取为例,一次 I/O 至少可以概念化为两段:
- 等待网络数据进入内核接收缓冲区;
- 将数据交给应用程序可访问的缓冲区。
同步非阻塞模式下,线程不在第一段的某个 Socket 上死等,可以用 Selector 等待多个连接;但得到就绪通知后,仍由应用线程调用 read() 获取数据。因此它不是“发起以后什么都不用管”。
二、四种说法如何理解
| 模式 | 典型表现 | Java 中的常见例子 |
|---|---|---|
| 同步阻塞 | 调用 read 后等到有数据或结束 | InputStream、阻塞 SocketChannel |
| 同步非阻塞 | read 无数据时立即返回,应用稍后重试 | 非阻塞 SocketChannel |
| 同步非阻塞 + 多路复用 | 先等待就绪集合,再主动读写 | Selector 事件循环 |
| 异步 | 提交操作,完成时收到结果 | AsynchronousSocketChannel |
Selector.select() 本身可以阻塞等待事件,这与被它监听的 Channel 处于非阻塞模式并不矛盾。一个线程在一个 Selector 上睡眠,比很多线程分别在很多 Socket 上等待更容易管理。
三、异步通道如何返回结果
ByteBuffer buffer = ByteBuffer.allocate(4096);
channel.read(buffer, buffer, new CompletionHandler<>() {
@Override
public void completed(Integer count, ByteBuffer attachment) {
if (count == -1) return;
attachment.flip();
// 处理读取到的数据
}
@Override
public void failed(Throwable error, ByteBuffer attachment) {
// 记录异常并清理连接
}
});
AsynchronousSocketChannel 支持 CompletionHandler 和 Future。使用 Future.get() 后立即在原地等待,会把异步 API 又用成阻塞风格;它仍是异步接口,但调用方并没有获得并发编排上的好处。
异步操作完成前,不能把同一个 Buffer 提前清空、归还池中或交给其他请求复用,否则底层 I/O 与应用修改会并发访问同一块数据。
四、实际选型不能只看名词
- 连接数少、逻辑简单:同步阻塞代码最直观。
- 大量长连接且单次处理很短:Selector 事件循环可以很高效,但状态机和半包处理更复杂。
- 已有框架封装:优先遵循框架的线程模型,不要在事件线程中做长时间阻塞业务。
- Java 21+ 虚拟线程能让很多阻塞 I/O 代码在保留简单编程模型的同时支撑高并发,但它不会让 I/O 本身变快,也不能消除数据库连接、外部服务等下游容量限制。
五、用时间线判断四个概念
假设客户端在 t=0 调用读取,数据在 t=80 ms 到达内核,复制到应用 Buffer 需要 2 ms。同步阻塞调用可能从 t=0 一直返回到 t=82 ms;同步非阻塞调用会在数据未到时立即返回 0,应用稍后再试;Selector 可让线程在一个等待点观察多个连接,某个连接就绪后仍由应用调用 read()。
t=0 t=80 ms t=82 ms
提交/尝试读取 ──等待数据──→ 数据就绪 ──复制/交付──→ 应用得到结果
异步 API 的关键不是“绝不使用线程”,而是调用方提交操作后不必亲自轮询就绪,并在完成时接收结果。底层可能利用内核异步设施,也可能借助实现管理的线程,API 合同并不承诺所有平台采用同一种内核机制。
| 问题 | 阻塞/非阻塞维度 | 同步/异步维度 |
|---|---|---|
| 暂时做不了是否立即返回 | 是 | 否 |
| 谁负责等待并交付最终完成结果 | 否 | 是 |
| Selector 的典型归类 | 非阻塞 | 同步 |
| CompletionHandler 的典型归类 | 调用方不原地等待 | 异步 |
心法:先画出“等待数据就绪”和“完成数据交付”两阶段,再判断调用方在哪一阶段等待、谁拿到最终结果,名词就不会混。
六、超时、取消与背压不能省略
非阻塞或异步只改变等待方式,不代表请求可以无限提交。若服务每秒只能完成 500 次写入,却持续接收 800 次请求,每秒会积压 300 个;10 秒后就是约 3,000 个待处理任务,最终表现为内存增长和尾延迟恶化。
工程上要给连接数、待写队列、并发异步操作和超时设置上界。取消 Future 或关闭异步通道的具体结果要按 API 合同处理,回调仍要区分正常完成、失败、超时和关闭,并确保 Buffer 不会被并发复用。
七、常见误区与追问
- 误区:非阻塞 I/O 就是异步 I/O。 非阻塞
SocketChannel.read()仍由应用主动调用并取得结果,通常属于同步非阻塞模型。 - 误区:
Selector.select()会阻塞,所以整个模型就是阻塞 I/O。 Selector 可以阻塞在统一等待点,而被管理的通道仍是非阻塞的,两者并不矛盾。 - 误区:使用
Future就一定没有阻塞。 如果提交后立即调用get()原地等待,调用方仍会阻塞,只是使用了异步接口。 - 误区:异步 I/O 不需要任何线程。 API 只规定完成通知语义,底层实现仍可能使用系统线程或线程池。
- 追问:同步非阻塞为什么常和多路复用一起出现? 单纯轮询大量 Socket 会浪费 CPU,Selector 能集中等待就绪集合,避免无意义重试。
- 追问:回调里能否立刻复用原 Buffer? 只有当前操作已经完成且没有其他并发操作持有它时才可以,否则会产生数据竞争或内容污染。
- 追问:虚拟线程属于异步 I/O 吗? 对业务代码通常仍是同步阻塞风格,只是 JVM 能在许多阻塞点卸载虚拟线程,降低平台线程占用。
八、加强记忆
把两组概念分开记:阻塞与非阻塞看“当前调用要不要原地等”,同步与异步看“谁在完成 I/O 后交付结果”。非阻塞只代表这次调用不死等,不代表 I/O 完成后一定主动回调。