参考文章:

零拷贝(Zero-Copy)概念

  • 零拷贝(Zero-Copy)是一种高效的数据传输技术,它可以将数据从内核空间直接传输到应用程序的内存空间中。
  • 零拷贝是提升 IO 操作性能的一个常用手段,像 ActiveMQ、Kafka 、RocketMQ、QMQ、Netty 等顶级开源项目都用到了零拷贝。

为什么需要零拷贝

在传统的 I/O 操作中,数据从磁盘传输到网络的过程中,通常需要经过多次数据拷贝和上下文切换。例如,数据需要从磁盘拷贝到内核缓冲区,再从内核缓冲区拷贝到用户缓冲区,最后从用户缓冲区拷贝到网络协议栈的缓冲区。这种频繁的拷贝和上下文切换显著降低了系统性能。零拷贝技术通过减少这些不必要的操作来提升效率。

传统数据传输方式

在传统的 I/O 操作中,数据从磁盘读取并通过网络发送到客户端,通常涉及以下步骤:

  1. 用户进程发出read()系统调用,触发上下文切换,从用户态转换到内核态。
  2. CPU发起IO请求,通过直接内存访问(DMA)从磁盘读取文件内容,复制到内核缓冲区PageCache。
  3. 将内核缓冲区数据,拷贝到用户空间缓冲区,触发上下文切换,从内核态转换到用户态。
  4. 用户进程发起write系统调用,触发上下文切换,从用户态切换到内核态。
  5. 将数据从用户缓冲区拷贝到内核中与目的地Socket关联的缓冲区。
  6. 数据最终经由Socket通过DMA传送到网卡缓冲区,write()系统调用返回,并从内核态切换回用户态。
文字详解

期间共发生了4次用户态与内核态的上下文切换,因为发生了两次系统调用,一次是read(),一次是write(),每次系统调用都得先从用户态切换到内核态,等内核完成任务后,再从内核态切换回用户态。
其次,还发生了4次数据拷贝,其中两次是DMA的拷贝,另外两次则是通过CPU拷贝的:

  • 第一次拷贝,把磁盘上的数据拷贝到操作系统内核的缓冲区里,这个拷贝的过程是通过DMA搬运的。
  • 第二次拷贝,把内核缓冲区的数据拷贝到用户的缓冲区里,于是我们应用程序就可以使用这部分数据了,这个拷贝到过程是由CPU完成的。
  • 第三次拷贝,把刚才拷贝到用户的缓冲区里的数据,再拷贝到内核的socket的缓冲区里,这个过程依然还是由CPU搬运的。
  • 第四次拷贝,把内核的socket缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程又是由DMA搬运的。

我们回过头看这个文件传输的过程,我们只是搬运一份数据,结果却搬运了4次,过多的数据拷贝无疑会消耗CPU资源,大大降低了系统性能。

这种简单又传统的文件传输方式,存在冗余的上下文切换和数据拷贝,在高并发系统里是非常糟糕的,多了很多不必要的开销,会严重影响系统性能。

所以,要想提高文件传输的性能,就需要减少「用户态与内核态的上下文切换」和「内存拷贝」的次数。

零拷贝技术的核心思想

零拷贝技术的目标是减少或消除数据在用户空间和内核空间之间的拷贝,从而降低 CPU 的负载,提高数据传输效率。其核心思想包括:

  • 共享内存映射:通过内存映射(mmap)机制,使用户空间和内核空间共享同一块物理内存,避免数据复制。
  • 直接数据传输:利用系统调用(如 sendfile、splice)直接在内核空间完成数据的读取和发送,绕过用户空间。

零拷贝技术的实现方式

  1. mmap + write
    mmap 是 Linux 提供的一种内存映射机制,它将文件映射到进程的地址空间,使得文件的内容可以像访问内存一样被访问。这样就减少了一次用户态和内核态的CPU拷贝,但是在内核空间内仍然有一次CPU拷贝。

操作流程:

  1. 映射文件到内存:用户进程通过mmap系统调用将文件映射到内存。

  2. DMA传输数据到内核缓冲区:DMA将磁盘上的数据拷贝到内核缓冲区。

  3. 建立用户空间和内核缓冲区的映射关系:内核建立用户空间的进程缓冲区和内核缓冲区的映射关系。

  4. 写入网络协议栈:用户进程通过write系统调用将映射的内核缓冲区数据写入网络协议栈的缓冲区。

  5. DMA传输数据到网卡:DMA将网络协议栈的缓冲区数据拷贝到网卡。

  6. sendfile
    sendfile 是 Linux2.1 提供的系统调用,用于在内核空间直接将文件数据发送到 socket,避免了数据在用户空间的拷贝。

操作流程:

  1. 系统调用:用户进程通过sendfile系统调用发起文件传输请求。这一步需要从用户态切换到内核态。
  2. DMA传输数据到内核缓冲区:DMA将磁盘上的数据拷贝到内核缓冲区。
  3. 内核拷贝数据到网络缓冲区:内核将内核缓冲区的数据拷贝到网络协议栈的缓冲区。
  4. DMA传输数据到网卡:DMA将网络协议栈的缓冲区数据拷贝到网卡。
  5. 系统调用返回:系统调用返回,从内核态切换到用户态。

该系统调用,可以直接把内核缓冲区里的数据拷贝到 socket 缓冲区里,不再拷贝到用户态,这样就只有 2 次上下文切换,和 3 次数据拷贝。

  1. sendfile + DMA Scatter/Gather
    上面的sendfile依然需要3次拷贝,这不是我们希望的真正的零拷贝技术,如果网卡支持SG-DMA(The-Scatter-Gather Direct Memory Access)技术(和普通的 DMA 有所不同),我们可以进一步减少通过 CPU 把内核缓冲区里的数据拷贝到 socket 缓冲区的过程,SG-DMA直接将内核缓冲区的数据传输到网卡。

于是,从 Linux 内核 2.4开始,sendfile()的调用发生了变化,具体流程如下

操作流程:

  1. 系统调用:用户进程通过sendfile系统调用发起文件传输请求。这一步需要从用户态切换到内核态。
  2. DMA传输数据到内核缓冲区:DMA将磁盘上的数据拷贝到内核缓冲区。
  3. DMA根据描述信息传输数据到网卡:DMA根据网络缓冲区中的数据描述信息,直接从内核缓冲区传输数据到网卡。
  4. 系统调用返回:系统调用返回,从内核态切换到用户态。

因为我们没有在内存层面去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的,相比传统文件传输的方式,减少了 2 次上下文切换和数据拷贝次数。

  1. splice
    splice() 调用通常用于管道(pipe)和套接字(socket)之间的数据传输,可以大幅提高数据传输的效率和性能。主要涉及两个文件描述符(file descriptor)之间的数据传输,其中一个是源文件描述符(source file descriptor),另一个是目标文件描述符(target file descriptor)。数据将从源文件描述符传输到目标文件描述符,或者反过来,而且在传输过程中数据不需要经过用户空间。

操作流程:

  1. 系统调用:用户进程通过splice系统调用将数据从文件系统传输到管道。这一步需要从用户态切换到内核态。
  2. DMA传输数据到内核缓冲区:DMA将磁盘上的数据拷贝到内核缓冲区。
  3. 建立内核缓冲区和网络缓冲区的管道:内核建立内核缓冲区和网络缓冲区的管道。
  4. DMA传输数据到网卡:DMA从管道读取数据并传输到网卡。
  5. 系统调用返回:系统调用返回,从内核态切换到用户态。

splice() 的主要优势在于减少了数据拷贝的次数和数据在用户空间和内核空间之间的来回传输,从而显著提高了数据传输的效率。但它也有一些局限,它的两个文件描述符参数中有一个必须是管道设备。

  1. tee
    tee() 系统调用的主要功能是将一个管道的数据复制到另一个管道中。与传统的 read() 和 write() 系统调用不同,tee() 并不将数据从内核空间复制到用户空间,而是直接在内核空间中进行操作,从而避免了不必要的数据拷贝。

    1. 用户进程发起管道传输请求:用户进程调用tee()系统调用,触发上下文切换,从用户态切换到内核态。
    2. DMA传输数据到内核缓冲区:DMA从磁盘读取数据到内核缓冲区。
    3. 内核复制数据到多个管道:内核将数据从源管道复制到目标管道,而不改变原有的数据。
    4. DMA传输数据到网卡:DMA从目标管道读取数据并传输到网卡,触发上下文切换,从内核态切换回用户态。

tee是一种灵活的零拷贝技术,特别适用于需要将数据流同时发送到多个目标的场景。它通过在内核空间中直接操作数据,避免了用户空间的拷贝,从而提高了数据传输的效率。

应用场景:

  • 多目标数据传输:适用于需要将同一数据流同时发送到多个目标的场景,比如同时处理日志和实时流数据。
  • 日志记录:在记录日志的同时,将数据发送到其他处理模块。
  • 实时监控:在实时监控数据流的同时,将数据发送到多个监控模块。

Java 对零拷贝的支持

  • MappedByteBuffer 是 NIO 基于内存映射(mmap)这种零拷⻉⽅式的提供的⼀种实现,底层实际是调用了 Linux 内核的 mmap 系统调用。它可以将一个文件或者文件的一部分映射到内存中,形成一个虚拟内存文件,这样就可以直接操作内存中的数据,而不需要通过系统调用来读写文件。
  • FileChannel 的transferTo()/transferFrom()是 NIO 基于发送文件(sendfile)这种零拷贝方式的提供的一种实现,底层实际是调用了 Linux 内核的 sendfile系统调用。它可以直接将文件数据从磁盘发送到网络,而不需要经过用户空间的缓冲区。关于FileChannel的用法可以看看这篇文章:Java NIO 文件通道 FileChannel 用法。
    eg:
1
2
3
4
5
6
7
8
9
10
private void loadFileIntoMemory(File xmlFile) throws IOException {
FileInputStream fis = new FileInputStream(xmlFile);
// 创建 FileChannel 对象
FileChannel fc = fis.getChannel();
// FileChannel.map() 将文件映射到直接内存并返回 MappedByteBuffer 对象
MappedByteBuffer mmb = fc.map(FileChannel.MapMode.READ_ONLY, 0, fc.size());
xmlFileBuffer = new byte[(int)fc.size()];
mmb.get(xmlFileBuffer);
fis.close();
}