- 1. I/O模型
- 1.1 用户进程与系统进程
- 1.2 一次I/O操作经历了什么?
- 1.3 I/O模型是什么?
- 2. Java对I/O模型的封装
- 2.1 BIO
- 2.2 NIO
- 2.3 NIO的Reactor模式
- 2.3.1 单Reactor单线程模型
- 2.3.2 单Reactor多线程模型
- 2.3.3 主从Reactor多线程模型
- 2.4 AIO
在了解I/O操作的过程之前,先介绍一下什么是用户进程和系统进程:
- 用户进程:通过执行用户进程或内核之外的程序而产生的进程,该类进程可以在用户的控制下运行或者关闭。
- 系统进程:可以执行内存资源分配和进程切换等管理操作,系统进程一般无法使用用户权限进行控制。
用户进程所在的区域叫做用户空间,系统进程所在的区域叫做内核空间。
用户进程不可以直接访问系统数据或者资源,他没有办法从内核态空间直接获取资源,需要将数据从内核态空间复制到用户空间,再获取用户空间中的数据。处于用户态的程序只能访问用户空间,只有处于内核态的程序才可以访问用户空间和内核空间。
如果用户进程需要访问系统资源或者使用系统数据,则必须调用操作系统提供的接口。这个调用接口的过程就是用户态进程主动要求从用户态切换到内核态的一种方式,也就是平常所说的系统调用。
以一次数据的读取为例,无论磁盘I/O还是网络I/O,第一步都是用户进程等待内核准备数据,内核准备数据的阶段会将数据复制到内核空间,用户进程也从用户态转变为内核态。磁盘I/O和网络I/O唯一不同的是磁盘I/O中内核从磁读取磁盘数据,而在网络I/O中内核从网卡中读取流数据。数据准备完毕收,第二步就是从内核空间将数据复制到目标用户空间供用户进程读取。
了解了一次I/O操作的过程后,那么平常说的I/O模型又是什么呢?
I/O模型可以理解为机器上进行数据读/写调度的策略模型。
I/O模型可以从两个维度去理解:
-
1)阻塞与非阻塞。用户进程发起I/O操作后,根据是否需要等待I/O操作完成才能继续运行,可以分为阻塞与非阻塞两种。
-
2)同步与异步。在用户进程发起I/O操作后,会由用户态转变为内核态,内核进程在完成复制数据的操作后执行I/O操作,操作执行完成后,需要从内核态转变为用户态,并且需要执行复制数据的操作。
- 如果是同步I/O操作,则在将数据复制到用户空间的过程中,用户线程会阻塞。
- 如果是异步I/O操作,则内核线程直接执行复制数据到用户空间,完成复制数据的操作后才通知用户进程I/O操作完成,不会造成用户线程直接执行复制数据的时候阻塞。也就是内核进程负责将数据复制到用户进程,用户进程可以直接执行后续操作。
同步和异步针对的对象是内核进程。
Linux有5种I/O模型,分别是:
- BIO(Blocking I/O):用户进程发起I/O操作后,内核进入数据准备阶段。数据还未全部准备好时,内核一直处于数据准备节点,而用户进程也被阻塞。
- NIO(Non-blocking I/O):用户进程发起I/O操作后,内核进入数据准备阶段。数据还未全部准备好时,内核一直处于数据准备阶段,而用户进程暂时先去做其他事情,但是用户进程会每隔一段时间询问数据是否准备好了。一旦准备好了,用户进程将会继续后续的操作。
- I/O Multiplexing:该模型主要是单个进程就可以同时处理多个网络连接的I/O模型,本质上用户进程还是阻塞的,但是select不会被阻塞,select/poll/epoll的方法不断地轮询所负责的而所有Socket,当某个Socket有数据到达时,就通知对应的用户进程。
- Singal Driven I/O:当用户进程执行I/O操作(读或写)时,内核马上返回,进程继续运行,在内核进程完成I/O操作(或出错)后,通过信号通知进程。
- AIO(Asychronous I/O):内核线程直接执行复制数据的过程,完成数据复制后才通知用户进程I/O操作完成,不会造成用户线程在复制数据的时候阻塞。
前四种是同步的,最后一种是异步的。
2. Java对I/O模型的封装
Java对I/O模型的封装可以分为BIO、NIO和AIO三种。
2.1 BIO一个客户端的连接请求被接收器接收后,在服务端会创建一个线程与该客户端进行通信,这个线程一般会执行读取、解码、计算、编码、发送这几个步骤。
这种模式非常耗费系统资源,首先在服务器端需要一个接收器一直处于阻塞状态等待新的客户端请求,其次每当有新的客户端请求连接时,都需要创建新的线程来处理请求,每个线程在处理完请求后都需要销毁。线程的创建和销毁会占用系统资源。
于是在该架构上又增加了线程池的设计,如下图:
当服务端收到客户端的连接请求后,作为一个任务丢给线程池,让线程池来分配线程执行与客户端通信的逻辑。
虽然加入线程池的设计可以提升系统性能,但是当请求量增大、线程数过高时,线程池频繁切换线程带来的成本问题也会暴露出来;并且在服务端需要一个线程一直阻塞等待客户端的连接请求,在客户端的线程也会因为服务端还没有把请求结果返回而一直阻塞等待。
Java NIO和Linux中的非阻塞I/O有一些区别,他更像是Linux中的非阻塞多路复用I/O,它可以帮助Java并发构建同步非阻塞的多路复用I/O应用程序,同时提供更接近底层高性能的数据操作方式,并且它还支持select/poll模型,在JDK1.5之后又增加了对epoll的支持。
介绍一下三个重要的概念:
-
Channel(通道):Java提供了四种Channel,分别是FileChannel(用于文件操作)、SocketChannel(用于客户端TCP操作)、ServerSocketChannel(用于服务端TCP操作)、DatagramChannel(用于UDP操作)。
- Channel是一个双向的数据读、写通道。 Channel可以实现读和写同时操作,并且同时支持阻塞和非阻塞模式。
- 一个客户端连接对应一个Channel,当有新的连接请求时,会向Selector中注册一个Channel,并且会将Channel与对应的事件处理器进行绑定。
- 事件处理器包含该通道里面的各类事件对应的处理逻辑,事件处理器一共有4中,分别是读数据事件处理器、写数据事件处理器、连接事件处理器、接受事件处理器。
-
Buffer(缓冲区):可以将Buffer看作一个存储数据的容器,Channel可以对Buffer进行读/写操作,并且所有数据的读/写都会经过缓冲区。在NIO中,有两种不同的缓冲区:
- 直接缓冲区:可以直接操作JVM的堆外内存,即在系统内核缓存中分配的缓冲区,通过allocateDirect()方法可以分配直接缓冲区。
- 非直接缓冲区:只能操作JVM的堆中内存,通过allocate()方法可以分配非直接缓冲区。
Buffer有许多实现,比如ByteBuffer、CharBUffer、LongBuffer等,分别对应一种数据类型的实现。
-
Selector(选择器):Selector通过不断轮询注册在其上的Channel来选择分发已处理就绪的事件,这里的事件有四种,分别是连接事件、接收连接事件、读事件和写事件。
- Selector可以同时轮询和监控多个Channel,当Selector发现某个Channel的数据状态发生变化时,会通过SelectorKey出发相关事件,并由对此事件感兴趣的事件处理器来执行相关逻辑,数据则从Buffer中获取,执行完成后再写回Buffer,以供Channel读取。
了解了以上概念之后,整个NIO的处理过程也就比较简单明了了。从上面的处理结构图可以看到,当一个客户端连接到来时,服务端会为这个新的连接创建一个Channel,并将这个Channel注册到Selector上,Selector会监控所有注册在自己身上的Channel,一旦某个Channel的数据状态发生改变,比如数据读取完毕,则会触发相关的事件。Channel的数据读取和写入都只与Buffer进行交互。
2.3 NIO的Reactor模式
Java NIO最核心的就是Selector,每当连接事件、接收连接事件、读事件和写事件中的一种事件就绪时,相关的事件处理器就会执行相应的逻辑,这种基于事件驱动的模式叫做Reactor模式。
Reactor模式的核心思想就是减少线程的等待。当遇到需要等待的I/O操作时,先释放资源,而在I/O操作完成时,在通过事件驱动的方式,继续接下来的处理,这样从整体上减少了资源的消耗。
以下是Reactor模式中的五种重要角色:
- Handle(句柄或描述符,在Windows下称为句柄,在Linux下成为描述符):它是资源在操作系统层面上的一种抽象,表示一种由操作系统提供的资源。该资源与事件绑定在一起,也可用于表示一个个事件。
- Synchronous Event Demultiplexer(同步事件分离器):Handle代表的事件会被注册到同步事件分离器上,当事件就绪时,同步事件分离器就会分发和处理这些事件。它的本质是一个系统调用,用于等待事件的发生。调用方法在调用它的时候会被阻塞,一直阻塞到同步事件分离器上有事件就绪为止。在Linux中同步事件分离器指的就是常用的I/O多路复用机制,比如select、poll、epoll等系统调用,用来等待一个或多个事件的发生。在Java NIO领域中,同步事件分离器对应的组件就是上述讲到的Selector,对应的阻塞方法就是select方法。
- Event Handler(事件处理器):它由多个回调方法构成,这些回调方法就是对某个事件的逻辑反馈,事件处理器一般都是抽象接口。我们可以实现这些回调方法来达到某一个事件进行特定反馈的目的。在Java NIO中没有提供事件处理器的抽象供我们使用,但是Netty支持。
- Concrete Event Handler(具体的事件处理器):它是事件处理器的实现。他本身实现了事件处理器所提供的各种回调方法,从而实现了特定的业务逻辑。
- Initation Dispatacher(初始分发器):可以把它看作Reactor,它规定了事件的调度策略,并且用于管理事件处理器,提供事件处理器的注册、删除等方法,事件处理器需要注册到Initiation Dispatcher上才能生效。它是整个事件处理器的核心,Initiation Dispatcher会通过Synchronous Event Demultiplexer来等待事件的发生。一旦事件发生,Initiation Dispatcher首先会分理处每一个事件,然后找到相应的事件处理器,最后调用相关的回调方法处理这些事件。
Reactor模式有三种模型,分别是单Reactor单线程模型、单Reactor多线程模型、主从Reactor多线程模型。
2.3.1 单Reactor单线程模型只有一个Reactor,并且无论是与I/O相关的读/写,还是与I/O无关的编/解码、计算,都在一个线程上完成。
从图中可以看出,客户端的请求可以分为连接请求和其他请求两种,Selector上注册了一系列的Channel,Selector不断地监听这些Channel,等到某个Channel上面的事件就绪,就会将该事件分发给事件处理器。
由于解码、计算及编码的处理会影响后续事件的分发和处理,所以再高并发场景下,处理读/写任务的线程负载过高,处理速度下降,请求就会被堆积,甚至超时。
所以对于解码、计算及编码需要另起线程处理并且采用线程池来管理,这就是下面要说的单Reactor多线程模型。
2.3.2 单Reactor多线程模型这中模型中的多线程指的是处理I/O操作之外的逻辑通过多线程来执行,而I/O的读/写和Reactor的处理还是由一个线程执行。
相对于第一种模型来说,将业务逻辑交给线程池来处理,可以充分利用多核CPU的处理能力,但是Reactor用了一个线程处理了所有事件的监听和响应,在高并发应用场景下,容易出现性能瓶颈,所以出现了主从Reactor多线程模型。
2.3.3 主从Reactor多线程模型在单Reactor模型中,当Reactor角色在处理读/写事件时,新客户端的连接事件就会被阻塞,所以它会影响新客户端建立连接的请求,导致连接超时等情况。在主从Reactor多线程模型中专门处理了这个问题。
从上图可以看到,处理连接事件的线程与处理读/写事件的线程隔离。
在此模型中存在多个Reactor。Main Reactor一般只有一个,它负责监听和处理连续连接的请求;而Sub Reactor可以有多个,用线程池进行管理,Sub Reactot主要负责监听和处理读/写事件等。
当然也可以将Main Reactor改为多个,通过线程池管理,但并不是Main Reactor不是越多越好,其数量主要取决于客户端连接是否频繁。
2.4 AIOAIO是NIO的升级版本,是在JDK1.7之后引入的Java I/O新类库,提供了异步非阻塞的I/O操作方式。异步I/O是基于事件和回调机制实现的,也就是应用程序发起请求之后会直接返回,不会阻塞,当后台处理完成时,操作系统会通知相应的线程执行后续的操作。
在Java中,AIO有两种使用方式:一种是简单的将来式,另一种是回调式。
- 将来式:Java用Future类实现将来式,将执行任务交给线程池执行后,执行任务的线程并不会阻塞它会返回一个Future对象,在执行结束后,Future对象的状态会变成完成。在Future对象中可以获得对应的返回值,但是需要调用get()方法来获取结果,如果结果还没返回,则调用get()方法的线程就会被阻塞,这无疑跟同步调用相差无几,如果需要及时得到结果,那么这种方式甚至可能比同步调用的效率更低。
- 回调式:Java提供了CompletionHandler作为回调接口,在调用read()、write()等方法时,可以传入CompletionHander的实现作为事件完成的回调接口,这种方法需要用户自行编写回调后的业务逻辑。
因为Future的get()方法会阻塞线程,并且Future接口无法实现自动回调,所以在Java8中提供了CompletableFuture,它及支持原来的Future的功能,也支持回调式。除此之外,CompletableFuture还支持不同CompletableFuture间的相互协调或组合,方便异步I/O的开发。
Java中的AIO采用的时Proactor模式,Proactor模式是I/O多路复用技术的另一种常见模式,它主要用于异步I/O处理。Proactor模式有如下六个角色:
- Handle:他和Reactor模式中的Handler是一个意思,都代表句柄或描述符。
- Proactor:Proactor:负责监听完成事件,当有事件完成时,选择对应的完成事件处理器进行处理。
- Asynchronous Operation Processor(异步操作处理器):负责执行相关事件的I/O操作。
- Completion Event Queue(完成事件队列):异步操作处理器执行完的I/O操作结果会被放入该队列,Proactor会从该队列中获取相应的结果。
- Completion Event Handler(完成事件处理器):与Reactor模式的Event Handler的区别在于Completion Event Handler针对的是完成的事件,而Event Handler针对是的就绪事件。
- Concrete Completion Event Handler:它是完成事件处理器的具体实现。
Proactor模式与Reactor模式很相似,也会进行事件分发,不同于Reactor模式的是,它注册的并不是就绪事件,而是完成事件。这是因为Reactor模式是应用程序自身处理I/O操作;而Proactor模式则是由内核进程处理I/O操作,当执行事件处理器时,Reactor模式下的I/O操作还没有完成,只是就绪。而在Proactor模式下I/O操作已经完成。
例如:一次读事件发生,如果时Reactor模式,那么在事件分离器收到读事件就绪的信息后,需要主动调用read()方法,直到I/O操作完成,再处理后续事件处理器的逻辑。如果时Proactor模式,那么只要读事件一到,在事件分离器上注册后,内核就会执行相关的I/O操作,完全不需要应用程序关系,直到监听到I/O操作完成,才会选择对应的事件处理器执行相应的逻辑。
参考资料:《深入理解RPC框架原理与实现》 华钟明著.



