§ 1 按时间顺序
BIO
- 即 同步阻塞 IO
- 读写请求会阻塞至有数据可供读写
- 等待内核数据就绪并将数据传输到阻塞区
- 会为每个请求开辟一个线程
因此遇到高并发时,线程数量会爆炸
PIO
- 即 同步伪非阻塞 IO
- 通常配合线程池模型使用的 IO
- 解决了线程的频繁创灭问题
但最大线程数和等候队列都打满时依然爆炸
NIO
- 即 同步阻塞 IO
- 读写会阻塞,但读写请求不会
- 通过 Reactor 模式,注册 IO 事件
- 可供读写的数据准备就绪时,才开始正式读写
- 解决了从接收到读写请求,到可以真实进行读写之间的阻塞浪费
AIO
- 即 异步非阻塞 IO
- 读写不会阻塞
- 引入异步通道
- 代码简单
- 依赖 Netty
§ 2 同步 & 异步、阻塞 & 非阻塞
| 同步 | 异步 |
---|
阻塞 | BIO | - |
非阻塞 | NIO / PIO | AIO |
同步 & 异步
- 针对系统进程,是操作系统底层读写层面上是否阻塞
- 系统进程在等待数据就绪的时间范围内,是否阻塞
- 现象上主要影响系统进程通知用户应用数据已经就绪的方式,阻塞 VS 回调
阻塞 & 非阻塞
- 针对用户应用,是 API 调用时获取返回的过程是否阻塞
- 调用方发起一个请求后,接收到这个请求的响应之前,是否阻塞
- 现象上主要影响 API 调用返回的即时性,立即 VS 稍后
NIO 到底是异步阻塞还是同步非阻塞
本帖更倾向于 同步非阻塞,虽然作者也更习惯于反着叫(符合项目开发经验),但还是尊重官方 API 的命名
- 这个问题本质上是个文字游戏,类似
@Overload
和 @OverWrite
是重载、重写还是重写、复写 - 同步异步和阻塞非阻塞这两组,本质上都是对是否阻塞的区分,只是阻塞的位置不一样
- 对应的 阻塞位置 可参考下文 BIO 模型中红线和红框
- 只要能正确理解和区分这两处阻塞并可以在交流过程中表达清楚,怎么称呼实际上无所谓
- 这两处阻塞的冠名为什么会有差异
- 红线 处,是 API 调用相关的阻塞
- 在日常项目中,我们通常用 同步/异步 进行区分
依据如 Ajax 是一门用于网页异步请求的技术
、来,小伙,把这个接口给我改成异步
- 但在官方的 API 中,确实把它叫做 阻塞/非阻塞
依据如 socketServerChannel.configureBlocking(false)
- 红框 处,是 系统底层读写层面上的阻塞
- 在日常项目中,我们通常用 阻塞/非阻塞 进行区分
依据如 测试反馈迁移大镜像总卡死,我看了下,后台阻塞了,小伙你给优化一下
- 但在官方的 API 中,确实把它叫做 同步/异步
依据如 AsynchronousFileChannelDemo
这个类在读写时,是系统读写完成后通过回调通知 API 的,其名为 Asynchronous
而不是 Unblocked
§3 各个 IO 模型
BIO
- 主线程 收到 IO 请求
- 主线程 在 recvfrom 系统调用阻塞
- 应用因为系统调用切换至内核态
- 内核等待数据就绪,比如等待网络 IO 中完整数据包到达
- 内核数据就绪,复制到用户空间
- 完成复制,退回用户态
- 应用处理数据
- 返回 IO 响应
阻塞位置
这里的阻塞就是指广义的阻塞(不在区分阻塞、异步),下同
下面方法会互相阻塞,卡在其中之一就不能响应其他
accept()
,等待连接read()
,等待对方写write()
,等待对方读
因此,SocketServer
等待对方 Socket
写出时,就不能响应其他 Socket
的连接、写出请求
PIO
- 主线程 收到 IO 请求
- 主线程 在线程池启动一根 子线程 处理此 IO 请求
- 子线程 在 recvfrom 系统调用阻塞
- 子线程 因为系统调用切换至内核态
- 内核等待数据就绪,比如等待网络 IO 中完整数据包到达
- 内核数据就绪,复制到用户空间
- 完成复制,退回用户态
- 子线程 处理数据
- 返回 IO 响应
阻塞位置
下面方法 对同一个线程而言 会互相阻塞,卡在其中之一就不能响应其他
accept()
,等待连接read()
,等待对方写write()
,等待对方读
PIO 达到了非阻塞效果
- 阻塞的实际上是线程池里开辟处理处理请求的子线程
- 主线程上的
SocketServer
不耽误接待其他 Socket
的请求
由此达到了 非阻塞 效果,但上图红线处的阻塞实际上并没有解决,因此只是 伪非阻塞
伪非阻塞的问题
伪非阻塞通过开辟线程达到非阻塞的效果,但它的问题也来自于线程池
- 开辟线程本身是个系统调用,会加剧模型中的用户态内核态切换,带来开销
- 线程本身也是一种比较珍贵的资源,JVM 线程数是有限制的
Linux 默认一个线程最大 1024 个线程,详情参考 基础 | JVM - [内存溢出] - 开辟线程本身也是一种开销,需要消耗 CPU 时间
- 高并发时,线程池打满只能拒绝请求
NIO
- 客服端通过
Socket
连接到服务端,并发送请求 - 服务端将请求和文件描述符注册到 多路复用器
- 多路复用器 将文件描述符和请求按请求与对应的 事件处理器进行关联
- 多路复用器 默认通过
epoll
函数监管文件描述符和请求 - 数据就绪时,多路复用器生成对应的 文件事件
- 多路复用器 将 文件事件 放到 事件队列 中
- 事件队列 经由单线程的 文件事件分派器 依次消费
- 文件事件分派器 将事件分发给关联的 事件处理器 进行处理
阻塞位置
下面方法都是 不阻塞 或 不完全阻塞 的
accept()
,完全不阻塞,没有连接直接返回 errorread()
,不完全阻塞,未读到数据直接返回error,只有读到数据且正在读数据的过程中阻塞write()
,不完全阻塞
NIO 引入 Socket
队列 (假设没有引入多路复用)
通过了 Socket
队列,实现了单线程监管多个 Socket
,同时解决了频繁开辟线程的问题
- NIO 对多个
Socket
的监管,只依赖于一个线程 - 与
Socket
的连接加入数组,只由一个线程周期性遍历一次 - 每次遍历只判断数据是否就绪,不进行其他操作
但 Socket
队列尚有下列问题没有解决
Socket
很多时,Socket
队列就会很长,但实际上数据就绪的可能很少,这是极大的浪费
Socket
队列中大部分 Socket
都是没有数据的,但不得不始终轮询- 没有多路复用时,只能通过
read
进行轮询,相当于 玩命切换用户态、内核态
总结:
优点
- 实现单线程管理多
Socket
不足 - 队列中管理很多
Socket
连接,连接越多,队列越长,但数据就绪数量可能很低,浪费 - 轮询依赖
read()
,海量系统调用导致频繁的用户态内核态切换,浪费
NIO 引入多路复用
更多详细内容参考 中间件 | Redis - [深度理解多路复用]
多路复用程序将批量的轮询包装为多个文件描述符(File Descriptor),并统一传输给内核
轮询通过内核态完成,避免用户态内核态海量切换