我所理解的IO

我所理解的IO

要说IO基本上所有人都用过,但是每每提起NIO,BIO,AIO这些概念,总是觉得模糊又清晰。下面简单的总结下:

首先,抛开Java NIO的概念,从操作系统的层面上说说几种IO

BIO(Blocking IO)

在操作系统刚开始的时候,使用的是传统的BIO。由于服务端(Server)并不知道什么时候会有客户端进行连接,同时,当建立连接后,数据的准备也需要一段时间,比如需要查询数据库,然后执行运算操作等等才能返回数据等。因此,IO采用了最简单的操作:阻塞

然后阻塞带来一个坏处他会使得其他操作无法继续进行,因为这个线程被阻塞到这里了,因此一般BIO都需要新开线程。

一个BIO Server如下(已省略try-catch):

 public static void main(String[] args) throws Exception {
        //初始化客户端
        ServerSocket serverSocket = new ServerSocket(8000);

        // 建立线程监听请求
        new Thread(() -> {
            while (true) {
                    //监听客户端连接,阻塞
                    Socket socket = serverSocket.accept();
                    //成功监听连接后,创建线程处理此连接
                    new Thread(() -> {                  
                            int len;
                            byte[] data = new byte[1024];
                            InputStream inputStream = socket.getInputStream();
                            //读取数据,阻塞
                            while ((len = inputStream.read(data)) != -1) {
                                System.out.println(new String(data, 0, len));
                            }                        
                    }).start();          
            }
        }).start();
    }

可以看到,上面阻塞的地方有两个:

  • 由于不知道客户端多久会来建立连接,因此只能阻塞等待。
  • 由于不知道客户端数据多久能准备好,因此只能阻塞等待。

因此监听连接使用一个线程,每当连接一个客户端再创建一个新的线程。

BIO的好处就是使用简单,按照正常的处理逻辑即可使用,但是坏处也比较明显,那就是每个来一个客户端连接,就需要创建一个线程,当线程过多的时候,系统的上下文开销会非常大。


NIO(Non-blocking IO)

为了解决BIO带来的问题,于是出现了NIONIO顾名思义就是非阻塞IO,在Linux操作系统中,可以通过参数设置socket为非阻塞IO.

IO为非阻塞之后,调用线程就不会被阻塞从而可以执行其他的操作。然后再次带来的一个问题就是线程不知道数据什么时候能准备好,因此需要不断的去轮询数据是否准备完成。

因此NIO的优缺点如下:

优点 : 不会阻塞线程,线程可以在数据准备期间执行其他操作。

缺点:

  • 循环主动轮询将将大幅度推高CPU占用率。
  • 由于需要主动轮询数据是否已经准备完成,可能导致数据早已准备完成,但是没有来得及查询,因此会降低系统的吞吐量。

多路复用IO(IO multiplexing)

虽然NIO能够不阻塞线程,但是会降低吞吐量,这是万万不能接受的,因此出现了多路复用IO,多路复用IONIO的基础上,修改了模型,NIO的问题就在于需要调用者主动去轮询数据是否已经准备完成,但是调用者并不知道数据多久准备完毕。于是操作系统提供了API让专门的一个线程(Selector)去监听所有的连接,如果有数据准备好,就读取,如果没有数据,Selector就会阻塞等待。

理解多路复用模型可以按照观察者来进行理解,前面说明NIO虽然非阻塞,但是可以不知道什么时候数据能准备好,而多路复用则是通过操作系统提供一个API,可以通过调用这个API获取当前Socket下所有连接的数据的准备情况,从而达到数据准备好,就立即通知业务线程进行处理的效果。从这里也可以看出多路复用名称的由来:多路连接复用一个或多个Selector进行查询通知。

JavaNIO则是使用的多路复用模型

AIO(Asynchronous IO)

AIO是真正意义的异步IO,AIO完全不会阻塞,当数据拷贝到应用程序中(后面会详细解释),才会通知,但是AIO使用比较少,在Linux 内核2.6版本才开始引入,只用知道有这个IO模型即可。


内核态,用户态

大概理解了IO的几个模型之后,接下来深入理解下IO.

在操作系统中,为了保证系统的安全,以及达到进程不能相互影响,操作系统将操作权限分为了用户态和内核态。用户态想要调用与操作系统相关的方法时,只能通过内核代码去执行,内核代码在检查这次访问合法后,才会进行执行。通过这样的方式, 可以防止他们获取别的程序的内存数据, 或者获取外围设备的数据, 并发送到网络等,提高了系统的安全性。

image-20210119114803648

Linux模型如下,可以看到,应用程序想要访问内核的东西,只能通过系统调用来实现,系统调用作为一个隔离层,来实现用户态主动切换到内核态的一种主要方式,用户态切换到内核态还有另外两种方式便是异常和中断。


Data Copy

为什么要说内核态和用户态,是因为在一次IO中,会涉及到好几次Data Copy

操作系统

前面说过,使用NIO调用IO就不会阻塞,这里的不会阻塞并不是完全不会阻塞。由于数据基本都都是在磁盘或者网卡这类“内核”中,当应用程序需要从磁盘或网卡读取数据的时候,会涉及到以下两个步骤:

  • 将数据由网卡或磁盘拷贝到内核中,这一步阻塞时间是最久的,因为磁盘要查找,网卡要等网络传输
  • 将内核的数据拷贝到应用程序中

几乎所有的IO,都需要经过这两步,包括前面说的BIO,NIO,多路复用。他们的阻塞情况如下图:

  • BIO

    image-20210119135857134

    BIO两个阶段都会阻塞。

  • NIO

    image-20210119140235570

    NIO如下,在数据准备阶段,也就是从网卡/磁盘中读取数据到内核阶段是非阻塞的,但是从内核读取数据到应用中依然是阻塞的。

  • 多路复用

    image-20210119140456673

    多路复用和NIO又不太像,由于多路复用需要调用操作系统API进行监听,因此使用Selector也是阻塞的,不过这个过程一般会单独启动一个线程进行操作,但是这个过程依然涉及到用户态到内核态的切换。当有数据准备好(也就是数据已经成功拷贝到内核中)之后,会通知Selector,然后应用程序需要从内核拷贝数据到应用程序中,这个过程是阻塞的。

  • AIO

    image-20210119152235689

    AIO全程异步,当用户发起IO请求后,只需将应用程序接收数据的地址传给操作系统,操作系统在完成两次Copy之后,才会通知应用程序,应用程序接收到通知之后便可以直接使用数据。

JVM

前面说了操作系统IOcopy data的次数,接下来再说说Java层面的。

对于JVM来说,由于存在GC,不管是标记-整理还是复制算法,都会涉及到移动对象。因此,如果我们直接将JVM中的对象的地址传给操作系统进行拷贝数据,则如果发生GC,会导致出错。同时由于JVM规范并不要求byte[]必须是连续的内存空间,但是操作系统在IO拷贝数据的时候,要求接受的内存需要是连续的,因此对于JVMIO会先将数据从内核态拷贝到DirectByteBuffer,然后再从DirectByteBuffer拷贝到JVM中,这里又涉及到一次Copy

DirectByteBuffer

从上面可以看出来,对于JVMIO来说,会涉及到3次data copy,网卡/磁盘 -> 内核 -> DirectByteBuffer -> Java程序

因此,如果我们不需要操作数据,仅仅是转发数据的话,可以只用使用堆外内存作为中转站,以节省一次data copy

但是,如果我们需要操作数据,比如解析,编码,过滤等,还是不得不将数据读取到JVM内存中进行操作。

Zero Copy

说了这么多阻塞与非阻塞,使用NIO能够使得系统在高并发的情况下依然保持很高的吞吐,但是还有没有更好的提高性能的方式呢?

前面说过,使用JVM IO数据,需要进行3次拷贝,如果有什么方法能够减少一些拷贝次数,那就更好了。

MemoryMap

由于磁盘->内核->应用程序 导致有两次数据拷贝,因此操作系统提供了内存映射(MemoryMap),通过内存映射,数据会先通过 DMA 被复制到操作系统内核的缓冲区中去。接着,应用程序会开辟一块内存与此内核缓冲区进行映射,这样,操作系统内核和应用程序存储空间就不需要数据复制操作,从而减少了一次从操作系统内核到用户态之间的数据拷贝。

注意:MMAP 的过程中,操作系统是首先建立一个用户程序虚拟地址到文件地址映射,此时仅仅是建立了映射,当程序调用MMAP读取数据的时候,CPU会对此映射的地址发起访问,此时会导致操作系统进行缺页异常,异常过程中,会调用文件系统将一页或多页的数据加载到操作系统的内核BUFFER中,从而使得你可以获取到数据。

个人的理解:mmap()高效的原因:减少了一次内核态到用户态的文件拷贝

这里mmap映射和普通的read()/write()一样最终读取的依然是操作系统内核的pagecache,只是mmap()是直接读写的pagecache,而没有经过二次拷贝,因此mmap()还是享受的有操作系统预读的特点,同时由于mmap只是将数据写到内存中,因此如果系统直接断电,则可能出现丢失数据的风险。

参考:Linux 中 mmap() 函数的内存映射问题理解? – 「已注销」的回答 – 知乎

JavaMappedByteBuffer是一个抽象类,它的具体的实现类为:DirectByteBuffer

由前面的内容,我们可以知道如果不需要对数据进行修改的话,DirectByteBuffer可以省略一次到Java对象的拷贝。因此使用MappedByteBuffer可以做到只有一次copy

mmap之所以快,是因为建立了页到用户进程的虚地址空间映射,以读取文件为例,避免了页从内核态拷贝到用户态。

image-20210225102633911

Zore Copy

同时,对于很多应用程序来说,比如网络文件下载,应用程序只是做了一次从磁盘到网卡的中转站。然而这样一次中转,最少却要经过4次拷贝。

image-20210120144952542

如图所示。可以看出来,其实这种中转站是很没有必要的,数据从内核态切换到用户态,再从用户态切换到内核态。

操作提供提供了API: sendFile,可以直接将磁盘的描述信息发送到socket buffer,然后通过DMA的方式将数据从磁盘传输到Socket Buffer,使得CPU不用拷贝任何数据,从而实现了Zero Copy

Java 中提供的Zero Copy APItransferTo()

image-20210120145456086

如图所示,应用程序只用传输少量的数据位置和长度信息的描述符到内核Buffer即可,其他的数据操作交由DMA来完成,从而实现了解放CPU

从这里也可以看出来,零拷贝不是说完全没有数据拷贝,而是说不需要CPU参与数据传输,CPU零次拷贝


对比MemoryMaptansferTo的区别:

  • MemoryMap的作用在于将磁盘空间映射到应用程序,一般都用于应用程序需要读取数据进行处理的情况下,通过MemoryMap读取文件,可以减少一次内核到应用程序的拷贝,提高性能。但是如果通过MemoryMap进行中转站式的网络文件传输,则还是需要一次CPU参与的数据传输,如下图:

    image-20210120150116787

  • sendFile则是用来解决上图所存在的问题的,在网络系统中,如果需要进行文件到网络的传输时,为了减少最后一次CPU的数据拷贝,则可以通过sendFile来完成。

对于MemoryMapsendFile使用的最直观的来说就是Kafka的使用,对于Kafka Broker来说,当接收到producer发送的消息时,直接通过MemoryMap来进行落盘,而需要将消息发送给Consumer时,则通过sendFile来完成Zero Copy,极大的提高了系统性能。

总结

IO模型到数据拷贝,都是为了提高IO的效率,网络IO通过优化IO模型实现,文件IO则通过减少CPU Copy实现。

当需要开发网络服务器的时候,为了解决IO阻塞带来的线程过多的问题,我们可以通过IO多路复用来减少线程数据,从而减少上下文切换,提高系统的吞吐率。

当涉及到文件IO的时候,由于系统的安全性设计,传统的IO存在多次数据拷贝的情况,因此在我们需要读取文件进行修改的时候,可以通过MemoryMap来减少数据从内核空间拷贝到用户空间的开销,当需要做文件传输的时候,可以通过sendFile来消除CPU的数据拷贝的开销

参考链接:

https://www.cnblogs.com/whj233/articles/10073629.html

Java NIO direct buffer的优势在哪儿? – 知乎

Linux 中 mmap() 函数的内存映射问题理解? – 「已注销」的回答 – 知乎

mmap为什么比read/write快(兼论buffercache和pagecache)