深入理解I/O(BIO,NIO)

什么是I/O

i/o 是计算机交换信息的主要途径,流是i/o操作的主要方式。

流是一种信息的转换,是有序的,是一种数据的载体,通过流可以实现数据的交换和传输。一般情况下我们把机器或者应用程序接受外界的信息称之为输入流。反之,机器或者应用程序向外界输出信息为输出流。合称为输入/输出流(I/O Streams)

不管是文件读写还是网络发送,信息的最小载体都是字节,那为啥又分为字节流和字符流呢?这是因为字符和字节之间的转换需要转码,转码过程中,如果我们不知道编码类型就容易出错,所以I/O 提供了直接对字符的操作,屏蔽了底层编码的转换。

I/O操作分为磁盘I/O和网络I/O操作。

磁盘I/O是从磁盘读取数据源到内存中,之后将读取的信息持久化到磁盘中,网络I/O操作是从网络中读取信息到内存中,之后将信息又输出到网络中,在传统I/O中都存在性能问题。

1、多次的内存复制

通常情况下,我们可以通过inputStream 从源数据中读取数据流到缓冲区中,通过outputStream将数据输出到网络或者磁盘中,以下是输入操作在操作系统中的具体流程:

深入理解I/O(BIO,NIO)

在这个过程中,数据由磁盘或者网络拷贝至内核空间,再从内核空间复制到用户空间,经过了2次数据的拷贝,而且也增加了上下文的切换,使得性能很低。

2、阻塞

在传统I/O操作中,inpustream的read操作是个循环,会一直等待数据读取,直到数据准备就绪后才返回,如果数据没有就绪就会一直等下去,阻塞当前用户线程,如果tps过高,多个线程会抢夺cpu资源(io阻塞线程不会占用cpu资源),会引起cpu上下文的切换,增加了系统开销。

如何优化I/O操作?

 

1、使用缓冲区优化读写操作。

传统I/O中,inputStream 和outputStream 是基于流的实现,他们以字节为单位处理数据,而NIO是基于块block的,他以块为单位处理数据,其中有2个概念比较重要,buffer和channel。buffer是一块连续的内存块,是NIO 读取数据的中转地,虽然传统的BIO中BufferedInputStream 也使用了缓冲,但是性能还是没法和NIO比。所以可以使用NIO代替传统BIO。

2、使用DirectBuffer减少内存复制。

普通的buffer分配的是jvm堆内存,而DirectBuffer是直接分配物理内存(非堆内存,通过 unsafe.allocateMemory(size)分配内存),按照前面介绍的,数据要输出到外部设备,必须先经过用户空间到内核空间的拷贝,内核空间再到网卡或者磁盘的拷贝,而在java中,在用户空间又存了一个拷贝,那就是从堆内存中拷贝到临时直接内存中,然后在通过临时直接内存拷贝到内核空间,此时临时直接内存和堆内存都属于用户空间。java为什么设计出一个临时的直接内存呢,这是因为如果使用堆内存,如果数据量比较大的话,gc压力就会很大,使用非堆内存可以减少gc压力。

DirectBuffer 是直接从内核空间拷贝数据至临时直接内存,而不经过堆内存。不过因为是非堆内存,所以创建和销毁的代价会很高,不由jvm负责垃圾回收。(会通过java reference机制来释放内存块)。DirectBuffer 只是优化了用户空间的拷贝,没有优化内核空间和用户空间的拷贝。NIO 中存在另一个MappedByteBuffer ,他是通过调用本地mmap 进行文件和内存地址的映射,map 会直接将文件从磁盘拷贝至用户空间,只进行一次数据拷贝,其实,mmap  是将用户空间和内核空间地址同时映射到相同的一块物理内存地址,不过是用户空间还是内核空间都是虚拟地址。最终都需要地址映射映射到物理内存地址。最终是减少了传统的read() 方法从硬盘拷贝到内核空间这一步。

通道channel

传统BIO的数据的读取和写入是通过内核空间和用户空间的来回拷贝,而内核空间的数据是通过操作系统层面的I/O接口从磁盘读取和写入的。最开始的时候,应用程序调用操作系统的I/O接口时,是由cpu完成分配的,如果发生大量I/O 会非常消耗cpu资源,之后,操作系统引入DMA(直接存储器存储),内核空间和磁盘之间的存储完全有DMA来负责管理,但是仍需要向cpu申请操作权限,而且需要借助DMA 总线来完成数据的拷贝操作,如果DMA总线很多,会造成总线冲突,最后引入channel的概念,channel有自己的处理器,可以完成内核空间和磁盘空间的I/O,channel是双向的,读写可以同时进行。

多路复用(selector)

selector 是nio的基础,主要是来用户检查一个或者多个channel 状态是否准备就绪(是否有数据可读可写),selector是基于事件驱动实现的,可以在selector上注册accept、读写监听事件,selector 会不断的轮训注册在其上面的channel,如果监听到channel准备就绪,有事件产生,然后就会进行I/O操作。socket 通信中的connect、accept、read、write 事件分别对应selector的selectKey的四个监听事件 OP_CONNECT、OP_ACCEPT、OP_READ、OP_WRITE。在nio服务器,首先会创建一个channel,用来监听客户端的连接,然后创建多路复用器selector,并将channel注册到selector上(OP_ACCEPT),程序会通过selector来轮询注册在其上的channel,如果发现channel事件准备就绪(OP_CONNECT->OP_READ),匹配相关的监听事件,进行I/O操作。

 

TCP流程如下:

深入理解I/O(BIO,NIO)

网络I/O模型分类概述

目前主要包括阻塞I/O,非阻塞I/O,I/O复用,信号渠道I/O,异步I/O。

1、阻塞式I/O

socket中涉及到的主要阻塞操作有connect阻塞、accept阻塞、read、write阻塞。

connect阻塞

客户端发起connect连接时,tcp连接的建立需要经过三次握手过程,客户端需要等待服务器的ACK和SYN信号,服务端也需要阻塞的等待客户端的ACK 信号。TCP的每个connect都会阻塞,直到确认连接。

深入理解I/O(BIO,NIO)

accept阻塞。

服务端通过调用accept() 函数来接受外来请求,如果一直没有新的链接过来,服务端将会被挂起,进入阻塞状态。

深入理解I/O(BIO,NIO)

read、write阻塞。

当tcp链接创建成功后,服务端调用fork函数创建一个子进程,调用read函数等待客户端写入,如果没有写入准备就绪,子进程将会被阻塞挂起,直到数据来临。

深入理解I/O(BIO,NIO)

2、非阻塞I/O。

connect阻塞和accept阻塞和read阻塞都可以设置为非阻塞操作,如果没有数据返回,就会直接返回-1,进程就不会阻塞。这时,我们需要设置一个线程对read是否准备就绪进行轮询检查,这是最传统的非阻塞I/O。不过会引起客户端的轮询,这显然不现实。

深入理解I/O(BIO,NIO)

 

3、I/O复用

linux提供了i/o 复用函数select、poll、epoll,进程将一个或多个读操作通过系统调用函数,阻塞在函数操作上。系统内核可以检测到多个读操作是否处于就绪状态。

select函数:在超时时间内,监听用户感兴趣的文件描述符事件(读写、异常)。linux 操作系统的内核将所有的外部设备当做一个文件来操作,对文件的操作会调用内核提供的命令,返回一个文件描述符fd。

 

深入理解I/O(BIO,NIO)

 

poll函数:对于select而言有些缺点。在每次调用select函数之前,系统都需要把一个fd从用户态拷贝到内核态,增加了性能开销。再者,select的单个进程监视的fd数量有限,最大为1024,虽然我们可以通过修改宏定义或者重新编译内核的方式修改最大的fd数量,但是fd_set 是基于数组实现的,在新增和修改fd时,性能不是很高。

poll函数与select函数最大的区别在于没有最大文件描述符fd数量的限制。poll函数、epoll 函数的调用流程类似select,这里就不再画图了。

epoll函数:select函数和poll函数都需要从用户态拷贝到内核态,epoll函数不需要。select和poll是顺序扫描fd是否准备就绪。epoll只是扫描已经准备就绪的fd。epoll函数是使用事件驱动的方式代替轮训扫描fd机制。epoll 事先通过epoll_ctl() 来注册一个文件描述符,将文件描述符存放到内核的一个事件表中,这个事件表是基于红黑树来实现的,所以在大量I/O下插入和删除性能比fd_set要好。一旦某个文件描述符准备就绪,内核会采用callback的回调机制**这个文件描述符,并通知epoll调用者。epoll也不会有fd数量的限制

4、信号渠道I/O

信号渠道I/O类似于观察者模式,内核就是个观察者,信号回调就是个异步通知,用户进程发起I/O操作时,通过系统调用sigaction函数,在对于的套接字注册一个信号回调,此时不阻塞用户进程,当内核数据准备就绪时,内核会为该进程生成SIGIO信号,通过信号回调通知进行I/O操作。

深入理解I/O(BIO,NIO)

信号驱动I/O相对于前三种模型来说,在数据准备就绪时,可以不阻塞,但是在数据从内核拷贝到用户空间的时候还是会阻塞。需要注意的一点是,对于TCP而言,信号驱动模型很少被使用,sigio信号是一种unix信号,信号没用附加信息,如果一个信号源有多种产生信号的原因,信号接受者无法确定原因,TCP sockect生产的信号有7种,当应用收到sigio信号时是无法区分的。

信号驱动模型现在广泛应用于udp上,udp只有一个数据请求。

5、异步I/O

当用户进程发起一个i/o操作时,系统会告诉内核启动某个操作,并让内核在整个操作完成后通知应用进程,这个操作包括把数据从内核拷贝至用户空间。(目前linux暂不支持,Windows已支持)

深入理解I/O(BIO,NIO)

线程模型

除了内核对网络I/O模型的优化,nio在用户层也做了优化,NIO是基于事件驱动模型来实现I/O操作,Reactor 模型是同步I/O事件处理的一种常见模型,核心思想是将I/O注册到多路复用器上,一旦I/O事件触发,多路复用器会将事件分发到对应的事件处理器中,该模型有以下三个主要组件:

  • 事件接收器Acceptor:主要负责接收请求连接。

  • 事件分离器Reactor:接收请求后,会将建立的连接注册到分离器中,以来循环监听多路复用器selector,一旦监听到事件,就会将事件分发到事件处理中。

  • 事件处理器Handlers:事件处理器完成相关事件的处理,比如读写I/O操作。

1、单线程Reactor 线程模型

最开始的时候nio是用单线程实现的,所有的i/o操作都是在一个nio线程上完成。由于nio是非阻塞I/O,理论上一个线程可以完成所有的I/O操作。

但NIO其实还不算真正实现了非阻塞IO操作,因为读写I/O操作时用户进程还是属于阻塞状态,如果在高并发下,一个nio线程处理几万个I/O操作,系统也会出现性能问题,轮询起来也会很慢,内核到用户空间的拷贝也是单个串行。

深入理解I/O(BIO,NIO)

2、多线程reactor模型

为了解决单线程的reactor模型的性能瓶颈,后来采用了线程池的设计思想。

在tomcat和netty中都使用了一个Acceptor线程来监听连接请求,当连接成功后,会将建立的连接注册到多路复用器selector中,一旦监听到事件后,交个worker线程来负责处理,这种模型能解决一定量的客户端请求,但是如果上了一个量级可能还是会存在性能问题,因为只有一个Acceptor。

深入理解I/O(BIO,NIO)

3、主从的Reactor模型

这个模型中,Acceptor不再是一个线程,而是一个线程池。

深入理解I/O(BIO,NIO)