基础 | NIO - [IO 模式的演进]

@

§ 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()完全不阻塞,没有连接直接返回 error
  • read()不完全阻塞,未读到数据直接返回error,只有读到数据且正在读数据的过程中阻塞
  • write()不完全阻塞

NIO 引入 Socket 队列 (假设没有引入多路复用)
通过了 Socket 队列,实现了单线程监管多个 Socket,同时解决了频繁开辟线程的问题

  • NIO 对多个 Socket 的监管,只依赖于一个线程
  • Socket 的连接加入数组,只由一个线程周期性遍历一次
  • 每次遍历只判断数据是否就绪,不进行其他操作

Socket 队列尚有下列问题没有解决

  • Socket 很多时,Socket 队列就会很长,但实际上数据就绪的可能很少,这是极大的浪费
    Socket 队列中大部分 Socket 都是没有数据的,但不得不始终轮询
  • 没有多路复用时,只能通过 read 进行轮询,相当于 玩命切换用户态、内核态

总结:
优点

  • 实现单线程管理多 Socket
    不足
  • 队列中管理很多 Socket 连接,连接越多,队列越长,但数据就绪数量可能很低,浪费
  • 轮询依赖 read(),海量系统调用导致频繁的用户态内核态切换,浪费

在这里插入图片描述

NIO 引入多路复用
更多详细内容参考 中间件 | Redis - [深度理解多路复用]

多路复用程序将批量的轮询包装为多个文件描述符(File Descriptor),并统一传输给内核
轮询通过内核态完成,避免用户态内核态海量切换

在这里插入图片描述

posted @ 2025-05-20 14:54  问仙长何方蓬莱  阅读(18)  评论(0)    收藏  举报