详解Linux中的零拷贝技术,小白也能秒懂!

前言

大白话解释,零拷贝就是没有把数据从一个存储区域拷贝到另一个存储区域。但是没有数据的复制,怎么可能实现数据的传输呢?

其实我们在java NIO、netty、kafka遇到的零拷贝,并不是不复制数据,而是减少不必要的数据拷贝次数,从而提升代码性能

本文内容:

1、零拷贝给我们带来的好处:

2、Linux系统的“用户空间”和“内核空间”

3、Linux 中零拷贝技术的实现方向

4、零拷贝机制的原理

4.1、传统I/O

4.2、DMA

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

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

7、"传统I/O” VS “sendfile零拷贝I/O”

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

9、FileChannel与零拷贝

10、java提供的零拷贝方式


1、零拷贝给我们带来的好处:

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

2、Linux系统的“用户空间”和“内核空间”

  • 内核空间:Linux自身使用的空间;主要提供进程调度、内存分配、连接硬件资源等功能

  • 用户空间:提供给各个程序进程的空间;用户空间不具有访问内核空间资源的权限,如果应用程序需要使用到内核空间的资源,则需要通过系统调用来完成:从用户空间切换到内核空间,完成相关操作后再从内核空间切换回用户空间

3、Linux 中零拷贝技术的实现方向

① 直接 I/O:对于这种数据传输方式来说,应用程序可以直接访问硬件存储,操作系统内核只是辅助数据传输。这种方式依旧存在用户空间和内核空间的上下文切换,但是硬件上的数据不会拷贝一份到内核空间,而是直接拷贝至了用户空间,因此直接I/O不存在内核空间缓冲区和用户空间缓冲区之间的数据拷贝。

② 在数据传输过程中,避免数据在用户空间缓冲区和系统内核空间缓冲区之间的CPU拷贝,以及数据在系统内核空间内的CPU拷贝。本文主要讨论的就是该方式下的零拷贝机制。

③ copy-on-write(写时复制技术):在某些情况下,Linux操作系统的内核空间缓冲区可能被多个应用程序所共享,操作系统有可能会将用户空间缓冲区地址映射到内核空间缓存区中。当应用程序需要对共享的数据进行修改的时候,才需要真正地拷贝数据到应用程序的用户空间缓冲区中,并且对自己用户空间的缓冲区的数据进行修改不会影响到其他共享数据的应用程序。所以,如果应用程序不需要对数据进行任何修改的话,就不会存在数据从系统内核空间缓冲区拷贝到用户空间缓冲区的操作。

注意,对于各种零拷贝机制是否能够实现都是依赖于操作系统底层是否提供相应的支持。

4、零拷贝机制的原理

下面我们通过一个Java非常常见的应用场景:将系统中的文件发送到远端(该流程涉及:磁盘上文件 ——> 内存(字节数组) ——> 传输给用户/网络)来详细展开传统I/O操作和通过零拷贝来实现的I/O操作。

4.1、传统I/O

#include <unistd>
ssize_t write(int filedes, void *buf, size_t nbytes);
ssize_t read(int filedes, void *buf, size_t nbytes);
  • 如java在linux系统上,读取一个磁盘文件,并发送到远程端的服务

  • 1)发出read系统调用,会导致用户空间到内核空间的上下文切换,然后再通过DMA将文件中的数据从磁盘上读取到内核空间缓冲区

  • 2)接着将内核空间缓冲区的数据拷贝到用户空间进程内存,然后read系统调用返回。而系统调用的返回又会导致一次内核空间到用户空间的上下文切换

  • 3)write系统调用,则再次导致用户空间到内核空间的上下文切换,将用户空间的进程里的内存数据复制到内核空间的socket缓冲区(也是内核缓冲区,不过是给socket使用的),然后write系统调用返回,再次触发上下文切换

  • 4)至于socket缓冲区到网卡的数据传输则是独立异步的过程,也就是说write系统调用的返回并不保证数据被传输到网卡

Q:你可能会问独立和异步这是什么意思?难道是调用会在数据被传输前返回?
A:事实上调用的返回并不保证数据被传输;它甚至不保证传输的开始。它只是意味着将我们要发送的数据放入到了一个待发送的队列中,在我们之前可能有许多数据包在排队。除非驱动器或硬件实现优先级环或队列,否则数据是以先进先出的方式传输的。

总的来说,传统的I/O操作进行了4次用户空间与内核空间的上下文切换,以及4次数据拷贝。其中4次数据拷贝中包括了2次DMA拷贝和2次CPU拷贝。

Q: 传统I/O模式为什么将数据从磁盘读取到内核空间缓冲区,然后再将数据从内核空间缓冲区拷贝到用户空间缓冲区了?为什么不直接将数据从磁盘读取到用户空间缓冲区就好?
A: 传统I/O模式之所以将数据从磁盘读取到内核空间缓冲区而不是直接读取到用户空间缓冲区,是为了减少磁盘I/O操作以此来提高性能。因为OS会根据局部性原理在一次read()系统调用的时候预读取更多的文件数据到内核空间缓冲区中,这样当下一次read()系统调用的时候发现要读取的数据已经存在于内核空间缓冲区中的时候只要直接拷贝数据到用户空间缓冲区中即可,无需再进行一次低效的磁盘I/O操作(注意:磁盘I/O操作的速度比直接访问内存慢了好几个数量级)。
Q: 既然系统内核缓冲区能够减少磁盘I/O操作,那么我们经常使用的BufferedInputStream缓冲区又是用来干啥的?
A: BufferedInputStream的作用是会根据情况自动为我们预取更多的数据到它自己维护的一个内部字节数据缓冲区中,这样做能够减少系统调用的次数以此来提供性能。

总的来说内核空间缓冲区的一大用处是为了减少磁盘I/O操作,因为它会从磁盘中预读更多的数据到缓冲区中。而BufferedInputStream的用处是减少“系统调用”。

4.2、DMA

DMA(Direct Memory Access) ———— 直接内存访问 :DMA是允许外设组件将I/O数据直接传送到主存储器中并且传输不需要CPU的参与,以此将CPU解放出来去完成其他的事情。
而用户空间与内核空间之间的数据传输并没有类似DMA这种可以不需要CPU参与的传输工具,因此用户空间与内核空间之间的数据传输是需要CPU全程参与的。所有也就有了通过零拷贝技术来减少和避免不必要的CPU数据拷贝过程。
 

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

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

  • 1)发出sendfile系统调用,导致用户空间到内核空间的上下文切换,然后通过DMA引擎将磁盘文件中的内容复制到内核空间缓冲区中,接着再将数据从内核空间缓冲区复制到socket相关的缓冲区

  • 2)sendfile系统调用返回,导致内核空间到用户空间的上下文切换。DMA异步将内核空间socket缓冲区中的数据传递到网卡

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

Q:但通过是这里还是存在着一次CPU拷贝操作,即,kernel buffer ——> socket buffer。是否有办法将该拷贝操作也取消掉了?
A:有的。但这需要底层操作系统的支持。从Linux 2.4版本开始,操作系统底层提供了scatter/gather这种DMA的方式来从内核空间缓冲区中将数据直接读取到协议引擎中,而无需将内核空间缓冲区中的数据再拷贝一份到内核空间socket相关联的缓冲区中。
 

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

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

  • 1)发出sendfile系统调用,导致用户空间到内核空间的上下文切换。通过DMA引擎将磁盘文件中的内容复制到内核空间缓冲区
  • 2)这里没把数据复制到socket缓冲区;取而代之的是,相应的描述符信息被复制到socket缓冲区。该描述符包含了两种的信息:A)内核缓冲区的内存地址、B)内核缓冲区的偏移量

  • 3)sendfile系统调用返回,导致内核空间到用户空间的上下文切换。DMA根据socket缓冲区的描述符提供的地址和偏移量直接将内核缓冲区中的数据复制到网卡

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

关于sendfile:

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

在linux2.6.33版本之前 sendfile指支持文件到套接字之间传输数据,即in_fd相当于一个支持mmap的文件,out_fd必须是一个socket。但从linux2.6.33版本开始,out_fd可以是任意类型文件描述符。所以从linux2.6.33版本开始sendfile可以支持“文件到文件”和“文件到套接字”之间的数据传输。
 

7、"传统I/O” VS “sendfile零拷贝I/O”

  • 传统I/O通过两条系统指令read、write来完成数据的读取和传输操作,以至于产生了4次用户空间与内核空间的上下文切换的开销;而sendfile只使用了一条指令就完成了数据的读写操作,所以只产生了2次用户空间与内核空间的上下文切换。
  • 传统I/O产生了2次无用的CPU拷贝,即内核空间缓存中数据与用户空间缓冲区间数据的拷贝;而sendfile最多只产出了一次CPU拷贝,即内核空间内之间的数据拷贝,甚至在底层操作体系支持的情况下,sendfile可以实现零CPU拷贝的I/O。
  • 因传统I/O用户空间缓冲区中存有数据,因此应用程序能够对此数据进行修改等操作;而sendfile零拷贝消除了所有内核空间缓冲区与用户空间缓冲区之间的数据拷贝过程,因此sendfile零拷贝I/O的实现是完成在内核空间中完成的,这对于应用程序来说就无法对数据进行操作了。

Q:对于上面的第三点,如果我们需要对数据进行操作该怎么办了?
A:Linux提供了mmap零拷贝来实现我们的需求。
 

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

mmap(内存映射)是一个比sendfile昂贵但优于传统I/O的方法。

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

  • 1)发出mmap系统调用,导致用户空间到内核空间的上下文切换。然后通过DMA引擎将磁盘文件中的数据复制到内核空间缓冲区

  • 2)mmap系统调用返回,导致内核空间到用户空间的上下文切换

  • 3)这里不需要将数据从内核空间复制到用户空间,因为用户空间和内核空间共享了这个缓冲区

  • 4)发出write系统调用,导致用户空间到内核空间的上下文切换。将数据从内核空间缓冲区复制到内核空间socket缓冲区;write系统调用返回,导致内核空间到用户空间的上下文切换

  • 5)异步,DMA引擎将socket缓冲区中的数据copy到网卡

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

9、FileChannel与零拷贝

FileChannel中大量使用了我们上面所提及的零拷贝技术。
FileChannel的map方法会返回一个MappedByteBuffer。MappedByteBuffer是一个直接字节缓冲器,该缓冲器的内存是一个文件的内存映射区域。map方法底层是通过mmap实现的,因此将文件内存从磁盘读取到内核缓冲区后,用户空间和内核空间共享该缓冲区。
MappedByteBuffer内存映射文件是一种允许Java程序直接从内存访问的一种特殊的文件。我们可以将整个文件或者整个文件的一部分映射到内存当中,那么接下来是由操作系统来进行相关的页面请求并将内存的修改写入到文件当中。我们的应用程序只需要处理内存的数据,这样可以实现非常迅速的I/O操作。

FileChannel map的三种模式

  • 只读模式
/**
 * Mode for a read-only mapping.
 */
public static final MapMode READ_ONLY = new MapMode("READ_ONLY");

只读模式来说,如果程序试图进行写操作,则会抛出ReadOnlyBufferException异常

  • 读写模式
/**
 * Mode for a read/write mapping.
 */
public static final MapMode READ_WRITE = new MapMode("READ_WRITE");

读写模式表明,对结果对缓冲区所做的修改将最终广播到文件。但这个修改可能会也可能不会被其他映射了相同文件程序可见。

  • 专用模式
/**
 * Mode for a private (copy-on-write) mapping.
 */
public static final MapMode PRIVATE = new MapMode("PRIVATE");

私有模式来说,对结果缓冲区的修改将不会被广播到文件并且也不会对其他映射了相同文件的程序可见。取而代之的是,它将导致被修改部分缓冲区独自拷贝一份到用户空间。这便是OS的“copy on write”原则。

FileChannel的transferTo、transferFrom

如果操作系统底层支持的话transferTo、transferFrom也会使用相关的零拷贝技术来实现数据的传输。所以,这里是否使用零拷贝必须依赖于底层的系统实现。
 

10、java提供的零拷贝方式

  • java NIO的零拷贝实现是基于mmap+write方式

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

public void main(String[] args){
    try {
        FileChannel readChannel = FileChannel.open(Paths.get("./cscw.txt"), StandardOpenOption.READ);
        FileChannel writeChannel = FileChannel.open(Paths.get("./siting.txt"), StandardOpenOption.WRITE, StandardOpenOption.CREATE);
        MappedByteBuffer data = readChannel.map(FileChannel.MapMode.READ_ONLY, 0, 1024 * 1024 * 40);
        //数据传输
        writeChannel.write(data);
        readChannel.close();
        writeChannel.close();
    }catch (Exception e){
        System.out.println(e.getMessage());
    }
}
  • FileChannel的transferTo、transferFrom 如果操作系统底层支持的话,transferTo、transferFrom也会使用相关的零拷贝技术来实现数据的传输。用法如下
public void main(String[] args) {
    try {
        FileChannel readChannel = FileChannel.open(Paths.get("./cscw.txt"), StandardOpenOption.READ);
        FileChannel writeChannel = FileChannel.open(Paths.get("./siting.txt"), StandardOpenOption.WRITE, StandardOpenOption.CREATE);
        long len = readChannel.size();
        long position = readChannel.position();
        //数据传输
        readChannel.transferTo(position, len, writeChannel);
        //效果和transferTo 一样的
        //writeChannel.transferFrom(readChannel, position, len, );
        readChannel.close();
        writeChannel.close();
    } catch (Exception e) {
        System.out.println(e.getMessage());
    }
}

11、最后

本文是通过视频学习以及大量资料查询后对零拷贝机制进行的一个非常肤浅的知识梳理,至少个人是这么觉得。通过这次的学习,对Linux操作系统又多了一些了解。非常欢迎大家对文中的不足和错误进行指点~

  • 最后将学习资料分享给大家,都是免费的哦

学习资料:戳这里免费领取,暗号:CSDN,还有更多大厂面试专题资料和视频哦!

猜你喜欢

转载自blog.csdn.net/qq_43080036/article/details/109290905