Zero-Copy 零拷贝技术

本文完全参考零拷贝(zero-copy)原理详解,记录零拷贝技术相关知识点。

前置概念

用户空间与内核空间

CPU 将指令分为特权指令和非特权指令,对于危险指令,只允许操作系统及其相关模块使用,普通应用程序只能使用那些不会造成灾难的指令。 比如 Intel 的 CPU 将特权等级分为 4 个级别:Ring0~Ring3。

其实 Linux/windows 系统只使用了 Ring0 和 Ring3 两个运行级别。 当进程运行在 Ring3 级别时被称为运行在用户态,而运行在 Ring0 级别时被称为运行在内核态。

简单来说:内核空间和用户空间本质上是要提高操作系统的稳定性及可用性,当进程运行在内核空间时就处于内核态,当进程运行在用户空间时就处于用户态。

用户态和内核态之间切换时,会引发一系列切换动作(缓存失效、拷贝数据、安全检查、寄存器上下文切换),造成大量 CPU 开销。

DMA(Direct Memory Access 直接存储器访问)

DMA 控制方式是以存储器为中心,在主存和 I/O 设备之间建立一条直接通路,在 DMA 控制器的控制下进行设备和主存之间的数据交换。 这种方式只在传输开始和传输结束时才需要 CPU 的干预。它非常适用于高速设备与主存之间的成批数据传输。

传统 I/O

下面通过一个 Java 非常常见的应用场景:将系统中的文件发送到远端(磁盘文件 -> 内存(字节数组) -> 传输给用户/网络)来详细展开 I/O 操作。

old io

  1. JVM 发出 read()系统调用,上下文从用户态切换到内核态(第一次上下文切换)。 通过 DMA(Direct Memory Access,直接存储器访问)引擎将文件中的数据从磁盘上读取到内核空间缓冲区(第一次拷贝: hard drive -> kernel buffer)。
  2. 将内核空间缓冲区的数据拷贝到用户空间缓冲区(第二次拷贝:kernel buffer -> user buffer),然后 read 系统调用返回。 而系统调用的返回又会导致一次内核态到用户态的上下文切换(第二次上下文切换)。
  3. JVM 处理代码逻辑并发送 write()系统调用,上下文从用户态切换到内核态(第三次上下文切换), 然后将用户空间缓冲区中的数据拷贝到内核空间中与 socket 相关联的缓冲区中(即,第 2 步中从内核空间缓冲区拷贝而来的数据原封不动的再次拷贝到内核空间的 socket 缓冲区中。) (第三次拷贝:user buffer -> socket buffer)。
  4. write 系统调用返回,上下文再次从内核态切换到用户态(第四次上下文切换)。 通过 DMA 引擎将内核缓冲区中的数据传递到协议引擎(第四次拷贝:socket buffer -> protocol engine),这次拷贝是一个独立且异步的过程。
  • 小结: 传统的 I/O 操作进行了 4 次用户态与内核态间的上下文切换,以及 4 次数据拷贝(2 次 DMA 拷贝和 2 次 CPU 拷贝)。 传统的文件传输方式简单但存在冗余的上下文切换和数据拷贝,多了很多不必要的开销,在高并发系统里会严重影响系统性能。 所以,要想提高文件传输的性能,就需要减少「用户态与内核态的上下文切换」和「内存拷贝」的次数

零拷贝(zero-copy)演化

零拷贝是站在内核的角度来说的,其目的是消除从内核空间到用户空间的来回复制,并不是完全不会发生任何拷贝。

零拷贝不仅仅带来了更少的数据复制,还能带来其他的性能优势,例如:更少的上下⽂切换,更少的 CPU 缓存伪共享以及无 CPU 校验和计算。

mmap(Memory Mapped Files) 实现

mmap 是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。 实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必再调用 read,write 等系统调用函数。 相反,内核空间对这段区域的修改也直接反映用户空间,从而可以实现不同进程间的文件共享。

基于 mmap 的拷贝流程如下图:

mmap

  1. 发出 mmap 系统调用,上下文从用户态切换到内核态(第一次上下文切换)。 通过 DMA 将磁盘文件中的内容拷贝到内核空间缓冲区中(第一次拷贝:hard drive -> kernel buffer)。
  2. mmap 系统调用返回,上下文从内核态切换到用户态(第二次上下文切换)。接着用户空间和内核空间共享这个缓冲区而不需要进行数据拷贝。
  3. 发出 write 系统调用,上下文从用户态切换到内核态(第三次上下文切换)。 将数据从内核空间缓冲区拷贝到内核空间 socket 相关联的缓冲区(第二次拷贝:kernel buffer -> socket buffer)。
  4. write 系统调用返回,上下文从内核态切换到用户态(第四次上下文切换)。 通过 DMA 将内核空间 socket 缓冲区中的数据传递到协议引擎(第三次拷贝:socket buffer -> protocol engine)。
  • 小结: 通过 mmap 实现的零拷贝 I/O 进行了 4 次用户态与内核态间的上下文切换,以及 3 次数据拷贝(2 次 DMA 拷贝和 1 次 CPU 拷贝)。 通过 mmap 实现的零拷贝 I/O 与传统 I/O 相比仅仅少了 1 次内核空间缓冲区和用户空间缓冲区之间的 CPU 拷贝。 这样的好处是,可以将整个文件或者整个文件的一部分映射到内存当中,用户直接对内存中对文件进行操作,然后是由操作系统来进行相关的页面请求并将内存的修改写入到文件当中。 应用程序只需要处理内存的数据,这样可以实现非常迅速的 I/O 操作。

sendfile 实现

在 Java 中,FileChannel 的 transferTo() 方法可以实现发送过程,该方法将数据从文件通道传输到给定的可写字节通道。 在 UNIX 和各种 Linux 系统中,此调用被传递到 sendfile() 系统调用中,最终实现将数据从一个文件描述符传输到了另一个文件描述符。

sendfile

  1. 发出 sendfile 系统调用,上下文从用户态切换到内核态(第一次上下文切换)。 通过 DMA 将磁盘文件中的内容拷贝到内核空间缓冲区中(第一次拷贝:hard drive -> kernel buffer)。
  2. 将数据从内核空间缓冲区拷贝到内核中与 socket 相关的缓冲区中(第二次拷贝:kernel buffer -> socket buffer)。
  3. sendfile 系统调用返回,上下文从内核态切换到用户态(第二次上下文切换)。 通过 DMA 将内核空间 socket 缓冲区中的数据传递到协议引擎(第三次拷贝:socket buffer -> protocol engine)。
  • 小结: 通过 sendfile 实现的零拷贝 I/O 只进行了 2 次用户态与内核态间的上下文切换,以及 3 次数据的拷贝(2 次 DMA 拷贝和 1 次 CPU 拷贝)。 此时操作系统仍然需要在内核内存空间中复制数据(kernel buffer ->socket buffer)。 虽然从操作系统的角度来看,这已经是零拷贝了,因为没有数据从内核空间复制到用户空间。 但内核需要复制,原因是因为通用硬件 DMA 访问需要连续的内存空间(因此需要缓冲区),但是,如果硬件支持 scatter-and-gather ,这是可以避免的。

带有 DMA 收集拷贝功能的 sendfile 实现

从 Linux 2.4 版本开始,操作系统底层提供了带有 scatter/gather 的 DMA 来从内核空间缓冲区中将数据读取到协议引擎中。 这样一来待传输的数据可以分散在存储的不同位置上,而不需要在连续存储中存放。 那么从文件中读出的数据就根本不需要被拷贝到 socket 缓冲区中去,只是需要将缓冲区描述符添加到 socket 缓冲区中去, DMA 收集操作会根据缓冲区描述符中的信息将内核空间中的数据直接拷贝到协议引擎中。

gather sendfile

  1. 发出 sendfile 系统调用,上下文从用户态切换到内核态(第一次上下文切换)。 通过 DMA 将磁盘文件中的内容拷贝到内核空间缓冲区中(第一次拷贝:hard drive -> kernel buffer)。
  2. 没有数据拷贝到 socket 缓冲区。取而代之的是只有相应的描述符信息会被拷贝到相应的 socket 缓冲区当中。 该描述符包含了两方面的信息:kernel buffer 的内存地址和 kernel buffer 的偏移量。
  3. sendfile 系统调用返回,上下文从内核态切换到用户态。 DMA gather copy 根据 socket 缓冲区中描述符提供的位置和偏移量信息直接将内核空间缓冲区中的数据拷贝到协议引擎上 (第二次拷贝:socket buffer -> protocol engine),这样就避免了最后一次 CPU 数据拷贝。
  • 小结: 带有 DMA 收集拷贝功能的 sendfile 实现的 I/O 只进行了 2 次用户态与内核态间的上下文切换,以及 2 次数据的拷贝, 而且这 2 次的数据拷贝都是非 CPU 拷贝。这样一来就实现了最理想的零拷贝 I/O 传输了,不需要任何一次的 CPU 拷贝,以及最少的上下文切换。

零拷贝使用场景

“编程的本质是 数据结构+算法+内存管理”,而零拷贝大大减少了内存管理的损耗(CPU/内存空间), 在 Linux”一切皆文件”的设计方针下,必然在要求 IO 高性能的地方被广泛使用。

  • 适用场景:

    • ⽂件较⼤,读写较慢,追求速度
    • 内存不够,不能加载太⼤的数据
    • 内存宽带不够,即存在其他程序或线程存在⼤量的 IO 操作
  • 使用零拷贝的技术:

    • Java NIO
    • Netty
    • RocketMQ
    • Kafka