Java IO篇:什么是零拷贝?

x33g5p2x  于2022-02-07 转载在 Java  
字(4.0k)|赞(0)|评价(0)|浏览(479)

一、传统的IO模式:

  • (1)用户空间的应用程序发出read系统调用,会导致用户空间到内核空间的上下文切换,然后再通过 DMA 将磁盘文件中的数据读取到内核空间缓冲区
  • (2)接着将内核空间缓冲区的数据拷贝到用户空间的数据缓冲区,然后read系统调用返回,而系统调用的返回又会导致一次内核空间到用户空间的上下文切换
  • (3)write系统调用,用户空间到内核空间的上下文再次切换;接着将用户空间缓冲区的数据复制到内核空间的 socket 缓冲区(也是内核缓冲区,只不过是给socket使用),然后write系统调用返回,再次触发上下文切换
  • (4)最后异步传输socket缓冲区的数据到网卡,也就是说write系统调用的返回并不保证数据被传输到网卡

在传统的数据 IO 模式中,读取一个磁盘文件,并发送到远程端的服务,就共有四次用户空间与内核空间的上下文切换,四次数据复制,分别是两次 CPU 数据复制,两次 DMA 数据复制。但两次 CPU 数据复制才是最消耗资源和时间的,这个过程还需要内核态和用户态之间的来回切换,而CPU资源十分宝贵,要拷贝大量的数据,还要处理大量的任务,如果能把 CPU 的这两次拷贝给去除掉,既能节省CPU资源,还可以避免内核态和用户态之间的切换。而零拷贝技术就是为了解决这个问题
DMA(Direct Memory Access,直接存储器存取)方式: 外部设备不通过CPU而直接与系统内存进行数据交换的技术

二、什么是零拷贝:

零拷贝指在进行数据 IO 或传输时,数据在用户态下经历了零次拷贝,并非不拷贝数据。通过减少数据传输过程中 内核缓冲区和用户进程缓冲区间不必要的 CPU数据拷贝 与 用户态和内核态的上下文切换次数,降低 CPU 在这两方面的开销,释放 CPU 执行其他任务,更有效的利用系统资源,提高传输效率,同时还减少了内存的占用,也提升应用程序的性能。

由于零拷贝在内核空间中完成所有的内存拷贝,可以最大化使用 socket 缓冲区的可用空间,从而提高了一次系统调用中处理的数据量,进一步降低了上下文切换次数。零拷贝技术基于 PageCache,而 PageCache 缓存了最近访问过的数据,提升了访问缓存数据的性能,同时,为了解决机械磁盘寻址慢的问题,它还协助 IO 调度算法实现了 IO 合并与预读(这也是顺序读比随机读性能好的原因),这进一步提升了零拷贝的性能。

三、Linux 中的零拷贝方式:

1、mmap + write 实现的零拷贝:

#include <sys/mman.h>
void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset)

在传统 IO 模式的4次内存拷贝中,与物理设备相关的2次拷贝(把磁盘内容拷贝到内存 以及 把内存拷贝到网卡)是必不可少的。但与用户缓冲区相关的2次拷贝都不是必需的,如果内核在读取文件后,直接把内核缓冲区中的内容拷贝到 Socket 缓冲区,待到网卡发送完毕后,再通知进程,这样就可以减少一次 CPU 数据拷贝了。而内存映射 mmap就是通过前面介绍的方式实现零拷贝的,它的核心就是操作系统会把内核缓冲区与应用程序共享,可以将一段用户空间内存映射到内核空间,当映射成功后,用户对这段内存区域的修改可以直接反映到内核空间;同样地,内核空间对这段区域的修改也直接反映用户空间。正因为有这样的映射关系, 就不需要在用户态(User-space)与内核态(Kernel-space) 之间拷贝数据, 提高了数据传输的效率,这就是内存直接映射技术。具体示意图如下:

  • (1)发出 mmap 系统调用,导致用户空间到内核空间的上下文切换;然后通过 DMA 将磁盘文件中的数据复制到内核空间缓冲区
  • (2)mmap 系统调用返回,导致内核空间到用户空间的上下文切换
  • (3)这里不需要将数据从内核空间复制到用户空间,因为用户空间和内核空间共享了这个缓冲区
  • (4)发出 write 系统调用,导致用户空间到内核空间的上下文切换。将数据从内核空间缓冲区复制到内核空间 socket 缓冲区;write 系统调用返回,导致内核空间到用户空间的上下文切换
  • (5)DMA 异步将 socket 缓冲区中的数据拷贝到网卡

**        mmap 的零拷贝 I/O 进行了4次用户空间与内核空间的上下文切换,以及3次数据拷贝;其中3次数据拷贝中包括了2次 DMA 拷贝和1次 CPU 拷贝**

2、sendfile 实现的零拷贝:

#include <sys/sendfile.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

只要我们的代码执行 read 或者 write 这样的系统调用,一定会发生 2 次上下文切换:首先从用户态切换到内核态,当内核执行完任务后,再切换回用户态交由进程代码执行。因此,如果想减少上下文切换次数,就一定要减少系统调用的次数,解决方案就是把 read、write 两次系统调用合并成一次,在内核中完成磁盘与网卡的数据交换。在 Linux 2.1 版本内核开始引入的 sendfile 就是通过这种方式来实现零拷贝的,具体流程图如下:

  • (1)发出 sendfile 系统调用,导致用户空间到内核空间的上下文切换;然后通过 DMA 将磁盘文件中的内容复制到内核空间缓冲区中,
  • (2)接着再将数据从内核空间缓冲区复制到 socket 缓冲区
  • (3)sendfile 系统调用返回,导致内核空间到用户空间的上下文切换
  • (4)DMA 异步将内核空间 socket 缓冲区中的数据传递到网

**        通过 sendfile 实现的零拷贝I/O使用了2次用户空间与内核空间的上下文切换,以及3次数据的拷贝。其中3次数据拷贝中包括了2次DMA拷贝和1次CPU拷贝**

3、带有 DMA 收集拷贝功能的 sendfile 实现的零拷贝:

Linux 2.4 版本开始支持,操作系统提供 scatter 和 gather 的 SG-DMA 方式,直接从内核空间缓冲区中将数据读取到网卡,无需将内核空间缓冲区的数据再复制一份到 socket 缓冲区。

  •  (1)发出 sendfile 系统调用,导致用户空间到内核空间的上下文切换;接着通过 DMA 将磁盘文件中的内容复制到内核空间缓冲区
  • (2)这里没把数据复制到 socket 缓冲区,而是将相应的描述符信息被复制到 socket 缓冲区,该描述符包含了两种的信息:①内核缓冲区的内存地址、②内核缓冲区的偏移量
  • (3)sendfile 系统调用返回,导致内核空间到用户空间的上下文切换。
  • (4)DMA 根据 socket 缓冲区中描述符提供的地址和偏移量直接将内核缓冲区中的数据复制到网卡

**        带有 DMA 收集拷贝功能的 sendfile 实现的 I/O 使用了2次用户空间与内核空间的上下文切换,以及2次数据的拷贝,而且这2次的数据拷贝都是非CPU拷贝,这样就实现了最理想的零拷贝I/O传输了,不需要任何一次的CPU拷贝,以及最少的上下文切换** ​​​​​​​
备注:需要注意的是,零拷贝有一个缺点,就是不允许进程对文件内容作一些加工再发送,比如数据压缩后再发送。

四、零拷贝技术的应用场景:

1、Java 的 NIO:

(1)mmap + write 的零拷贝方式:

FileChannel 的 map() 方法产生的 MappedByteBuffer:FileChannel 提供了 map() 方法,该方法可以在一个打开的文件和 MappedByteBuffer 之间建立一个虚拟内存映射,MappedByteBuffer 继承于 ByteBuffer;该缓冲器的内存是一个文件的内存映射区域。map() 方法底层是通过 mmap 实现的,因此将文件内存从磁盘读取到内核缓冲区后,用户空间和内核空间共享该缓冲区。

(2)sendfile 的零拷贝方式:

FileChannel 的 transferTo、transferFrom 如果操作系统底层支持的话,transferTo、transferFrom也会使用 sendfile 零拷贝技术来实现数据的传输

2、Netty 框架:

Netty 的零拷贝主要体现在下面五个方面:

(1)在网络通信上,Netty 的接收和发送 ByteBuffer 采用直接内存,使用堆外直接内存进行 Socket 读写,不需要进行字节缓冲区的二次拷贝。如果使用传统的堆内存进行 Socket 读写,JVM 会将堆内存 Buffer 拷贝一份到直接内存中(为什么拷贝?因为 JVM 会发生 GC 垃圾回收,数据的内存地址会发生变化,直接将堆内的内存地址传给内核,内存地址一旦变了就内核读不到数据了),然后才写入 Socket 中。相比于堆外直接内存,消息在发送过程中多了一次缓冲区的内存拷贝。

(2)在文件传输上,Netty 的通过 FileRegion 包装的 FileChannel.tranferTo 实现文件传输,它可以直接将文件缓冲区的数据发送到目标 Channel,避免了传统通过循环 write 方式导致的内存拷贝问题。

(3)在缓存操作上,Netty 提供了CompositeByteBuf 类,它可以将多个 ByteBuf 合并为一个逻辑上的 ByteBuf,避免了各个 ByteBuf 之间的拷贝。

(4)通过 wrap 操作,我们可以将byte[]数组、ByteBuf、ByteBuffer等包装成一个Netty ByteBuf对象,进而避免了拷贝操作。

(5)ByteBuf 支持 slice 操作,因此可以将 ByteBuf 分解为多个共享同一个存储区域的 ByteBuf,避免了内存的拷贝。

3、kafka:

Kafka 的索引文件使用的是 mmap + write 方式,数据文件使用的是 sendfile 方式

参考文章:

(重点推荐)https://juejin.cn/post/6887469050515947528

https://juejin.cn/post/6854573213452599310#heading-8

https://blog.csdn.net/u022812849/article/details/109805403

相关文章