JAVA-零拷贝

img

引言

传统的 Linux 操作系统的标准 I/O 接口是基于数据拷贝操作的,即 I/O 操作会导致数据在操作系统内核地址空间的缓冲区和应用程序地址空间定义的缓冲区之间进行传输。这样做最大的好处是可以减少磁盘 I/O 的操作,因为如果所请求的数据已经存放在操作系统的高速缓冲存储器中,那么就不需要再进行实际的物理磁盘 I/O 操作。但是数据传输过程中的数据拷贝操作却导致了极大的 CPU 开销,限制了操作系统有效进行数据传输操作的能力。
​ 零拷贝( zero-copy )技术可以有效地改善数据传输的性能,在内核驱动程序(比如网络堆栈或者磁盘存储驱动程序)处理 I/O 数据的时候,零拷贝技术可以在某种程度上减少甚至完全避免不必要 CPU 数据拷贝操作。

什么是零拷贝

零拷贝就是一种避免 CPU 将数据从一块存储拷贝到另外一块存储的技术。针对操作系统中的设备驱动程序、文件系统以及网络协议堆栈而出现的各种零拷贝技术极大地提升了特定应用程序的性能,并且使得这些应用程序可以更加有效地利用系统资源。这种性能的提升就是通过在数据拷贝进行的同时,允许 CPU 执行其他的任务来实现的。
​ 零拷贝技术可以减少数据拷贝和共享总线操作的次数,消除传输数据在存储器之间不必要的中间拷贝次数,从而有效地提高数据传输效率。而且,零拷贝技术减少了用户应用程序地址空间和操作系统内核地址空间之间因为上下文切换而带来的开销。进行大量的数据拷贝操作其实是一件简单的任务,从操作系统的角度来说,如果 CPU 一直被占用着去执行这项简单的任务,那么这将会是很浪费资源的;如果有其他比较简单的系统部件可以代劳这件事情,从而使得 CPU 解脱出来可以做别的事情,那么系统资源的利用则会更加有效。

避免数据拷贝

综上所述,零拷贝技术的目标可以概括如下:

  1. 避免操作系统内核缓冲区之间进行数据拷贝操作。
  2. 避免操作系统内核和用户应用程序地址空间这两者之间进行数据拷贝操作。
  3. 用户应用程序可以避开操作系统直接访问硬件存储。
  4. 数据传输尽量让 DMA 来做。

将多种操作结合在一起

  1. 避免不必要的系统调用和上下文切换。
  2. 需要拷贝的数据可以先被缓存起来。
  3. 对数据进行处理尽量让硬件来做。

零拷贝给我们带来的好处

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

预备知识

关于I/O内存映射

设备通过控制总线,数据总线,状态总线与CPU相连。控制总数传送控制信号。在传统的操作中,都是通过读写设备寄存器的值来实现。但是这样耗费了CPU时钟。而且每取一次值都要读取设备寄存器,造成了效率的低下。在现代操作系统中。引用了I/O内存映射。即把寄存器的值映身到主存。对设备寄存器的操作,转换为对主存的操作,这样极大的提高了效率。

CPU COPY

通过计算机的组成原理我们知道, 内存的读写操作是需要CPU的协调数据总线,地址总线和控制总线来完成的,因此在”拷贝”发生的时候,往往需要CPU暂停现有的处理逻辑,来协助内存的读写.这种我们称为CPU COPY,cpu copy不但占用了CPU资源,还占用了总线的带宽。

DMA COPY

DMA(DIRECT MEMORY ACCESS,直接内存存取)是现代计算机的重要功能,

它允许不同速度的硬件装置来沟通,而不需要依赖于CPU的大量中断负载。否则,CPU需要从来源把每一片段的资料复制到暂存器,然后把它们再次写回到新的地方。在这个时间中,CPU对于其他的工作来说就无法使用

它的一个重要 的特点就是, 当需要与外设进行数据交换时, CPU只需要初始化这个动作便可以继续执行其他指令,剩下的数据传输的动作完全由DMA来完成,可以看到DMA COPY是可以避免大量的CPU中断的

上下文切换

本文中的上下文切换时指由用户态切换到内核态, 以及由内核态切换到用户态

存在多次拷贝的原因

  1. 操作系统为了保护系统不被应用程序有意或无意地破坏,为操作系统设置了用户态和内核态两种状态.用户态想要获取系统资源(例如访问硬盘), 必须通过系统调用进入到内核态, 由内核态获取到系统资源,再切换回用户态返回应用程序.
  2. 出于”readahead cache”和异步写入等等性能优化的需要, 操作系统在内核态中也增加了一个”内核缓冲区”(kernel buffer). 读取数据时并不是直接把数据读取到应用程序的buffer, 而先读取到kernel buffer, 再由kernel buffer复制到应用程序的buffer. 因此,数据在被应用程序使用之前,可能需要被多次拷贝

传统读操作

img

JAVA用传统方式进行读操作时整体流程如上图,具体如下:

  1. 应用程序发起读数据操作,JVM会发起read()系统调用。
  2. 这时操作系统OS会进行一次上下文切换(把用户空间切换到内核空间)
  3. 通过磁盘控制器把数据copy到内核缓冲区中,这里的就发生了一次DMA Copy
  4. 然后内核将数据copy到用户空间的应用缓冲区中,发生了一次CPU Copy
  5. read调用返回后,会再进行一次上下文切换(把内核空间切换到用户空间)

我们看一下一个读操作,发了2次上下文切换,和2次数据copy,一次是DMA Copy,一次是CPU Copy。

注意一点的是 内核从磁盘上面读取数据 是 不消耗CPU时间的,是通过磁盘控制器完成;称之为DMA Copy。

传统写操作

img

上图是JAVA传统的写操作,具体流程:

1、应用发起写操作,OS进行一次上下文切换(从用户空间切换为内核空间)

2、并且把数据copy到内核缓冲区Socket Buffer,做了一次CPU Copy

3、内核空间再把数据copy到磁盘或其他存储(网卡,进行网络传输),进行了DMA Copy

4、写入结束后返回,又从内核空间切换到用户空间

实际场景

回想现实世界的所有系统中, 不管是web应用服务器, ftp服务器,数据库服务器, 静态文件服务器等等, 所有涉及到数据传输的场景, 无非就一种:从硬盘上读取文件数据, 发送到网络上去。

这个场景我们简化为一个模型:

File.read(fileDesc, buf, len);
Socket.send(socket, buf, len);

操作系统在实现这个模型时,需要有以下步骤:

  1. 应用程序开始读文件的操作
  2. 应用程序发起系统调用, 从用户态切换到内核态(第一次上下文切换)
  3. 内核态中把数据从硬盘文件读取到内核中间缓冲区(kernel buf)
  4. 数据从内核中间缓冲区(kernel buf)复制到(用户态)应用程序缓冲区(app buf),从内核态切换回到用户态(第二次上下文切换)
  5. 应用程序开始发送数据到网络上
  6. 应用程序发起系统调用,从用户态切换到内核态(第三次上下文切换)
  7. 内核中把数据从应用程序(app buf)的缓冲区复制到socket的缓冲区(socket)
  8. 内核中再把数据从socket的缓冲区(socket buf)发送的网卡的缓冲区(NIC buf)上
  9. 从内核态切换回到用户态(第四次上下文切换)

img

由上图可以很清晰地看到, 涉及到了四次拷贝:

  1. 硬盘拷贝到内核缓冲区(DMA COPY)
  2. 内核缓冲区拷贝到应用程序缓冲区(CPU COPY)
  3. 应用程序缓冲区拷贝到socket缓冲区(CPU COPY)
  4. socket buf拷贝到网卡的buf(DMA COPY)

其中涉及到2次cpu中断, 还有4次的上下文切换

很明显,第2次和第3次的的copy只是把数据复制到app buffer又原封不动的复制回来, 为此带来了两次的cpu copy和两次上下文切换, 是完全没有必要的

linux的零拷贝技术就是为了优化掉这两次不必要的拷贝

传统IO

我们可以看出传统的IO读写操作,总共进行了4次上下文切换,4次Copy动作。我们可以看到数据在内核空间和应用空间之间来回复制,其实他们什么都没有做,就是复制而已,这个机制太浪费时间了,而且是浪费的CPU的时间

那我们能不能让数据不要来回复制呢?零拷贝这个技术就是来解决这个问题。关于零拷贝提供了两种解决方式:mmap+write方式、sendfile方式

虚拟内存

所有现代操作系统都使用虚拟内存,使用虚拟地址取代物理地址,这样做的好处就是:

  1. 多个虚拟内存可以指向同一个物理地址
  2. 虚拟内存空间可以远远大于物理内存空间

我们利用第一条特性可以优化一下上面的设计思路,就是把内核空间和用户空间的虚拟地址映射到同一个物理地址,这样就不需要来回复制了,看图:

img

mmap+write方式

使用mmap+write方式替换原来的传统IO方式,就是利用了虚拟内存的特性,看图

img

整体流程的核心区别就是,把数据读取到内核缓冲区后,应用程序进行写入操作时,直接是把内核的Read Buffer的数据 复制到 Socket Buffer 以便进行写入,这次内核之间的复制也是需要CPU参与的

注意:最后把Socket Buffer数据拷贝到很多地方,统称protocol engine(协议引擎)

这个流程就少了一个CPU Copy,提升了IO的速度。不过发现上下文的切换还是4次,没有减少,因为还是要应用程序发起write操作。那能不能减少上下文切换呢?

sendfile方式

为了简化用户接口,同时还要继续保留 mmap()/write() 技术的优点:减少 CPU 的复制次数,Linux 在版本 2.1 中引入了 sendfile() 这个系统调用。

这种方式可以替换上面的mmap+write方式,如:

mmap();
write();

替换为

sendfile();

这样就减少了一次上下文切换,因为少了一个应用程序发起write操作,直接发起sendfile操作。

到这里就只有3次Copy,其中只有1次CPU Copy;3次上下文切换。那能不能把CPU Copy减少到没有呢?

实际场景

有了sendFile这个系统调用后, 我们read-send模型就可以简化为:

  1. 应用程序开始读文件的操作
  2. 应用程序发起系统调用, 从用户态切换到内核态(第一次上下文切换)
  3. 内核态中把数据从硬盘文件读取到内核中间缓冲区
  4. 通过sendFile,在内核态中把数据从内核缓冲区复制到socket的缓冲区
  5. 内核中再把数据从socket的缓冲区发送的网卡的buf上
  6. 从内核态切换到用户态(第二次上下文切换)

img

涉及到数据拷贝变成:

  1. 硬盘拷贝到内核缓冲区(DMA COPY)
  2. 内核缓冲区拷贝到socket缓冲区(CPU COPY)
  3. socket缓冲区拷贝到网卡的buf(DMA COPY)

可以看到,一次read-send模型中, 利用sendFile系统调用后, 可以将4次数据拷贝减少到3次, 4次上下文切换减少到2次, 2次CPU中断减少到1次

相对传统I/O, 这种零拷贝技术通过减少两次上下文切换, 1次cpu copy, 可以将I/O性能提高50%以上(网络数据, 未亲测)

开始的术语中说到, 所谓的零拷贝的”零”, 是指用户态和内核态之间的拷贝次数为0, 从这个定义上来说, 现在的这个零拷贝技术已经是真正的”零”了

然而, 对性能追求极致的伟大的科学家和工程师们并不满足于此. 精益求精的他们对中间第2次的cpu copy依旧耿耿于怀, 想尽千方百计要去掉这一次没有必要的数据拷贝和CPU中断

scatter-gather特性的sendFile

Linux2.4内核进行了优化,提供了gather操作,这个操作可以把最后一次CPU Copy去除,什么原理呢?就是在内核空间Read Buffer和Socket Buffer不做数据复制,而是将Read Buffer的内存地址、偏移量记录到相应的Socket Buffer中,这样就不需要复制(其实本质就是和虚拟内存的解决方法思路一样,就是内存地址的记录),如图:

img

实际场景

这个优化后的sendFile, 我们称之为支持scatter-gather特性的sendFile

在支持scatter-gather特性的sendFile的支撑下, 我们的模型可以优化为:

  1. 应用程序开始读文件的操作
  2. 应用程序发起系统调用, 从用户态进入到内核态(第一次上下文切换)
  3. 内核态中把数据从硬盘文件读取到内核中间缓冲区
  4. 内核态中把数据在内核缓冲区的位置(offset)和数据大小(size)两个信息追加(append)到socket的缓冲区中去
  5. 网卡的buf上根据socekt缓冲区的offset和size从内核缓冲区中直接拷贝数据
  6. 从内核态返回到用户态(第二次上下文切换)

这个过程如下图所示:

img

最后数据拷贝变成只有两次DMA COPY:

  1. 硬盘拷贝到内核缓冲区(DMA COPY)
  2. 内核缓冲区拷贝到网卡的buf(DMA COPY)

JAVA零拷贝

MappedByteBuffer

MappedByteBuffer是一种效率低于零拷贝,但高于传统IO的IO操作。

算是一种弥补transferTo零拷贝时无法中间处理源数据的手段。不过如果要中间处理数据的话,效率就会变得比零拷贝低,如果不在java程序内做中间处理,效率其实和零拷贝差不多。

其实MappedByteBuffer是抽象类,而具体实现是DirectByteBuffer和DirectByteBufferR,并且是DirectBuffer的实现。也就是说,MappedByteBuffer其实用的也是堆外内存。只不过暂时现在我只知道MappedByteBuffer只能对文件进行映射。把该文件指定偏移量范围的数据与堆外内存的偏移量一一对应(但数据并不会在创建MappedByteBuffer对象的时候立即加载到内存中)

原理
  1. 在get数据时,如果缓冲区中没有数据,则会去磁盘获取文件数据(并且预读一部分数据(page cache页缓存。不纠结这个)),然后放到该缓冲区中(MappedByteBuffer)。
  2. 如果put数据,则会先把数据放到该MappedByteBuffer对应的偏移量位置(操作系统会自己找时机动态把数据按索引对应关系刷回磁盘)
  3. 如果java程序需要处理这部分数据,可以通过get方法把数据读到jvm内存中(获取出来复制给字节数组或者变量),处理过这些数据之后,把结果再put回该缓冲区(如果不做这一步操作,其实MappedByteBuffer与transferTo是一样的,最多就是多创建几个MappedByteBuffer的java对象而已,其他的堆外内存是基本一样的)
  4. 然后再把MappedByteBuffer的数据写到其他通道(如:SocketChannel、FileChannel),这些通道对应的操作目标此时就像零拷贝的过程一样,把MappedByteBuffer直接复制到操作目标的DirectBuffer缓冲区中。
三种方式

FileChannel提供了map方法来把文件影射为内存映像文件: MappedByteBuffer map(int mode,long position,long size); 可以把文件的从position开始的size大小的区域映射为内存映像文件,mode指出了 可访问该内存映像文件的方式:READ_ONLY,READ_WRITE,PRIVATE。

  • READ_ONLY(只读): 试图修改得到的缓冲区将导致抛出 ReadOnlyBufferException.(MapMode.READ_ONLY)
  • READ_WRITE(读/写): 对得到的缓冲区的更改最终将传播到文件;该更改对映射到同一文件的其他程序不一定是可见的。 (MapMode.READ_WRITE)
  • PRIVATE(专用): 对得到的缓冲区的更改不会传播到文件,并且该更改对映射到同一文件的其他程序也不是可见的;相反,会创建缓冲区已 修改部分的专用副本。 (MapMode.PRIVATE)
三个方法
  • fore():缓冲区是READ_WRITE模式下,此方法对缓冲区内容的修改强行写入文件
  • load():将缓冲区的内容载入内存,并返回该缓冲区的引用
  • isLoaded():如果缓冲区的内容在物理内存中,则返回真,否则返回假
三个特性

调用信道的map()方法后,即可将文件的某一部分或全部映射到内存中,映射内存缓冲区是个直接缓冲区,继承自ByteBuffer,但相对于ByteBuffer,它有更多的优点:

  • 读取快
  • 写入快
  • 随时随地写入
实战
public static void mapMemeryBuffer(boolean isZeroCopy) throws IOException {
    //对内缓存进行分配空间
    ByteBuffer byteBuf = ByteBuffer.allocate(1024 * 14 * 1024);
    //设置写缓存带下
    byte[] bbb = new byte[14 * 1024 * 1024];
    FileInputStream fis = new FileInputStream("e://迅雷下载//ideaIU-2019.3.exe");
    FileOutputStream fos = new FileOutputStream("e://tmp//outFile.txt");
    FileChannel fc = fis.getChannel();
    long timeStar = System.currentTimeMillis();// 得到当前的时间
    MappedByteBuffer mbb = null;
    if (isZeroCopy) {
        //使用零拷贝进行读取
        mbb = fc.map(FileChannel.MapMode.READ_ONLY, 0, fc.size());
    } else {
        //普通方式进行读取
        fc.read(byteBuf);
    }
    System.out.println(fc.size() / 1024);
    long timeEnd = System.currentTimeMillis();// 得到当前的时间
    System.out.println("Read time :" + (timeEnd - timeStar) + "ms");
    timeStar = System.currentTimeMillis();
    if (isZeroCopy) {
        //零拷贝方式写入
        mbb.flip();
    } else {
        //普通方式写入
        fos.write(bbb);
    }
    timeEnd = System.currentTimeMillis();
    System.out.println("Write time :" + (timeEnd - timeStar) + "ms");
    fos.flush();
    fc.close();
    fis.close();
}

测试代码

System.out.println("---------使用零拷贝--------------");
mapMemeryBuffer(true);
System.out.println("---------不使用零拷贝--------------");
mapMemeryBuffer(false);

输出结果:

---------使用零拷贝--------------
673399
Read time :2ms
Write time :0ms
---------不使用零拷贝--------------
673399
Read time :17ms
Write time :15ms

可以看出速度有了很大的提升。MappedByteBuffer的确快,但也存在一些问题,主要就是内存占用和文件关闭等不确定问题。被MappedByteBuffer打开的文件只有在垃圾收集时才会被关闭,而这个点是不确定的。在javadoc里是这么说的:

A mapped byte buffer and the file mapping that it represents remain valid until the buffer itself is garbage-collected.

这里提供一种解决方案:

AccessController.doPrivileged(new PrivilegedAction() {
   public Object run() {
     try {
       Method getCleanerMethod = buffer.getClass().getMethod("cleaner", new Class[0]);
       getCleanerMethod.setAccessible(true);
       sun.misc.Cleaner cleaner = (sun.misc.Cleaner) 
       getCleanerMethod.invoke(byteBuffer, new Object[0]);
       cleaner.clean();
     } catch (Exception e) {
       e.printStackTrace();
     }
     return null;
   }
 });

transgerTo

使用transgerTo()方法时涉及的步骤包括以下两步:

  1. transgerTo方法调用触发DMA引擎将文件上下文信息拷贝到内核读缓冲区,接着内核将数据从内核缓冲区拷贝到与外出套接字相关联的缓冲区。
  2. DMA引擎将数据从内核套接字缓冲区传输到协议引擎(第三次数据拷贝)

这是一个改进:上下文切换的次数从4次减少到2次,数据拷贝的次数从4次减少到3次(仅有一次数据拷贝消耗CPU资源)。然而,这并没有实现零拷贝的目标,如果底层网卡支持gather operations,可以进一步减少内核拷贝数据的次数。Linux 内核 从2.4 版本开始修改了套接字缓冲区描述符以满足这个要求。这种方法不仅减少了多个上下文切换,还消除了消耗CPU的重复数据拷贝。用户使用的方法没有任何变化,依然通过transferTo方法,但是方法的内部实现

发生了变化:

  1. transferTo方法调用触发 DMA 引擎将文件上下文信息拷贝到内核缓冲区。
  2. 数据不会被拷贝到套接字缓冲区,只有数据的描述符(包括数据位置和长度)被拷贝到套接字缓冲区。DMA 引擎直接将数据从内核缓冲区拷贝到协议引擎,这样减少了最后一次需要消耗CPU的拷贝操作。
使用场景
  • 较大,读写较慢,追求速度
  • M内存不足,不能加载太大数据
  • 带宽不够,即存在其他程序或线程存在大量的IO操作,导致带宽本来就小
性能比较

在Linux 内核2.6版本上,以毫秒统计使用传统方法和使用transferTo方法传输不同大小的文件的耗时。表1展示了测试结果:

File size Normal file transfer (ms) transferTo (ms)
7MB 156 45
21MB 337 128
63MB 843 387
98MB 1320 617
200MB 2124 1150
350MB 3631 1762
700MB 13498 4422
1GB 18399 8537

从测试结果来看使用transgerTo的API和传统方法相比可以降低65%的传输时间。这可以有效的提高在不同I/O通道之间大量拷贝数据应用的性能。

实战
public static void fileTransferTo() throws IOException {
    FileChannel inChannel = FileChannel.open(Paths.get("e://迅雷下载//ideaIU-2019.3.exe"), StandardOpenOption.READ);
    FileChannel outChannel = FileChannel.open(Paths.get("e://tmp//outFile.txt"), StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.READ, StandardOpenOption.CREATE);
    long timeStar = System.currentTimeMillis();// 得到当前的时间
    inChannel.transferTo(0, inChannel.size(), outChannel);
    //  outChannel.transferFrom(inChannel, 0, inChannel.size());
    long timeEnd = System.currentTimeMillis();
    System.out.println("transferTo Write time :" + (timeEnd - timeStar) + "ms");
    inChannel.close();
    outChannel.close();
}

输出

---------使用TransferTo零拷贝--------------
transferTo Write time :557ms

总结

零拷贝技术在很多中间件中,都有利用;如:Kafka,Spark、RocketMQ等,这个在网络传输数据时,能够提升速度,提升系统性能、吞吐量。小伙伴们不一定会编写,可以先了解基本原理就行。很多好的中间件产品都需要了解一些计算机原理方面的知识,才会更深入的理解。