Java中的零拷贝

先提出两个问题:
IO过程中,哪些步骤进行了拷贝?哪些地方零拷贝?
Java支持哪些零拷贝?

带着这俩问题,我们一起来看下面的探究。

哪里听说过零拷贝?真的0次拷贝吗?

相信大家伙在以往的学习中,或多或少在下面这些组件、框架中有听说过零拷贝 (Zero-Copy)?

Kafka
Netty
rocketmq
nginx
apache

什么是零拷贝?

零拷贝(英语: Zero-copy) 技术是指计算机执行操作时,CPU不需要先将数据从某处内存复制到另一个特定区域。这种技术通常用于通过网络传输文件时节省CPU周期和内存带宽。

➢零拷贝技术可以减少数据拷贝和共享总线操作的次数,消除传输数据在存储器之间不必要的中间拷贝次数,从而有效地提高数据传输效率
➢零拷贝技术减少了用户进程地址空间和内核地址空间之间因为上:下文切换而带来的开销
可以看出没有说不需要拷贝,只是说减少冗余[不必要]的拷贝。

LinuxI/O机制及零拷贝介绍

IO中断与DMA

IO中断,需要CPU响应,需要CPU参与,因此效率比较低。

用户进程需要读取磁盘数据,需要CPU中断,发起IO请求,每次的IO中断,都带来CPU的上下文切换。

因此出现了——DMA。

DMA(Direct Memory Access,直接内存存取) 是所有现代电脑的重要特色,它允许不同速度的硬件装置来沟通,而不需要依赖于CPU 的大量中断负载。
DMA控制器,接管了数据读写请求,减少CPU的负担。这样一来,CPU能高效工作了。
现代硬盘基本都支持DMA。

Linux IO流程

实际因此IO读取,涉及两个过程:
1、DMA等待数据准备好,把磁盘数据读取到操作系统内核缓冲区;
2、用户进程,将内核缓冲区的数据copy到用户空间。
这两个过程,都是阻塞的。

传统数据传送

比如:读取文件,再用socket发送出去
传统方式实现:
先读取、再发送,实际经过1~4四次copy。

buffer = File.read 
Socket.send(buffer)

1、第一次:将磁盘文件,读取到操作系统内核缓冲区;
2、第二次:将内核缓冲区的数据,copy到application应用程序的buffer;
3、第三步:将application应用程序buffer中的数据,copy到socket网络发送缓冲区(属于操作系统内核的缓冲区);
4、第四次:将socket buffer的数据,copy到网卡,由网卡进行网络传输。

传统方式,读取磁盘文件并进行网络发送,经过的四次数据copy是非常繁琐的。实际IO读写,需要进行IO中断,需要CPU响应中断(带来上下文切换),尽管后来引入DMA来接管CPU的中断请求,但四次copy是存在“不必要的拷贝”的。

重新思考传统IO方式,会注意到实际上并不需要第二个和第三个数据副本。应用程序除了缓存数据并将其传输回套接字缓冲区之外什么都不做。相反,数据可以直接从读缓冲区传输到套接字缓冲区。

显然,第二次和第三次数据copy 其实在这种场景下没有什么帮助反而带来开销,这也正是零拷贝出现的背景和意义。

传统数据传送所消耗的成本:4次拷贝,4次上下文切换。
4次拷贝,其中两次是DMA copy,两次是CPU copy。如下图所示
拷贝是个IO过程,需要系统调用。

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

零拷贝的出现

目的:减少IO流程中不必要的拷贝
零拷贝需要OS支持,也就是需要kernel暴露api。虚拟机不能操作内核,

Linux支持的(常见)零拷贝

一、mmap内存映射

data loaded from disk is stored in a kernel buffer by DMA copy. Then the pages of the application buffer are mapped to the kernel buffer, so that the data copy between kernel buffers and application buffers are omitted.

DMA加载磁盘数据到kernel buffer后,应用程序缓冲区(application buffers)和内核缓冲区(kernel buffer)进行映射,数据再应用缓冲区和内核缓存区的改变就能省略。

mmap内存映射将会经历:3次拷贝: 1次cpu copy,2次DMA copy;
以及4次上下文切换

二、sendfile

linux 2.1支持的sendfile

when calling the sendfile() system call, data are fetched from disk and copied into a kernel buffer by DMA copy. Then data are copied directly from the kernel buffer to the socket buffer. Once all data are copied into the socket buffer, the sendfile() system call will return to indicate the completion of data transfer from the kernel buffer to socket buffer. Then, data will be copied to the buffer on the network card and transferred to the network.

当调用sendfile()时,DMA将磁盘数据复制到kernel buffer,然后将内核中的kernel buffer直接拷贝到socket buffer;
一旦数据全都拷贝到socket buffer,sendfile()系统调用将会return、代表数据转化的完成。
socket buffer里的数据就能在网络传输了。

sendfile会经历:3次拷贝,1次CPU copy 2次DMA copy;
以及2次上下文切换

三、Sendfile With DMA Scatter/Gather Copy

Then by using the DMA scatter/gather operation, the network interface card can gather all the data from different memory locations and store the assembled packet in the network card buffer.

Scatter/Gather可以看作是sendfile的增强版,批量sendfile。

Scatter/Gather会经历2次拷贝: 0次cpu copy,2次DMA copy

IO请求批量化
DMA scatter/gather:需要DMA控制器支持的。
DMA工作流程:cpu发送IO请求给DMA,DMA然后读取数据。
IO请求:相当于可以看作包含一个物理地址。
从一系列物理地址(10)读数据:普通的DMA (10请求)
dma scatter/gather:一次给10个物理地址, 一个请求就可以(批量处理)。

4、splice

Linux 2.6.17 支持splice

it does not need to copy data between kernel space and user space.
When using this approach, data are copied from disk to kernel buffer first. Then the splice() system call allows data to move between different buffers in kernel space without the copy to user space.
Unlike the method sendfile() with DMA scatter/gather copy, splice() does not need support from hardware.

数据从磁盘读取到OS内核缓冲区后,在内核缓冲区直接可将其转成内核空间其他数据buffer,而不需要拷贝到用户空间。
如下图所示,从磁盘读取到内核buffer后,在内核空间直接与socket buffer建立pipe管道。
和sendfile()不同的是,splice()不需要硬件支持。

注意splice和sendfile的不同,sendfile是将磁盘数据加载到kernel buffer后,需要一次CPU copy,拷贝到socket buffer。
而splice是更进一步,连这个CPU copy也不需要了,直接将两个内核空间的buffer进行set up pipe。

splice会经历 2次拷贝: 0次cpu copy 2次DMA copy;
以及2次上下文切换

Linux零拷贝机制对比
无论是传统IO方式,还是引入零拷贝之后,2次DMA copy 是都少不了的。因为两次DMA都是依赖硬件完成的。

零拷贝的广义狭义之分

实际上,零拷贝时有广义和狭义之分的。
广义零拷贝: 能减少拷贝次数,减少不必要的数据拷贝,就算作“零拷贝”。
这是目前,对零拷贝最为广泛的定义,我们需要知道的是,这是广义上的零拷贝,并不是操作系统 意义上的零拷贝。

零拷贝的广义性

最早的零拷贝定义,来源于

Linux 2.4内核新增 sendfile 系统调用,提供了零拷贝。磁盘数据通过 DMA 拷贝到内核态 Buffer 后,直接通过 DMA 拷贝到 NIC Buffer(socket buffer),无需 CPU 拷贝。这也是零拷贝这一说法的来源。这是真正操作系统 意义上的零拷贝(也就是狭义零拷贝)。

但是我们知道,由OS内核提供的 操作系统意义上的零拷贝,发展到目前也并没有很多种,也就是这样的零拷贝并不是很多;

随着发展,零拷贝的概念得到了延伸,就是目前的减少不必要的数据拷贝都算作零拷贝的范畴;

糟糕的是,一些开发者、机构、某些框架,在产品推广或竞争中“滥用”零拷贝这个概念,包装并美其名曰“性能...有多高,采用零拷贝...”

尤其在框架孵化、推广初期,和竞对争夺市场时,这样的宣传似乎会让不是很内行的人 不明觉厉。

今天提及的目的,是要大家明白,在看到xxx框架底层采用零拷贝时,或许并不是真正意义上的零拷贝,或许只是借用概念。

在此说明,并不是否认某些框架借用概念的行为,毕竟随着发展,零拷贝的概念得到了延伸,容纳了新的东西。

想要强调的是,作为一线技术者,应该不被几句宣传蒙蔽双眼;需要清晰的知道,数据合并以减少拷贝和内核提供的API、在性能提升方面还是有天壤之别的。

若能稍作深入了解,便能识透其真相,究竟只是偏向于优化数据操作,还是真正切合场景、灵活运用了操作系统意义上的零拷贝,都会浮出水面了。

后文,也会对目前使用了零拷贝的常见框架进行分析。

Java零拷贝机制解析

Linux提供的领拷贝技术 Java并不是全支持,支持2种(内存映射mmap、sendfile);

NIO提供的内存映射 MappedByteBuffer
  • 首先要说明的是,JavaNlO中 的Channel (通道)就相当于操作系统中的内核缓冲区,有可能是读缓冲区,也有可能是网络缓冲区,而Buffer就相当于操作系统中的用户缓冲区。
MappedByteBuffer mappedByteBuffer = new RandomAccessFile(file, "r") 
                                 .getChannel() 
                                .map(FileChannel.MapMode.READ_ONLY, 0, len);

底层就是调用Linux mmap()实现的。

NIO中的FileChannel.map()方法其实就是采用了操作系统中的内存映射方式,底层就是调用Linux mmap()实现的。

将内核缓冲区的内存和用户缓冲区的内存做了一个地址映射。这种方式适合读取大文件,同时也能对文件内容进行更改,但是如果其后要通过SocketChannel发送,还是需要CPU进行数据的拷贝。
使用MappedByteBuffer,小文件,效率不高;一个进程访问,效率也不高。

MappedByteBuffer只能通过调用FileChannel的map()取得,再没有其他方式。
FileChannel.map()是抽象方法,具体实现是在 FileChannelImpl.c 可自行查看JDK源码,其map0()方法就是调用了Linux内核的mmap的API。
使用 MappedByteBuffer类要注意的是:mmap的文件映射,在full gc时才会进行释放。当close时,需要手动清除内存映射文件,可以反射调用sun.misc.Cleaner方法。

NIO提供的sendfile
  • FileChannel.transferTo()方法直接将当前通道内容传输到另一个通道,没有涉及到Buffer的任何操作,NIO中 的Buffer是JVM堆或者堆外内存,但不论如何他们都是操作系统内核空间的内存
  • transferTo()的实现方式就是通过系统调用sendfile() (当然这是Linux中的系统调用)
//使用sendfile:读取磁盘文件,并网络发送
FileChannel sourceChannel = new RandomAccessFile(source, "rw").getChannel();
SocketChannel socketChannel = SocketChannel.open(sa);
sourceChannel.transferTo(0, sourceChannel.size(), socketChannel);

ZeroCopyFile实现文件复制

class ZeroCopyFile {

    public void copyFile(File src, File dest) {
        try (FileChannel srcChannel = new FileInputStream(src).getChannel();
             FileChannel destChannel = new FileInputStream(dest).getChannel()) {

            srcChannel.transferTo(0, srcChannel.size(), destChannel);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

注意: Java NIO提供的FileChannel.transferTo 和 transferFrom 并不保证一定能使用零拷贝。实际上是否能使用零拷贝与操作系统相关,如果操作系统提供 sendfile 这样的零拷贝系统调用,则这两个方法会通过这样的系统调用充分利用零拷贝的优势,否则并不能通过这两个方法本身实现零拷贝。


Kafka中的零拷贝

Kafka两个重要过程都使用了零拷贝技术,且都是操作系统层面的狭义零拷贝,一是Producer生产的数据存到broker,二是 Consumer从broker读取数据。

  • Producer生产的数据持久化到broker,采用mmap文件映射,实现顺序的快速写入;
  • Customer从broker读取数据,采用sendfile,将磁盘文件读到OS内核缓冲区后,直接转到socket buffer进行网络发送。

深入学习请移步 https://www.jianshu.com/p/1c27da322767

Netty中的零拷贝

Netty中的Zero-copy与上面我们所提到到OS层面上的Zero-copy不太一样, Netty的Zero-copy完全是在用户态(Java层面)的,它的Zero-copy的更多的是偏向于优化数据操作这样的概念。

Netty的Zero-copy体现在如下几个个方面:

  • Netty提供了CompositeByteBuf类,它可以将多个ByteBuf合并为一个逻辑上的ByteBuf,避免了各个ByteBuf之间的拷贝。
  • 通过wrap操作,我们可以将byte[]数组、ByteBuf、 ByteBuffer 等包装成一个 Netty ByteBuf对象,进而避免了拷贝操作。
  • ByteBuf支持slice 操作,因此可以将ByteBuf分解为多个共享同一个存储区域的ByteBuf,避免了内存的拷贝。
  • 通过FileRegion包装的FileChannel.tranferTo实现文件传输,可以直接将文件缓冲区的数据发送到目标Channel,避免了传统通过循环write方式导致的内存拷贝问题。

认真阅读的读者,一定能够知道:
前三个都是 广义零拷贝,都是减少不必要数据copy;偏向于应用层数据优化的操作。
FileRegion包装的FileChannel.tranferTo,才是真正的零拷贝。下面我们分别来看其每一种实现。
下面的分析,也不会刻意区别广义零拷贝和狭义零拷贝,读者只需要了解二者的区别,及其各自的实现对我们应用程序的影响即可。

通过CompositeByteBuf实现零拷贝
  • 将多个ByteBuf合并为一个逻辑上的ByteBuf,简单理解就是类似于用一个链表,把分散的多个ByteBuf通过引用连接起来;
  • 分散的多个ByteBuf在内存中可能是大小各异、互不相连的区域,通过链表串联起来,作为一整块逻辑上的大区域。
  • 而在实际数据读取时,还是会去各自每一小块上读取。
通过wrap操作实现零拷贝
  • 将byte[]数组、ByteBuf、 ByteBuffer 等包装成一个 Netty ByteBuf对象;
  • 这个比较简单,看过ByteBuf源码的同学一定会知道,ByteBuf其实就是组合(包含)了byte[];
  • 通过 Unpooled.wrappedBuffer 方法来将 bytes 包装成为一个 UnpooledHeapByteBuf 对象, 而在包装的过程中, 是不会有拷贝操作的. 即最后我们生成的生成的 ByteBuf 对象是和 bytes 数组共用了同一个存储空间, 对 bytes 的修改也会反映到 ByteBuf 对象中.
通过slice操作实现零拷贝
  • 将ByteBuf分解为多个共享同一个存储区域的ByteBuf
  • slice恰好是将一整块区域,划分成逻辑上独立的小区域;
  • 在读取每个逻辑小区域时,实际会去按slice(int index, int length) index和length去读取原内存buffer的数据。
通过FileRegion实现零拷贝
  • FileRegion包装的FileChannel.tranferTo实现文件传输,可以直接将文件缓冲区的数据发送到目标Channel;
  • 这是操作系统级别的零拷贝

扩展阅读 https://pdfs.semanticscholar.org/6a35/60046cb8d3258669c86072a7cab05e1d2300.pdf

推荐阅读更多精彩内容