IO模型

同步阻塞IO

阻塞 指的是进程或线程的状态。

同步IO:用户空间的进程或线程主动发起IO请求,内核被动接收。

异步IO:内核主动发起IO请求,用户空间的进程或线程被动接收。

Blocking IO:用户空间的进程或线程主动发起,需要等待内核IO彻底操作完成返回后,才能返回用户空间继续执行后续指令的IO操作。在IO操作过程中,发起IO请求的用户进程或线程处于阻塞状态。

流程图

Java中读 socket 数据的过程

  1. Java线程 发起 recvfrom 系统调用,线程处于 blocked 状态,用户态切换为内核态
  2. 内核收到调用后,开始准备数据
    1. 内核将数据从网络协议栈拷贝到 socket buffer
    2. 内核将数据从socket buffer 拷贝到 heap
  3. recvfrom 返回,内核态切换为用户态,Java 线程处于 runnable 状态

优点

  • 开发非常简单
  • 发起IO请求的线程被阻塞直到IO完成,在阻塞期间不占用CPU资源

缺点

  • 一般为每个 socket 连接配一个线程
  • 一个线程维护一个连接,在并发量小的情况下,这样做还可以接受
  • 在高并发应用场景下,阻塞IO模型需要大量线程维护大量网络连接,内存、线程切换带来的性能低下是 no tolerance

同步非阻塞IO

非阻塞:用户进程或线程获得内核返回的状态值后回到用户空间,继续执行后续指令。

Non-Blocking IO:用户空间的进程或线程主动发起IO请求,不需要等待内核IO操作彻底完成就立即返回用户空间继续执行后续指令的IO操作。在IO操作过程中,发起IO请求的进程或线程处于非阻塞状态。

流程图

Linux 中 socket 默认是阻塞的,可以将 socket 设置为非阻塞模式。

在 NIO 模型中,应用程序一旦开始 IO 系统调用,就会出现以下两种情况:

  1. 在内核缓冲区中没有数据的情况下,函数立即返回一个调用失败的信息
  2. 在内核缓冲区中有数据的情况下,内核将数据从内核缓冲区拷贝到用户缓冲区,返回成功,整个过程中应用线程是阻塞的

Java 中不支持,作为了解。

读非阻塞 socket 过程

  1. 用户线程 发起 recvfrom syscall,函数返回失败
  2. 内核准备数据(从网络协议栈拷贝数据到 socket buffer),用户线程一直进行 recvfrom syscall,未准备好数据之前,一直返回失败
  3. 内核将数据准备好后,用户线程进行 recvfrom syscall,阻塞用户线程,将数据从 socket buffer 拷贝到 app buffer
  4. 拷贝完成后,recvfrom 返回拷贝的字节数,用户线程状态从阻塞到运行

优点:每次发起IO系统调用在内核准备数据过程中可以立即返回,用户线程不会阻塞,实时性较好

缺点

  • 不断轮询内核,占用大量CPU时间,效率低下
  • 高并发场景中,和同步阻塞一样基本不可用

虽然Java开发中不涉及此模型,但此模型仍然有其价值。

它的作用在于其它模型中可以使用非阻塞IO模型作为基础以提高性能。

IO多路复用

为了提高性能,OS 引入了一种新的系统调用,专门用于查询IO文件描述符的就绪状态。在Linux中,为 epoll。

通过该系统调用,一个用户进程或线程可以同时监视多个文件描述符,一旦某个描述符就绪(内核缓冲区可读/可写),内核将文件描述符的就绪状态返回给用户进程或线程。

用户进程或线程根据文件描述符的就绪状态进行响应的IO系统调用。

IO Multiplexing 属于一种 Reactor 模式的实现,也称为异步阻塞IO。

流程图

如何避免同步非阻塞IO模型中轮询等待?

采用IO多路复用模型。

目前支持IO多路复用的系统调用有 select、pselect、poll、epoll ,其中和 epoll 相关的由 epoll_create()、epoll_ctl()、epoll_wait()。

几乎所有OS都支持 select 调用,它有良好的跨平台特性。

epoll 是在Linux 2.6内核中提出,是 select 调用的 Linux 增强版本(之前是 poll)。

使用多路复用读一个 socket 过程

  1. 选择器注册。
    1. 将要读取的 socket 文件描述符注册到 epoll 选择器上
    2. 开启IO多路复用轮询流程
  2. 就绪状态的轮询。
    1. 调用选择器的查询方法,查询所有注册过的文件描述符的IO就绪状态,进入阻塞状态
    2. 当任何一个注册过的 socket 就绪,说明内核缓冲区中数据准备好了;内核将就绪的 socket 加入就绪的文件描述符列表,并且返回就绪事件,用户线程进入运行状态
  3. 用户线程获得就绪状态的 socket 列表,对其中的 socket 发起 recvfrom syscall,用户线程进入阻塞状态
  4. 内核拷贝 socket buffer 到 heap,完成后 read 函数返回,用户线程进入运行状态,继续执行

注册在选择器上的每一个可以查询的 socket 都必须设置为同步非阻塞模型。

通过 JDK+JVM 的源码可以看出NIO组件在Linux上是使用 epoll 实现的,所以Java NIO组件使用的是 IO多路复用模型。

【原创】万字长文浅析:Epoll与Java Nio的那些事儿 - 知乎 (zhihu.com)

select、poll、epoll 的区别

select poll epoll
底层实现 数组 链表 哈希表
操作方式 遍历 遍历 回调
效率 线性遍历 O(n) 线性遍历 O(n) fd就绪,将fd放到readyList 的回调函数触发 O(1)
连接数 1024(x86) 2048(x64) +∞ +∞
fd拷贝 需要把fd集合从用户态拷贝到内核态 需要把fd集合从用户态拷贝到内核态 调用epoll_ctl时拷贝到内核并保存,之后每次epoll_wait不拷贝

epoll 除了提供 select/poll 同样的 水平触发(Level Triggered)外,还提供了边缘触发(Edge Triggered)。

  • 水平触发(LT)

    默认工作模式,即当epoll_wait检测到某描述符事件就绪并通知应用程序时,应用程序可以不立即处理该事件;下次调用epoll_wait时,会再次通知此事件

  • 边缘触发(ET)

    当epoll_wait检测到某描述符事件就绪并通知应用程序时,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次通知此事件。(直到你做了某些操作导致该描述符变成未就绪状态了,也就是说边缘触发只在状态由未就绪变为就绪时只通知一次)。

epoll 对比 select 所做的改进:

Linux 网络编程很长时间都使用 select 做轮询和事件通知,但它的固有缺陷导致使用它的应用受到限制。

Linux 内核不得不寻找替代方案,最终选择了 epoll。

  1. 支持一个进程打开的 socket fd 仅受限于 Linux 的最大文件句柄数
  2. IO效率不会 随着 fd 的数量增加而线性下降
  3. 使用 mmap() 加速内核与用户空间的消息传递(同一块内存,只有少量拷贝)
  4. API 更简单(不止有 epoll 还有别的类似技术,但是使用难度大)

优点

  • 一个选择器查询线程可以同时处理成千上万的网络连接,而不必创建大量线程,减少了开销

缺点

  • epoll本质上是阻塞的
  • 需要在事件就绪后,由用户程序调用具体的读写函数来完成,这个过程也是阻塞的

信号驱动IO

为了减少阻塞,提高性能,产生了信号驱动IO模型。

在信号驱动IO模型中,用户线程通过对 IO事件 注册回调函数,来避免 IO查询过程中的 阻塞。

流程图

使用信号驱动读一个 socket 过程

  1. 用户线程对 一个socket 发起 sigaction syscall 在内核注册一个回调函数
    1. 注册 SIGIO 信号处理程序
    2. 使用 fcntl 的 F_SETOWN 设置套接字所有者
    3. 使用 fcntl 的 F_SETTFL 设置 O_ASYNNC标志,允许 socket 信号驱动输入输出
  2. 内核准备数据
  3. 内核使用信号(SIGIO)通知(运行回调函数)线程处理
  4. 用户线程发起 recvfrom syscall
  5. 内核拷贝数据
  6. recvfrom 返回拷贝的字节数

什么时候产生信号?

对于UDP而言:

  1. 套接字收到一个完整的数据包
  2. 套接字发生异步错误

恰好 recvfrom() 可以读取数据包数据或异步IO错误信息

信号发给谁处理?

套接字的所有者(进程或线程组标识)

收到信号如何处理?

注册的信号处理函数,有 signal() 和 sigaction(),但不推荐 signal() 函数

优点

  • 避免了IO 查询过程中的阻塞

缺点

  • 只能用于 UDP(对于 TCP 有很多情况都可以产生 SIGIO,而应用无法区分是哪种情况导致该信号产生)
  • 在大量IO操作时可能会因为信号队列溢出导致没法通知,so terrible!

异步IO

Asynchronous IO:用户空间的线程收到内核通知,数据已经被内核读取完毕并放在了用户缓冲区。

异步IO类似回调函数,用户进程或线程向内核注册IO事件的回调函数,由内核主动调用。

AIO也叫异步非阻塞IO。(perfect!!)

流程图

使用纯异步读一个 socket 过程

  1. 用户线程发起 sio_read syscall 后,函数立即返回,用户线程继续执行
  2. 内核准备数据,并拷贝到用户缓冲区
  3. 内核通过信号通知用户线程
  4. 用户线程的回调方法被触发,完成后续操作

优点

  • 纯异步非阻塞,性能最优

缺点

  • 需要内核提供支持。就目前而言,Windows 通过 IOCP 实现了真正的异步,而最新版 Linux 不支持(what a pity!)

在 Linux 中,异步 IO 模型在 2.6 内核版本才引入,JVM 对它的支持并不完善,因此异步IO在性能上没有明显的优势。

但是,异步编程模式值得学习!(为啥?)

想要继续学习?——《UNIX 网络编程》