您的位置首页>企业动态>

深度解析Linux中零拷贝原理

导读大家好,我是极客范的本期栏目编辑小友,现在为大家讲解深度解析Linux中零拷贝原理问题。什么是零拷贝?关于零拷贝,维基上给出的定义如下

大家好,我是极客范的本期栏目编辑小友,现在为大家讲解深度解析Linux中零拷贝原理问题。

什么是零拷贝?

关于零拷贝,维基上给出的定义如下:

「零拷贝」描述的是中央处理器不执行将数据从一个内存区域拷贝到另一个内存区域的任务的计算机操作。当通过网络传输文件时,这通常用于节省CPU周期和内存带宽。

所谓“零拷贝”是指在计算机操作系统中,CPU不执行将数据从一个内存区域拷贝到另一个内存区域的任务。当通过网络传输文件时,这通常会节省CPU周期和内存带宽。

从描述中,我们了解到零拷贝技术的优势:

1.节省了CPU周期,腾出的CPU可以完成更多的其他任务。

2.减少内存区域之间的数据复制,节省内存带宽。

3.减少用户模式和内核模式之间的数据复制,提高数据传输效率。

4.应用零拷贝技术减少用户状态和内核状态之间的上下文切换。

传统io数据复制原理

在正式分析零拷贝机制的原理之前,我们先来看看传统IO在数据拷贝方面的基本原理,对比分析数据拷贝(I/O拷贝)和上下文切换的次数。

传统IO:

1.JVM进程启动read()系统调用,操作系统从用户状态空间切换到内核状态空间(第一个上下文切换)。

2.通过DMA引擎将数据从磁盘复制到内核状态空间的输入套接字缓冲区(第一次复制)

3.将内核态空间缓冲区中的数据原封不动地复制到用户态空间缓冲区中(第二次复制)。同时,内核状态空间切换到用户状态空间(第二个上下文切换),read()系统调用结束。

4.JVM进程中业务逻辑代码的执行

5.在JVM进程中启动write()系统调用

6.操作系统从用户状态空间切换到内核状态空间(第三次上下文切换),并将用户状态空间的缓冲区数据完整地复制到内核状态空间输出的套接字缓冲区中(第三次复制)。

7.当write()系统调用返回时,操作系统从内核状态空间切换到用户状态空间(第四次上下文切换),DMA引擎将内核状态空间中套接字缓冲区的数据复制到协议引擎(第四次复制)。

在传统的IO模式下,用户状态空间和内核状态空间之间有四次上下文切换和四次数据复制,包括两个DMA副本和两个I/O副本(内核状态和用户应用程序之间的副本)。

内核空间缓冲区的主要用途之一是减少磁盘输入/输出操作,因为它将从磁盘预读取更多的数据到缓冲区。使用BufferedInputStream的目的是减少“系统调用”。

什么是DMA?

DMA (Direct Memory Access): DMA允许外围组件在没有CPU参与的情况下直接将I/O数据传输到主内存,从而释放CPU来做其他事情。

发送文件数据零拷贝原则

发送文件数据零拷贝:

显然,在传统IO中,用户状态空间和内核状态空间之间的关系

复制是完全不必要的,因为用户态空间仅仅起到了一种数据转存媒介的作用,除此之外没有做任何事情。

Linux 提供了 sendfile() 用来减少我们前面提到的数据拷贝和的上下文切换次数。

如下图所示:

1、发起 sendfile() 系统调用,操作系统由用户态空间切换到内核态空间(第一次上下文切换)

2、通过 DMA 引擎将数据从磁盘拷贝到内核态空间的输入的 socket 缓冲区中(第一次拷贝)

3、将数据从内核空间拷贝到与之关联的 socket 缓冲区(第二次拷贝)

4、将 socket 缓冲区的数据拷贝到协议引擎中(第三次拷贝)

5、sendfile() 系统调用结束,操作系统由用户态空间切换到内核态空间(第二次上下文切换)

根据以上过程,一共有 2 次的上下文切换,3 次的 I/O 拷贝。我们看到从用户空间到内核空间并没有出现数据拷贝,从操作系统角度来看,这个就是零拷贝。内核空间出现了复制的原因: 通常的硬件在通过DMA访问时期望的是连续的内存空间。

支持 scatter-gather 特性的 sendfile 数据零拷贝:

这次相比 sendfile() 数据零拷贝,减少了一次从内核空间到与之相关的 socket 缓冲区的数据拷贝。

基本流程:

1、发起 sendfile() 系统调用,操作系统由用户态空间切换到内核态空间(第一次上下文切换)

2、通过 DMA 引擎将数据从磁盘拷贝到内核态空间的输入的 socket 缓冲区中(第一次拷贝)

3、将描述符信息会拷贝到相应的 socket 缓冲区当中,该描述符包含了两方面的信息:

a) kernel buffer的内存地址;

b) kernel buffer的偏移量。

4、DMA gather copy 根据 socket 缓冲区中描述符提供的位置和偏移量信息直接将内核空间缓冲区中的数据拷贝到协议引擎上(第二次拷贝),这样就避免了最后一次 I/O 数据拷贝。

5、sendfile() 系统调用结束,操作系统由用户态空间切换到内核态空间(第二次上下文切换)

下面这个图更进一步理解:

  Linux/Unix 操作系统下可以通过下面命令查看是否支持 scatter-gather 特性。

  ethtool -k eth0 | grep scatter-gatherscatter-gather: on

  许多的 web server 都已经支持了零拷贝技术,比如 Apache、Tomcat。

  sendfile 零拷贝消除了所有内核空间缓冲区与用户空间缓冲区之间的数据拷贝过程,因此 sendfile 零拷贝 I/O 的实现是完成在内核空间中完成的,这对于应用程序来说就无法对数据进行操作了。

  mmap 数据零拷贝原理

  如果需要对数据做操作,Linux 提供了mmap 零拷贝来实现。

  mmap 零拷贝:

  

  通过上图看到,一共发生了 4 次的上下文切换,3 次的 I/O 拷贝,包括 2 次 DMA 拷贝和 1 次的 I/O 拷贝,相比于传统 IO 减少了一次 I/O 拷贝。使用 mmap() 读取文件时,只会发生第一次从磁盘数据拷贝到 OS 文件系统缓冲区的操作。

  1)在什么场景下使用 mmap() 去访问文件会更高效?

  对文件执行随机访问时,如果使用 read() 或 write(),则意味着较低的 cache 命中率。这种情况下使用 mmap() 通常将更高效。

  多个进程同时访问同一个文件时(无论是顺序访问还是随机访问),如果使用mmap(),那么操作系统缓冲区的文件内容可以在多个进程之间共享,从操作系统角度来看,使用 mmap() 可以大大节省内存。

  2)什么场景下没有使用 mmap() 的必要?

  访问小文件时,直接使用 read() 或 write() 将更加高效。

  单个进程对文件执行顺序访问时 (sequenTIal access),使用 mmap() 几乎不会带来性能上的提升。譬如说,使用 read() 顺序读取文件时,文件系统会使用 read-ahead 的方式提前将文件内容缓存到文件系统的缓冲区,因此使用 read() 将很大程度上可以命中缓存。

  Java 中 NIO 零拷贝实现

  Java NIO 中的通道(Channel)相当于操作系统的内核空间(kernel space)的缓冲区,而缓冲区(Buffer)对应的相当于操作系统的用户空间(user space)中的用户缓冲区(user buffer)。

  通道(Channel)是全双工的(双向传输),它既可能是读缓冲区(read buffer),也可能是网络缓冲区(socket buffer)。

  缓冲区(Buffer)分为堆内存(HeapBuffer)和堆外内存(DirectBuffer),这是通过 malloc() 分配出来的用户态内存。

  Java NIO 引入了用于通道的缓冲区的 ByteBuffer。

  ByteBuffer有三个主要的实现:

  1、HeapByteBuffer

  调用 ByteBuffer.allocate() 方法时使用到 HeapByteBuffer。这个缓存区域是在 JVM 进程的堆上分配的,可以获得如GC支持和缓存优化的优势。

  但它不是页面对齐的,这意味着若需通过JNI与本地代码交谈,JVM将不得不复制到对齐的缓冲区空间。

  2、DirectByteBuffer

  调用 ByteBuffer.allocateDirect() 方法时使用。 JVM 会使用 malloc() 在堆空间之外分配内存空间。 由于它的内存空间不由 JVM 管理,所以你的内存空间是页面对齐的,不受GC影响。但需要自己管理这个内存,注意分配和释放内存来防止内存泄漏。

  3、MappedByteBuffer

  调用 FileChannel.map() 时使用。与DirectByteBuffer类似,这也是 JVM 堆外部分配内存空间。它基本上作为操作系统 mmap() 系统调用的包装函数,以便代码直接操作映射的物理内存数据。

  Java IO 与 NIO 实战案例分析

  下面我们通过代码示例来对比下传统 IO 与使用了零拷贝技术的 NIO 之间的差异。

  我们通过服务端开启 socket 监听,然后客户端连接的服务端进行数据的传输,数据传输文件大小为 237M。

  零拷贝技术的 NIO,这里咱们通过刚刚介绍的 HeapByteBuffer 来实战对比一下。

  1、构建传统IO的socket服务端,监听8898端口。

  public class OldIOServer {

  public staTIc void main(String[] args) throws ExcepTIon {

  try (ServerSocket serverSocket = new ServerSocket(8898)) {

  while (true) {

  Socket socket = serverSocket.accept();

  DataInputStream inputStream = new DataInputStream(socket.getInputStream());

  byte[] bytes = new byte[4096];

  // 从socket中读取字节数据

  while (true) {

  // 读取的字节数大小,-1则表示数据已被读完

  int readCount = inputStream.read(bytes, 0, bytes.length);

  if (-1 == readCount) {

  break;

  }

  }

  }

  }

  }

  }

  2、构建传统 IO 的客户端,连接服务端的 8898 端口,并从磁盘读取 237M 的数据文件向服务端 socket 中发起写请求。

  public class OldIOClient {

  public static void main(String[] args) throws Exception {

  Socket socket = new Socket();

  socket.connect(new InetSocketAddress(“localhost”, 8898)); // 连接服务端socket 8899端口

  // 设置一个大的文件, 237M

  try (FileInputStream fileInputStream = new FileInputStream(new File(“/Users/david/Downloads/jdk-8u144-macosx-x64.dmg”));

  // 定义一个输出流

  DataOutputStream dataOutputStream = new DataOutputStream(socket.getOutputStream());) {

  // 读取文件数据

  // 定义byte缓存

  byte[] buffer = new byte[4096];

  int readCount; // 每一次读取的字节数

  int total = 0; // 读取的总字节数

  long startTime = System.currentTimeMillis();

  while ((readCount = fileInputStream.read(buffer)) 》 0) {

  total += readCount; //累加字节数

  dataOutputStream.write(buffer); // 写入到输出流中

  }

  System.out.println(“发送的总字节数:” + total + “, 耗时:” + (System.currentTimeMillis() - startTime));

  }

  }

  }

  运行结果:发送的总字节数:237607747,耗时:450 (400~600毫秒之间)

  接下来,我们通过使用 JDK 提供的 NIO 的方式实现数据传输与上述传统 IO 做对比。

  1、构建基于 NIO 的服务端,监听 8899 端口。

  public class NewIOServer {

  public static void main(String[] args) throws Exception {

  ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();

  serverSocketChannel.bind(new InetSocketAddress(8899));

  ByteBuffer byteBuffer = ByteBuffer.allocate(4096);

  while (true) {

  SocketChannel socketChannel = serverSocketChannel.accept();

  socketChannel.configureBlocking(false); // 这里设置为阻塞模式

  int readCount = socketChannel.read(byteBuffer);

  while (-1 != readCount) {

  readCount = socketChannel.read(byteBuffer);

  // 这里一定要调用下rewind方法,将position重置为0开始位置

  byteBuffer.rewind();

  }

  }

  }

  }

  2、构建基于 NIO 的客户端,连接NIO的服务端 8899 端口,通过

  FileChannel.transferTo 传输 237M 的数据文件。

  public class NewIOClient {

  public static void main(String[] args) throws Exception {

  SocketChannel socketChannel = SocketChannel.open();

  socketChannel.connect(new InetSocketAddress(“localhost”, 8899));

  socketChannel.configureBlocking(true);

  String fileName = “/Users/david/Downloads/jdk-8u144-macosx-x64.dmg”;

  FileInputStream fileInputStream = new FileInputStream(fileName);

  FileChannel fileChannel = fileInputStream.getChannel();

  long startTime = System.currentTimeMillis();

  long transferCount = fileChannel.transferTo(0, fileChannel.size(), socketChannel); // 目标channel

  System.out.println(“发送的总字节数:” + transferCount + “,耗时:” + (System.currentTimeMillis() - startTime));

  fileChannel.close();

  }

  }

  运行结果:发送的总字节数:237607747,耗时:161(100到300毫秒之间)

  结合运行结果,基于 NIO 零拷贝技术要比传统 IO 传输效率高 3倍多。所以,后续当设计大文件数据传输时可以优先采用类似 NIO 的方式实现。

  这里我们使用了 FileChannel,其中调用的 transferTo() 方法将数据从 FileChannel传输到其他的 channel 中,如果操作系统底层支持的话 transferTo、transferFrom 会使用相关的零拷贝技术来实现数据的传输。所以,这里是否使用零拷贝必须依赖于底层的系统实现。

  FileChannel.transferTo 方法:

  public abstract long transferTo(long position,

  long count,

  WritableByteChannel target) throws IOException

  将字节从此通道的文件传输到给定的可写入字节通道。

  试图读取从此通道的文件中给定 position 处开始的 count 个字节,并将其写入目标通道。

  此方法的调用不一定传输所有请求的字节;

  是否传输取决于通道的性质和状态。

  如果此通道的文件从给定的 position 处开始所包含的字节数小于 count 个字节,或者如果目标通道是非阻塞的并且其输出缓冲区中的自由空间少于 count 个字节,则所传输的字节数要小于请求的字节数。

  此方法不修改此通道的位置。

  如果给定的位置大于该文件的当前大小,则不传输任何字节。

  如果目标通道中有该位置,则从该位置开始写入各字节,然后将该位置增加写入的字节数。

  与从此通道读取并将内容写入目标通道的简单循环语句相比,此方法可能高效得多。

  很多操作系统可将字节直接从文件系统缓存传输到目标通道,而无需实际复制各字节。

  参数:

  position - 文件中的位置,从此位置开始传输;

  必须为非负数

  count - 要传输的最大字节数;

  必须为非负数

  target - 目标通道

  返回:实际已传输的字节数,可能为零

  FileChannel.transferFrom 方法:

  public abstract long transferFrom(ReadableByteChannel src,

  long position,

  long count) throws IOException

  将字节从给定的可读取字节通道传输到此通道的文件中。

  试着从源通道中最多读取 count 个字节,并将其写入到此通道的文件中从给定 position 处开始的位置。

  此方法的调用不一定传输所有请求的字节;

  是否传输取决于通道的性质和状态。

  如果源通道的剩余空间小于 count 个字节,或者如果源通道是非阻塞的并且其输入缓冲区中直接可用的空间小于 count 个字节,则所传输的字节数要小于请求的字节数。

  此方法不修改此通道的位置。

  如果给定的位置大于该文件的当前大小,则不传输任何字节。

  如果该位置在源通道中,则从该位置开始读取各字节,然后将该位置增加读取的字节数。

  与从源通道读

  取并将内容写入此通道的简单循环语句相比,此方法可能高效得多。

  很多操作系统可将字节直接从源通道传输到文件系统缓存,而无需实际复制各字节。

  参数:

  src - 源通道

  position - 文件中的位置,从此位置开始传输;

  必须为非负数

  count - 要传输的最大字节数;

  必须为非负数

  返回:实际已传输的字节数,可能为零

  发生相应的异常的情况:

  异常抛出:

  IllegalArgumentException - 如果关于参数的前提不成立

  NonReadableChannelException - 如果不允许从此通道进行读取操作

  NonWritableChannelException - 如果目标通道不允许进行写入操作

  ClosedChannelException - 如果此通道或目标通道已关闭

  AsynchronousCloseException - 如果正在进行传输时另一个线程关闭了任一通道

  ClosedByInterruptException - 如果正在进行传输时另一个线程中断了当前线程,因此关闭了两个通道并将当前线程设置为中断

  IOException - 如果发生其他 I/O 错误

郑重声明:本文版权归原作者所有,转载文章仅为传播更多信息之目的,如作者信息标记有误,请第一时间联系我们修改或删除,多谢。