1. 简介
I/O 或输入/输出通常意味着中央处理器 (CPU) 与外部设备(如磁盘、鼠标、键盘等)之间的读写。在深入研究零拷贝之前,有必要指出磁盘 I/O(包括磁盘设备和其他块导向设备)和网络 I/O 之间的区别。
磁盘 I/O 的常用接口是 read()、write() 和 seek()。同时,网络 I/O 的接口通常是套接字相关接口。套接字接口背后发生的事情是发送方和接收方计算机创建自己的套接字,并设置发送或接收文件的连接。如今,越来越多的 Web 应用程序已经实现了从 CPU 绑定到 I/O 绑定的转变,这意味着 I/O 的性能通常是这些应用程序的瓶颈。
一般来说,用户不能直接在内核操作任何数据,包括读写。数据必须从内核复制到用户内存,而这个操作必须由 CPU 来完成,因此带来了很大的性能损失。这时零拷贝就派上用场了。零拷贝的主要原理是尽可能地消除或减少 CPU 在用户内存和内核内存之间的数据复制,从而减少相应的中断和模式切换次数,从而提高网络 I/O 的 I/O 性能。
2. DMA(直接存储器访问) 的缺陷
直接内存访问(DMA)是一个好主意,在减轻 CPU 负载和避免直接将数据从磁盘复制到磁盘方面效果很好。但仍有改进的空间。
图 1. 使用 DMA 的 read() 时序图
典型的 read() 过程如图 1 所示。
- 首先,当应用程序调用 read() 命令时,会发生从用户模式到内核模式的切换。然后 CPU 发起 DMA 传输,DMA 发起从磁盘的 I/O 传输。要完成此 DMA 传输,数据应传输到磁盘缓存,以便 DMA 能够将数据传输到内核缓冲区。
- 然后 DMA 中断 CPU 执行其他操作以发出传输完成信号。然后 CPU 将数据从内核缓冲区传输到用户缓冲区。
- 最后,发生从内核到用户的另一种模式切换并将数据返回给应用程序。
现在,我们来看一个更复杂的例子,从磁盘读取数据并将其写入互联网接口。此操作可能是客户端-服务器模式下 Web 应用程序中最常见的操作之一。
在这个例子中,首先执行 read() 命令并导致 CPU 模式切换,然后触发 DMA 数据从磁盘复制到内核缓冲区。然后 CPU 负责将数据复制到用户缓冲区,并将另一个模式从内核切换到用户。 write() 命令对网络接口和套接字缓冲区执行类似操作。CPU 将数据从用户缓冲区复制到套接字缓冲区并生成用于传输的头和尾信息,然后进行模式切换。然后 DMA 将数据复制到网络接口。最后,模式切换回用户模式。
这个过程绝对不能说是高效的,因为总共四次数据复制,四次模式切换,确实增加了系统的负荷,降低了响应效率。
3. 零拷贝原理
前面已经说了 DMA 的局限性,在 DMA 的帮助下,即使 CPU 不需要进行任何计算,也需要进行两次 CPU 复制,以及四次模式切换。零拷贝的目的很简单,就是消除或减少 CPU 在内核缓冲区和用户缓冲区之间不必要的数据复制,从而减少模式切换,从而实现性能的提升。
零拷贝是一个通用概念,也是一组实现的通用名称。多年来,人们一直在探索和改进它的实现。对于这个项目,我将深入研究一些流行的实现。
4. 零拷贝的实现
4.1. 使用 mmap()
在操作系统中,虚拟内存 (VM) 通过分页表映射到物理内存。多个 VM 地址可以映射到单个物理内存地址。此实现的思想是将用户虚拟内存的地址映射到内核内存的地址。这样 CPU 就不必来回复制数据。mmap() 代表内存映射。它是一个系统调用,可以将内核缓冲区中的数据映射到用户内存。幕后发生的事情是内核内存和用户内存中的虚拟地址指向物理地址的同一位置(共享内存)。
图 3. 使用 mmap() 从磁盘读取到网络接口
如图 3 所示,应用程序调用 mmap() 而不是 read()。由于用户内存和内核内存共享相同的物理内存地址,内核缓冲区中的数据将由 CPU 直接复制到套接字缓冲区。在这种情况下,没有数据复制到用户内存。
使用 mmap(),我们在本例中成功摆脱了一次 CPU 复制。但我们仍然需要四次模式切换、三次数据复制和昂贵的 VM 映射操作。还有进一步改进的空间。
4.2. 使用 sendfiles()
Linux 内核 2.1 为我们提供了一个新的系统调用 sendfile(),用于替代 read() 和 write(),用于某些类似上述示例的用例。只需一个系统调用而不是两个,我们就可以省去两次模式切换。
图 4. 使用 sendfile() 从磁盘读取到网络接口
图 4 显示了同一个示例的过程,但使用 sendfile() 实现零拷贝。应用程序这次调用 sendfile() 系统调用。DMA 将数据复制到内核缓冲区,然后数据由 CPU 复制到套接字缓冲区。与第一种实现相比,使用 sendfile() 为我们带来了两次模式切换、三次数据复制(包括一次 CPU 复制)。
4.3. 使用 sendfiles() 和 DMA Gather
Linux 2.4 对 sendfile() 系统调用进行了一些改进,其中最重要的就是 DMA Scatter/Gather 的出现。通过这项改进,我们终于可以消除上述场景中的所有 CPU 拷贝,实现真正的零拷贝(Zero-copy)。
图 5. 使用 sendfile() 和 DMA 收集从磁盘读取到网络接口
图 5 显示了该过程。当应用程序调用 sendfile() 时,DMA 控制器通过 DMA scatter 将数据从磁盘复制到内核缓冲区,这意味着您不需要连续的内存空间来存储数据。然后 CPU 将文件描述符附加到套接字缓冲区,DMA 控制器生成相应的网络数据包的头和尾。最后,DMA 控制器按照套接字缓冲区上的描述,复制数据,然后将数据包从内核缓冲区发送到网络接口进行网络传输。
5. Java零拷贝实验
现在我们对 Zero-copy 有了大致的了解,包括它的目的、原理和多种实现方法。在这一部分,我评估了使用 Zero-copy 对性能的提升。在 Java 中,FileChannel 类中有 API 分别使用了 mmap() 和 sendfile() 的机制。在本次实验中,我使用了三种方法在 Ubuntu 22.04 上复制了一个 880 MB 的文件。结果如下。
private static void sendfileCopyFile(String inputFilePath, String outputFilePath) {
long start = System.currentTimeMillis();
try (
FileChannel channelIn = new FileInputStream(inputFilePath).getChannel();
FileChannel channelOut = new FileOutputStream(outputFilePath).getChannel();
) {
channelIn.transferTo(0, channelIn.size(), channelOut);
} catch (IOException e) {
e.printStackTrace();
}
long end = System.currentTimeMillis();
System.out.println("Total time spent: " + (end - start));
}
private static void mmapCopyFile(String inputFilePath, String outputFilePath) {
long start = System.currentTimeMillis();
try (
FileChannel channelIn = new FileInputStream(inputFilePath).getChannel();
FileChannel channelOut = new RandomAccessFile(outputFilePath, "rw").getChannel();
) {
long size = channelIn.size();
MappedByteBuffer mbbi = channelIn.map(FileChannel.MapMode.READ_ONLY, 0, size);
MappedByteBuffer mbbo = channelOut.map(FileChannel.MapMode.READ_WRITE, 0, size);
for (int i = 0; i < size; i++) {
byte b = mbbi.get(i);
mbbo.put(i, b);
}
} catch (Exception e) {
e.printStackTrace();
}
long end = System.currentTimeMillis();
System.out.println("Total time spent: " + (end - start));
}
private static void bufferInputStreamCopyFile(String inputFilePath, String outputFilePath) {
long start = System.currentTimeMillis();
try(
BufferedInputStream bis = new BufferedInputStream(new FileInputStream(inputFilePath));
BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(outputFilePath));
){
byte[] buf = new byte[1];
int len;
while ((len = bis.read(buf)) != -1) {
bos.write(buf);
}
}catch(Exception e){
e.printStackTrace();
}
long end = System.currentTimeMillis();
System.out.println("Total time spent: " + (end - start));
}
图 6. 使用 Java 11 进行性能评估的结果
六, 结论
对于这个项目,我介绍了零拷贝的几乎所有基本元素。从其背景到设计原理,然后我详细介绍了业界最流行的三种实现,并附上了示意图。
在实验部分,我使用 java 来模拟数据传输的过程。从结果来看,两种类型的零拷贝实现都有显着的改进。mmap() 的速度提高了 81%,sendfile() 的速度提高了 91%,这回答了为什么人们在业界大量使用这种技术的问题。同时,也有需要改进的空间,例如映射操作的高成本、sendfile() 的用例有限以及空间限制。
希望我们将来能看到更多关于它们的改进。这个项目我介绍了零拷贝的几乎所有基本元素,从背景到设计原理,然后详细介绍了业界最流行的三种实现方式,并附上了示意图。在实验部分,我用java模拟了数据传输的过程。
从结果来看,两种零拷贝实现方式都有显著的提升。mmap()的速度提升了81%,sendfile()的速度提升了91%,这也回答了为什么业界如此热衷于使用这种技术。但同时也存在一些需要改进的地方,比如映射操作的开销大、sendfile()的使用场景有限、空间受限等。希望未来我们能看到更多关于这方面的改进。