Linux传统IO
大家好,我是一段躺在Linux磁盘上的数据。现在要把我从磁盘发到网卡,需要经过以下步骤:
读操作
如上图:操作系统把内存分为了内核空间和用户空间。首先位于用户空间的应用程序使用发起数据读操作,比如JVM发起read()
系统调用。这个时候操作系统会进行一次上下文切换:从用户空间切换到内核空间。
然后内核空间通知磁盘,内核把我从磁盘copy到内核缓冲区。这个过程是由一个叫“DMA(Direct memory access)”的硬件来做的,所以不需要CPU的参与。
然后内核把我从内核缓冲区copy到应用程序缓冲区,这里需要CPU的参与。
最后进行上下文切换,又换回到用户空间的上下文。
整个读操作的过程需要两次上下文切换和两次copy。
写操作
写操作与读操作类似,只是方向相反而已,仍然需要两次上下文切换和两次数据的copy。我可能会被写到磁盘,也可能会被写到网卡。
内存映射
从上面的过程可以看到,如果想把我从磁盘发送到网卡,需要总共4次上下文切换和4次copy操作。我被操作系统在内核空间和用户空间之间来回复制,但其实我在这期间什么也没有做,什么也没有变化,就是复制而已,所以这个IO模型太浪费操作系统资源了,我被复制这么多次,身心疲惫。而且操作系统的资源是非常宝贵滴~
现在主流的操作系统都使用了虚拟内存。简单来说,就是用虚拟地址取代物理地址,这样做可以让多个虚拟内存只想同一个物理地址,虚拟内存的空间可以远远大于物理内存的空间。
那如果操作系统能够把用户空间的应用程序缓冲区和内核空间的内核缓冲区映射到同一个物理地址,那岂不是就少了很多复制的过程?如下图:
Linux零拷贝
所以为了解决这个问题,聪明的Linux开发者们写了一些新的系统调用来做这个事。主要有两种方式:
- mmap + write
- sendfile
mmap + write
mmap()
系统调用首先会使用DMA copy的方式将我从磁盘读取到内核缓冲区,然后通过内存映射的方式,使用户缓冲区和内核读缓冲区的内存地址为同一内存地址,也就是说,不需要CPU再将我从内核读缓冲区复制到用户缓冲区啦!
当使用write()
系统调用的时候,CPU将我从内核缓冲区(等同于用户缓冲区)直接写入到需要发送的内核缓冲区,比如网络发送缓冲区(socket buffer),然后通过DMA的方式将我传入到网卡驱动程序(或磁盘)中准备发送。
mmap + write的方式读写数据总共需要两次系统调用,4次上下文切换,2次DMA Copy和1次CPU Copy。
sendfile
sendfile也是一个系统调用,它其实本质上就是把上述两个系统调用的功能合起来,变成了一个调用。这样做的好处是,操作系统只需要2次上下文切换了,减少了2次上下文切换的开销。
gather
Linux2.4内核对sendfile进行了优化,提供了gather操作,这个操作可以把上图中的最后一次CPU copy去掉,原理就是不复制数据,而是把数据在之前的内核缓冲区(比如图中的案例是Read Buffer)的内存地址、偏移量记录发送给目标内核缓冲区(比如图中案例的Socket Buffer),这样在最后的DMA copy阶段就可以拿着这个指针直接去找数据copy了。
Java NIO使用零拷贝
Linux的零拷贝确实能够节约一些操作系统的资源。所以Java的NIO为了支持零拷贝,提供了一些类:
- DirectByteBuffer
- FileChannel
在之前的《Java NIO - Buffer》这篇文章里大概介绍了DirectByteBuffer。ByteBuffer主要有两种实现,一种是DirectByteBuffer, 一种是HeapByteBuffer。
其中,DirectByteBuffer直接在堆外分配内存,底层是直接通过JNI调用操作系统的NIO系统调用,所以性能会比较高。而HeapByteBuffer是堆内内存,而且数据需要多一次拷贝,所以性能比较低。
FileChannel
是Java NIO提供的用于复制文件的类,可以把文件复制到磁盘或者网络等。
map
方法其实就是采用了操作系统中的内存映射方式,将内核缓冲区的内存和用户缓冲区的内存做了一个地址映射。
transferTo
方法直接将当前通道内容传输到另一个通道,也就是说这种方式不会有内核缓冲区到用户缓冲区的读写问题。底层是sendfile系统调用。transferFrom
方法同理。
示例代码:
File file = new File("test.txt");
RandomAccessFile raf = new RandomAccessFile(file, "rw");
FileChannel fileChannel = raf.getChannel();
SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("", 8080));
// 直接使用了transferTo()进行通道间的数据传输
fileChannel.transferTo(0, fileChannel.size(), socketChannel);
共同学习,写下你的评论
评论加载中...
作者其他优质文章