把学习当成一种习惯
选择往往大于努力,越努力越幸运

前言

  • 本文简单介绍了五种I/O模型 :
    • 阻塞I/O(BIO)、非阻塞I/O(NIO)、IO多路复用(NIO)、信号驱动I/O(NIO) 和 异步I/O(AIO);
    • 前四种为 同步I/O , 第五种为 异步I/O.
  • 如果能吸收本文的知识点,那么将有助于我们更好的去深入学习 如 Java较为成熟稳定的NIO框架Netty、Tomcat的网络I/O模型、Redis的网络I/O模型等知识点.
  • 本文结合了《UNIX网络变成卷1:套接字联网API》这本书对应的章节,还有网上一些大佬的博客,做一个学习笔记,记录一下自己的想法.

阻塞/非阻塞、同步/异步的概念

  在了解这几个概念之前,我们有必要知道操作系统管理的用户态、内核态、网卡之间对于套接口的处理关系,例如一般的输入操作(接收数据)需要有两个阶段 :

  • 1.套接口的分组数据到达时(网卡),它会被拷贝到内核中的某个缓冲区;
    • 这个阶段可以定义为 等待数据准备好,如下图的网卡拷贝到内核空间;
  • 2.上面第一阶段的数据已经准备好,那么此时将内核缓冲区拷贝应用缓冲区,如下图的内核空间read()到用户空间;

阻塞/非阻塞 : 描述是用户线程调用内核IO操作的方式.

  • 阻塞是指IO操作需要彻底完成后才返回到用户空间;
    • 如上图第一阶段的数据如果没有准备好,那么进入阻塞状态,直到复制到了用户空间,才能返回,否则整个线程一直在等待;
  • 非阻塞是指IO操作被调用后立即返回给用户一个状态值,无需等到IO操作彻底完成.
    • 如上图第一阶段的数据如果没有准备好,那么不会进入阻塞状态,但是会直接返回一个状态值来表示当前数据未准备好,所以需要不断的轮询访问.

同步/异步 : 描述的是用户线程与内核的交互方式.

  • 同步指用户线程发起IO请求后需要等待或者轮询内核IO操作完成后才能继续执行;
    • 如上图的第二阶段执行完之后,用户线程才能继续执行其他的.
  • 异步是指用户线程发起IO请求后仍然可以继续执行其他的事情,当内核IO操作完成后会通知用户线程或者调用用户线程注册的回调函数;
    • 如上图的第一阶段、第二阶,用户线程对这两阶段是无需过问的,可以去执行其他的,如果第二阶段执行完成,那么会通知用户线程或者调用用户线程注册的回调函数.

五种IO模型

同步I/O :

  • BIO : 阻塞式IO,在如今的高并发场景下性能最差;如Tomcat在该模式下每个请求都会创建一个线程(线程池),对性能开销大,不适合高并发场景.优点是稳定,适合连接数目小且固定架构.
  • NIO : 非阻塞式IO,比传统BIO能更好的支持并发性能.Tomcat 8.0之后默认采用该模式;
    • 提供了三种实现方式 : 其中IO多路复用是最核心的一种 :
    • 轮询的方式 : 这种轮询实现非阻塞IO一般是在只专门提供某种功能的系统中才使用;
    • IO多路复用 : 实现方式有select、poll、epoll,本文暂不详细介绍.
    • 信号驱动I/O : 用户线程不再等待内核态的数据准备好,直接可以去做别的事情,直到第一阶段数据准备好了内核立马发给用户线程一个信号,用户线程就会执行把内核数据拷贝到应用缓冲区,直到拷贝完成,用户线程才处理别的事情.信号驱动式I/O模型有种异步操作的赶脚,但是在将数据从内核复制到用户空间这段时间内用户线程是阻塞的,所以是非阻塞I/O.

异步I/O :

  • AIO : 异步非阻塞式IO,与NIO不同在于不需要多路复用选择器,而是请求处理线程执行完程进行回调调知,已继续执行后续操作.Tomcat 8之后支持、Ajax默认也是异步的、JDK1.7之后支持AIO.

阻塞I/O --- BIO

  阻塞IO必须是第一阶段的数据到达内核,并且复制到了用户空间,才能返回,否则整个用户线程一直在等待.所以阻塞IO的问题就是,线程在读写IO的时候不能干其它的事情,即全程处于阻塞状态, 如下图 :

  • 全程处于阻塞状态,直到数据拷贝完成才返回.
  • 可能对于一些IO密集型的应用,如果采用 BIO + 多线程(线程池) , 那么就会导致有一些用户线程是阻塞于等待数据(第一阶段),占用了线程(系统资源),并发较高的情况下,性能就有了很大的影响了;如果能做到占用的线程最起码是处于第二阶段那就更好了.
  • 多线程并发模式,即一个连接一个线程
    • 优点 : 一定程度上极大地提高了服务器的吞吐量,因为之前的请求在read阻塞以后,不会影响到后续的请求,因为他们在不同的线程中.
    • 缺点 : 缺点在于资源要求太高,系统中创建线程是需要比较高的系统资源的,如果连接数太高,系统无法承受,而且,线程的反复创建-销毁也需要代价(当然可以用线程池)

非阻塞I/O --- NIO

轮询

  非阻塞IO调用read()后会返回一个状态值,内核通过这个状态值来反映数据是否已经准备好;因为不知道什么时候准备好,要保证实时性,就得不断的轮询, 如下图 :

  • 前期一直轮询访问数据的准备状态,与上面的阻塞IO相比,阻塞IO比较佛系.
  • 可能对于一些IO密集型的应用,如果采用 NIO(轮询) + 多线程(线程池) , 那么就会导致每个线程访问网络后都不停的轮询,那么这个线程就被占用了,那跟阻塞IO也没什么区别了,同样还是占用了线程(系统资源),并发较高的情况下,性能也是有很大的影响;如果能做到占用的线程最起码是处于第二阶段那就更好了.
  • 这种轮询的模式只是偶尔才遇到,一般是在只专门提供某种功能的系统才使用.

IO多路复用

  • IO多路复用的实现可以是select 或者 poll 或者 epoll,这三种也是系统调用;这三种系统调用上的阻塞,并不是真正阻塞于I/O系统调用.
  • 对于阻塞于select调用的套接口,是处于 等待数据套接口的可读状态(即第一阶段数据未准备好);当存在数据套接口为可读状态时(即第一阶段数据已准备好),那么就可以执行内核数据拷贝到用户空间;
  • 如果能有一个或者几个线程去轮询所有阻塞于select调用的套接口,只要是数据准备好,就找一个线程处理,这就是IO多路复用. 如下图 :
  • 多了一次系统调用(select),如果第一阶段的数据没准备好,那么会阻塞于select;通过一个或几个线程去管理很多个(可以成千上万)socket连接,这样连接数就不再受限于系统能启动的线程数.
  • 只要socket连接处于可读状态,那么就启动线程处理(可以是线程池),当然轮询的线程也可以不用找其他线程处理,自己处理就行,例如Redis就是这样的.
  • 可能对于一些IO密集型的应用,如果采用 NIO(多路复用) + 多线程(线程池) , 现在就做到占用的线程最起码是处于第二阶段了.
  • 上图使用了Reactor模式 : 采用基于事件驱动的设计,当有事件触发时,才会调用处理器进行数据处理.使用Reactor模式,对线程的数量进行控制,一个线程处理大量的事件.

信号驱动IO

  当数据没有准备好的时候(处于第一阶段),用户线程会向内核发送一个信号,并立即返回,用户线程可以继续干其他事情;当数据已准备好的时候,就会通知用户线程,用户线程就会调用read()来从内核拷贝数据到应用缓冲区, 如下图 :

  • 信号驱动IO有点异步的感觉了,但他还是同步非阻塞I/O的,原因就是当数据已准备好的时候,就会通知用户线程,用户线程就会调用read()来从内核拷贝数据到应用缓冲区,而从内核拷贝数据到应用缓冲区是阻塞的.

异步I/O --- AIO

  • 真正的异步IO需要操作系统更强的支持.
  • IO多路复用模型中,数据到达内核后通知用户线程,用户线程负责从内核空间拷贝数据;
  • 而在异步IO模型中,当用户线程收到通知时,数据已经被操作系统从内核拷贝到用户指定的缓冲区内,用户线程直接使用即可, 如下图 :
  • 异步IO模型中,用户线程只告诉内核态需要什么数据,然后就可以去干其他事情了,等内核把数据拷贝到应用缓冲区,并且通知用户线程,用户线程执行.
  • 常见的Web系统里很少使用异步I/O.

结束语

  • 本文只是简单的记录了五种I/O模型,并没有深入理解,后期会慢慢对Netty、Tomcat的多路复用、Redis的多路复用等加强学习.
  • 结合本文的多路复用I/O模型,现在大概可以知道Redis如何实现的单线程处理模型(6.0版本之前)
    • 由于Redis是单线程来处理命令的,所有每一条到达服务端的命令不会立刻执行,所有的命令都会进入一个队列中,然后逐个被执行.并且多个客户端发送的命令的执行顺序是不确定的.但是可以确定的是不会有两条命令被同时执行,不会产生并发问题,这就是Redis的单线程基本模型.
    • 单线程指的是网络请求模块使用了一个线程(所以不需考虑并发安全性),即一个线程处理所有网络请求,其他模块仍用了多个线程.
  • Tomcat8.0后默认使用NIO,并且配合线程池来实现异步操作(并不是真正的异步).

相关参考资料


目录