一、零拷贝
1.介绍
零拷贝描述的是CPU不执行拷贝数据从一个存储区域到另一个存储区域的任务,这通常用于通过网络传输一个文件时以减少CPU周期和内存带宽。
优点:
- 减少甚至完全避免不必要的CPU拷贝,从而让CPU解脱出来去执行其他的任务
- 减少内存带宽的占用
- 通常零拷贝技术还能够减少用户空间和操作系统内核空间之间的上下文切换
2.传统copy机制
Java 传统 IO 和 网络编程的一段代码:
1 File file = new File("test.txt"); 2 RandomAccessFile raf = new RandomAccessFile(file, "rw"); 3 4 byte[] arr = new byte[(int) file.length()]; 5 raf.read(arr); 6 7 Socket socket = new ServerSocket(8080).accept(); 8 socket.getOutputStream().write(arr);
通过网络把一个文件传输给另一个程序,在OS的内部,这个copy操作要经历四次user mode和kernel mode之间的上下文切换,甚至连数据都被拷贝了四次,四次copy中,两次在用户态和内核态间copy需要CPU参与、两次在内核态与IO设备间copy为DMA方式不需要CPU参与。零拷贝避免了用户态和内核态间的copy、减少了两次用户态内核态间的切换。如下图:
① read() 调用导致一次从用户态到核心态的上下文切换。在内部调用了sys_read() 来从文件中读取data。第一次copy由DMA (direct memory access)完成,将文件内容从disk读出,存储在内核的buffer中。(hardware —> kernel buffer)
② 然后请求的数据被copy到user buffer中,此时read()成功返回。调用的返回触发了第二次context switch: 从kernel到user。至此,数据存储在user的buffer中。(kernel buffer —> user buffer)
③ send() Socket call 带来了第三次context switch,这次是从user mode到kernel mode。同时,也发生了第三次copy:把data放到了kernel adress space中。当然,这次的kernel buffer和第一步的buffer是不同的buffer。(user buffer —> kernel buffer)
④ 最终 send() system call 返回了,同时也造成了第四次context switch。同时第四次copy发生,DMA egine将data从kernel buffer拷贝到protocol engine中。第四次copy是独立而且异步的。(kernel buffer —> hardware)
3. 通过sendFile实现的零拷贝I/O
① 发出sendfile系统调用,导致用户空间到内核空间的上下文切换(第一次上下文切换)。通过DMA将磁盘文件中的内容拷贝到内核空间缓冲区中(第一次拷贝: hard driver —> kernel buffer)。
② 然后再将数据从内核空间缓冲区拷贝到内核中与socket相关的缓冲区中(第二次拷贝: kernel buffer ——> socket buffer)。
③ sendfile系统调用返回,导致内核空间到用户空间的上下文切换(第二次上下文切换)。通过DMA引擎将内核空间socket缓冲区中的数据传递到协议引擎(第三次拷贝: socket buffer —> protocol engine)。
通过sendfile实现的零拷贝I/O只使用了2次用户空间与内核空间的上下文切换,以及3次数据的拷贝。 你可能会说操作系统仍然需要在内核内存空间中复制数据(kernel buffer —>socket buffer)。 是的,但从操作系统的角度来看,这已经是零拷贝,因为没有数据从内核空间复制到用户空间。 内核需要复制的原因是因为通用硬件DMA访问需要连续的内存空间(因此需要缓冲区)。 但是,如果硬件支持scatter-and-gather,这是可以避免的。
4. 带有DMA收集拷贝功能的sendfile实现的I/O
① 发出sendfile系统调用,导致用户空间到内核空间的上下文切换(第一次上下文切换)。通过DMA引擎将磁盘文件中的内容拷贝到内核空间缓冲区中(第一次拷贝: hard drive ——> kernel buffer)。
② 没有数据拷贝到socket缓冲区。取而代之的是只有相应的描述符信息会被拷贝到相应的socket缓冲区当中。该描述符包含了两方面的信息:a)kernel buffer的内存地址;b)kernel buffer的偏移量。
③ sendfile系统调用返回,导致内核空间到用户空间的上下文切换(第二次上下文切换)。DMA gather
copy根据socket缓冲区中描述符提供的位置和偏移量信息直接将内核空间缓冲区中的数据拷贝到协议引擎上(第二次拷贝: kernel
buffer ——> protocol engine),这样就避免了最后一次CPU数据拷贝。
带有DMA收集拷贝功能的sendfile实现的I/O只使用了2次用户空间与内核空间的上下文切换,以及2次数据的拷贝,而且这2次的数据拷贝都是非CPU拷贝。这样一来我们就实现了最理想的零拷贝I/O传输了,不需要任何一次的CPU拷贝,以及最少的上下文切换。
5. 通过mmap实现的零拷贝I/O
传统I/O用户空间缓冲区中存有数据,因此应用程序能够对此数据进行修改等操作;而sendfile零拷贝消除了所有内核空间缓冲区与用户空间缓冲区之间的数据拷贝过程,因此sendfile零拷贝I/O的实现是完成在内核空间中完成的,这对于应用程序来说就无法对数据进行操作了。为了解决这个问题,Linux提供了mmap零拷贝来实现我们的需求。
① 发出mmap系统调用,导致用户空间到内核空间的上下文切换(第一次上下文切换)。通过DMA引擎将磁盘文件中的内容拷贝到内核空间缓冲区中(第一次拷贝: hard drive —> kernel buffer)。
② mmap系统调用返回,导致内核空间到用户空间的上下文切换(第二次上下文切换)。接着用户空间和内核空间共享这个缓冲区,而不需要将数据从内核空间拷贝到用户空间。因为用户空间和内核空间共享了这个缓冲区数据,所以用户空间就可以像在操作自己缓冲区中数据一般操作这个由内核空间共享的缓冲区数据。
③ 发出write系统调用,导致用户空间到内核空间的上下文切换(第三次上下文切换)。将数据从内核空间缓冲区拷贝到内核空间socket相关联的缓冲区(第二次拷贝: kernel buffer —> socket buffer)。
④ write系统调用返回,导致内核空间到用户空间的上下文切换(第四次上下文切换)。通过DMA引擎将内核空间socket缓冲区中的数据传递到协议引擎(第三次拷贝: socket buffer —> protocol engine)
通过mmap实现的零拷贝I/O进行了4次用户空间与内核空间的上下文切换,以及3次数据拷贝。其中3次数据拷贝中包括了2次DMA拷贝和1次CPU拷贝。明显,它与传统I/O相比仅仅少了1次内核空间缓冲区和用户空间缓冲区之间的CPU拷贝。这样的好处是,我们可以将整个文件或者整个文件的一部分映射到内存当中,用户直接对内存中对文件进行操作,然后是由操作系统来进行相关的页面请求并将内存的修改写入到文件当中。我们的应用程序只需要处理内存的数据,这样可以实现非常迅速的I/O操作。
3. NIO零拷贝机制
NIO引入了用于通道的缓冲区的ByteBuffer。 ByteBuffer有三个主要的实现:直接在内核中操作文件。
① HeapByteBuffer
在调用ByteBuffer.allocate()时使用。 它被称为堆,因为它保存在JVM的堆空间中,因此您可以获得所有优势,如GC支持和缓存优化。 但是,它不是页面对齐的,这意味着如果您需要通过JNI与本地代码交谈,JVM将不得不复制到对齐的缓冲区空间。
② DirectByteBuffer
在调用ByteBuffer.allocateDirect()时使用。 JVM将使用malloc()在堆空间之外分配内存空间。 因为它不是由JVM管理的,所以你的内存空间是页面对齐的,不受GC影响,这使得它成为处理本地代码的完美选择。 然而,你要C程序员一样,自己管理这个内存,必须自己分配和释放内存来防止内存泄漏。
③ MappedByteBuffer
在调用FileChannel.map()时使用。 与DirectByteBuffer类似,这也是JVM堆外部的情况。 它基本上作为OS mmap()系统调用的包装函数,以便代码直接操作映射的物理内存数据。
1 import java.io.RandomAccessFile; 2 import java.nio.MappedByteBuffer; 3 import java.nio.channels.FileChannel; 4 5 /* 6 说明 7 1. MappedByteBuffer 可让文件直接在内存(堆外内存)修改, 操作系统不需要拷贝一次 8 */ 9 public class MappedByteBufferTest { 10 public static void main(String[] args) throws Exception { 11 RandomAccessFile randomAccessFile = new RandomAccessFile("1.txt", "rw"); 12 //获取对应的通道 13 FileChannel channel = randomAccessFile.getChannel(); 14 15 /** 16 * 参数1: FileChannel.MapMode.READ_WRITE 使用的读写模式 17 * 参数2: 0 : 可以直接修改的起始位置 18 * 参数3: 5: 是映射到内存的大小(不是索引位置) ,即将 1.txt 的多少个字节映射到内存 19 * 可以直接修改的范围就是 0-5 20 * 实际类型 DirectByteBuffer 21 */ 22 MappedByteBuffer mappedByteBuffer = channel.map(FileChannel.MapMode.READ_WRITE, 0, 5); 23 mappedByteBuffer.put(0, (byte) 'H'); 24 mappedByteBuffer.put(3, (byte) '9'); 25 // mappedByteBuffer.put(5, (byte) 'Y');//IndexOutOfBoundsException 26 randomAccessFile.close(); 27 System.out.println("修改成功~~"); 28 } 29 }
④ NIO中的FileChannel拥有transferTo和transferFrom两个方法,可直接把FileChannel中的数据拷贝到另外一个Channel,或直接把另外一个Channel中的数据拷贝到FileChannel。该接口常被用于高效的网络/文件的数据传输和大文件拷贝。在操作系统支持的情况下,通过该方法传输数据并不需要将源数据从内核态拷贝到用户态,再从用户态拷贝到目标通道的内核态,同时也避免了两次用户态和内核态间的上下文切换,也即使用了“零拷贝”,所以其性能一般高于Java IO中提供的方法。
1 import java.io.FileInputStream; 2 import java.io.FileOutputStream; 3 import java.nio.channels.FileChannel; 4 5 public class NIOFileChannel04 { 6 public static void main(String[] args) throws Exception { 7 //创建相关流 8 FileInputStream fileInputStream = new FileInputStream("C:\Users\QMillet\Pictures\Saved Pictures\wallhaven-w8lo2p.png"); 9 FileOutputStream fileOutputStream = new FileOutputStream("d:\a2.jpg"); 10 //获取各个流对应的filechannel 11 FileChannel sourceCh = fileInputStream.getChannel(); 12 FileChannel destCh = fileOutputStream.getChannel(); 13 //使用transferForm完成拷贝 14 destCh.transferFrom(sourceCh,0,sourceCh.size()); 15 //关闭相关通道和流 16 sourceCh.close(); 17 destCh.close(); 18 fileInputStream.close(); 19 fileOutputStream.close(); 20 } 21 }