零拷贝

本文深入探讨了零拷贝技术,介绍了其概念、优势及在不同操作系统中的实现方式,包括传统I/O、sendfile、DMA收集拷贝和mmap等,并详细讲解了Java NIO如何实现零拷贝。

零拷贝的概念

  零拷贝是CPU不执行拷贝数据从一个存储区域到另一个存储区域的任务,这通常用于通过网络传输一个文件时以减少CPU周期和内存带宽。零拷贝是操作系统底层的一种实现,在网络编程中,利用操作系统这一特性,可以大大提高数据传输的效率。

零拷贝的好处

  • 减少甚至完全避免不必要的CPU拷贝,从而让CPU解脱出来去执行其他的任务
  • 减少内存带宽的占用
  • 通常零拷贝技术还能够减少用户空间和操作系统内核空间之间的上下文切换

传统I/O

  在Java中,可以通过InputStream从源数据中读取数据流到一个缓冲区里,然后再将它们输入到OutputStream里。这种IO方式传输效率是比较低的。那么,操作系统发生的情况:
在这里插入图片描述

  1. JVM发出read() 系统调用
  2. OS上下文切换到内核模式(第一次上下文切换)并将数据读取到内核空间缓冲区。(第一次拷贝:hardware ----> kernel buffer)
  3. OS内核然后将数据复制到用户空间缓冲区(第二次拷贝: kernel buffer ——> user buffer),然后read系统调用返回。而系统调用的返回又会导致一次内核空间到用户空间的上下文切换(第二次上下文切换)
  4. JVM处理代码逻辑并发送write()系统调用
  5. OS上下文切换到内核模式(第三次上下文切换)并从用户空间缓冲区复制数据到内核空间缓冲区(第三次拷贝: user buffer ——> kernel buffer)
  6. write系统调用返回,导致内核空间到用户空间的再次上下文切换(第四次上下文切换)。将内核空间缓冲区中的数据写到hardware(第四次拷贝: kernel buffer ——> hardware)。

传统的I/O操作进行了4次用户空间与内核空间的上下文切换,以及4次数据拷贝。但是显然从内核空间到用户空间内存的复制是完全不必要的,因为除了将数据转储到不同的buffer之外,没有做任何其他的事情。所以最好直接从hardware读取数据到kernel buffer后,再从kernel buffer写到目标地点。为了解决这种不必要的数据复制,操作系统出现了零拷贝的概念。注意,不同的操作系统对零拷贝的实现各不相同。本文针对Linux下的零拷贝实现。

通过sendfile实现的零拷贝I/O

在这里插入图片描述

  1. 发出sendfile系统调用,导致用户空间到内核空间的上下文切换(第一次上下文切换)。通过DMA将磁盘文件中的内容拷贝到内核空间缓冲区中(第一次拷贝: hard driver ——> kernel buffer)
  2. 然后再将数据从内核空间缓冲区拷贝到内核中与socket相关的缓冲区中(第二次拷贝: kernel buffer ——> socket buffer)
  3. sendfile系统调用返回,导致内核空间到用户空间的上下文切换(第二次上下文切换)。通过DMA引擎将内核空间socket缓冲区中的数据传递到协议引擎(第三次拷贝: socket buffer ——> protocol engine)。

通过sendfile实现的零拷贝I/O只使用了2次用户空间与内核空间的上下文切换,以及3次数据的拷贝。你可能会说操作系统仍然需要在内核内存空间中复制数据(kernel buffer —>socket buffer),但从操作系统的角度来看,这已经是零拷贝,因为没有数据从内核空间复制到用户空间。 内核需要复制的原因是因为通用硬件DMA访问需要连续的内存空间(因此需要缓冲区)。 但是,如果硬件支持scatter-and-gather,这是可以避免的。

带有DMA收集拷贝功能的sendfile实现的I/O

  1. 发出sendfile系统调用,导致用户空间到内核空间的上下文切换(第一次上下文切换)。通过DMA引擎将磁盘文件中的内容拷贝到内核空间缓冲区中(第一次拷贝: hard drive ——> kernel buffer)
  2. 没有数据拷贝到socket缓冲区。取而代之的是只有相应的描述符信息会被拷贝到相应的socket缓冲区当中。该描述符包含了两方面的信息:a)kernel buffer的内存地址;b)kernel buffer的偏移量
  3. sendfile系统调用返回,导致内核空间到用户空间的上下文切换(第二次上下文切换)。DMA gather copy根据socket缓冲区中描述符提供的位置和偏移量信息直接将内核空间缓冲区中的数据拷贝到协议引擎上(第二次拷贝: kernel buffer ——> protocol engine),这样就避免了最后一次CPU数据拷贝

带有DMA收集拷贝功能的sendfile实现的I/O只使用了2次用户空间与内核空间的上下文切换,以及2次数据的拷贝,而且这2次的数据拷贝都是非CPU拷贝。这样一来就实现了最理想的零拷贝I/O传输了,不需要任何一次的CPU拷贝,以及最少的上下文切换。

  传统I/O用户空间缓冲区中存有数据,因此应用程序能够对此数据进行修改等操作;而sendfile零拷贝消除了所有内核空间缓冲区与用户空间缓冲区之间的数据拷贝过程,因此sendfile零拷贝I/O的实现是完成在内核空间中完成的,这对于应用程序来说就无法对数据进行操作了。为了解决这个问题,Linux提供了mmap零拷贝来实现需求。

通过mmap实现的零拷贝I/O

mmap(内存映射)是一个比sendfile昂贵但优于传统I/O的方法。
在这里插入图片描述

  1. 发出mmap系统调用,导致用户空间到内核空间的上下文切换(第一次上下文切换)。通过DMA引擎将磁盘文件中的内容拷贝到内核空间缓冲区中(第一次拷贝: hard drive ——> kernel buffer)
  2. mmap系统调用返回,导致内核空间到用户空间的上下文切换(第二次上下文切换)。接着用户空间和内核空间共享这个缓冲区,而不需要将数据从内核空间拷贝到用户空间。因为用户空间和内核空间共享了这个缓冲区数据,所以用户空间就可以像在操作自己缓冲区中数据一般操作这个由内核空间共享的缓冲区数据
  3. 发出write系统调用,导致用户空间到内核空间的上下文切换(第三次上下文切换)。将数据从内核空间缓冲区拷贝到内核空间socket相关联的缓冲区(第二次拷贝: kernel buffer ——> socket buffer)
  4. write系统调用返回,导致内核空间到用户空间的上下文切换(第四次上下文切换)。通过DMA引擎将内核空间socket缓冲区中的数据传递到协议引擎(第三次拷贝: socket buffer ——> protocol engine)

通过mmap实现的零拷贝I/O进行了4次用户空间与内核空间的上下文切换,以及3次数据拷贝。其中3次数据拷贝中包括了2次DMA拷贝和1次CPU拷贝。明显,它与传统I/O相比仅仅少了1次内核空间缓冲区和用户空间缓冲区之间的CPU拷贝。这样的好处是可以将整个文件或者整个文件的一部分映射到内存当中,用户直接对内存中对文件进行操作,然后是由操作系统来进行相关的页面请求并将内存的修改写入到文件当中。应用程序只需要处理内存的数据,这样可以实现非常迅速的I/O操作。

Java的实现

NIO的零拷贝

  File file = new File("test.zip");
  RandomAccessFile raf = new RandomAccessFile(file, "rw");
  FileChannel fileChannel = raf.getChannel();
  SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("", 1234));
  // 直接使用了transferTo()进行通道间的数据传输
  fileChannel.transferTo(0, fileChannel.size(), socketChannel);

NIO的零拷贝由transferTo()方法实现。transferTo()方法将数据从FileChannel对象传送到可写的字节通道(如Socket Channel等)。在内部实现中,由native方法transferTo()来实现,它依赖底层操作系统的支持。在UNIX和Linux系统中,调用这个方法将会引起sendfile()系统调用。

使用场景一般是:较大,读写较慢,追求速度;内存不足,不能加载太大数据;带宽不够,即存在其他程序或线程存在大量的IO操作,导致带宽本来就小。

以上都建立在不需要进行数据文件操作的情况下,如果既需要这样的速度,也需要进行数据操作那么使用NIO的直接内存。

NIO的直接内存

  File file = new File("test.zip");
  RandomAccessFile raf = new RandomAccessFile(file, "rw");
  FileChannel fileChannel = raf.getChannel();
  MappedByteBuffer buffer = fileChannel.map(FileChannel.MapMode.READ_ONLY, 0, fileChannel.size());

首先,它的作用位置处于传统IO(BIO)与零拷贝之间

  • IO,可以把磁盘的文件经过内核空间,读到JVM空间,然后进行各种操作,最后再写到磁盘或是发送到网络,效率较慢但支持数据文件操作
  • 零拷贝则是直接在内核空间完成文件读取并转到磁盘(或发送到网络)。由于它没有读取文件数据到JVM这一环,因此程序无法操作该文件数据,尽管效率很高

而直接内存则介于两者之间,效率一般且可操作文件数据。直接内存(mmap技术)将文件直接映射到内核空间的内存,返回一个操作地址(address),它解决了文件数据需要拷贝到JVM才能进行操作的窘境。而是直接在内核空间直接进行操作,省去了内核空间拷贝到用户空间这一步操作。

  NIO的直接内存是由MappedByteBuffer实现的。核心即是map()方法,该方法把文件映射到内存中,获得内存地址addr,然后通过这个addr构造MappedByteBuffer类,以暴露各种文件操作API。
  由于MappedByteBuffer申请的是堆外内存,因此不受Minor GC控制,只能在发生Full GC时才能被回收。而DirectByteBuffer改善了这一情况,它是MappedByteBuffer类的子类,同时它实现了DirectBuffer接口,维护一个Cleaner对象来完成内存回收。因此它既可以通过Full GC来回收内存,也可以调用clean()方法来进行回收。另外,直接内存的大小可通过jvm参数来设置:-XX:MaxDirectMemorySize。
  NIO的MappedByteBuffer还有实现叫做HeapByteBuffer,它用来在堆中申请内存,本质是一个数组。由于它位于堆中,因此可受GC管控,易于回收。

NIO DirectByteBuffer

Java NIO引入了用于通道的缓冲区的ByteBuffer。NIO中的零拷贝方法,其实都封装了sendfile,map等的系统调用,零拷贝的实现原理一样的,只不过是在基于封装的系统调用函数上,又提供了一些api方法,可以对文件进行操作。 ByteBuffer有三个主要的实现:

HeapByteBuffer
  在调用ByteBuffer.allocate()时使用。 它被称为堆,因为它保存在JVM的堆空间中,因此可以获得如GC支持和缓存优化。 但是,它不是页面对齐的,这意味着如果需要通过JNI与本地代码交谈,JVM将不得不复制到对齐的缓冲区空间。

DirectByteBuffer
  在调用ByteBuffer.allocateDirect()时使用。DirectByteBuffer是map()系统调用,DirectByteBuffer是MappedByteBuffer的子类,实现了DirectBuffer。优化了MappedByteBuffer只能依靠full gc才能回收内存的短板,自身提供了clean()方法,可主动回收内存。JVM将使用malloc()在堆空间之外分配内存空间。 因为它不是由JVM管理的,所以内存空间是页面对齐的,不受GC影响,这使得它成为处理本地代码的完美选择。

MappedByteBuffer
  在调用FileChannel.map()时使用。 与DirectByteBuffer类似,这也是JVM堆外部的情况。MappedByteBuffer 调用的是操作系统的 mmap,以便代码直接操作映射的物理内存数据。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值