零拷贝技术的原理与应用

零拷贝(Zero-copy)技术是指计算机执行操作时,CPU不需要先将数据从某处内存复制到另一个特定区域。这种技术通常用于通过网络传输文件时节省CPU周期和内存带宽。

零拷贝技术可以减少数据拷贝和共享总线操作的次数,消除传输数据在存储器之间不必要的中间拷贝次数,从而有效地提高数据传输效率。减少了用户进程地址空间和内核地址空间之间因为上下文切换而带来的开销。

注意:零拷贝并不是不需要拷贝,只是减少不必要的拷贝次数与线程上下文切换次数。

Linux的I/O机制与DMA

在早期计算机中,用户进程需要读取磁盘数据,需要CPU中断和CPU参与,因此效率比较低,发起IO请求,每次的IO中断,都带来CPU的上下文切换,因此出现了DMA。

DMA(Direct Memory Access,直接内存存取)是所有现代电脑的重要特色,它允许不同速度的硬件装置来沟通,而不需要依赖于CPU的大量中断负载。

DMA控制器,接管了数据读写请求,减少CPU的负担,这样一来,CPU能高效工作了,现代硬盘基本都支持DMA。

实际任何IO读取都涉及以下两个过程,这两个过程都是阻塞的:

  1. DMA等待数据准备好,把磁盘数据读取到操作系统内核缓冲区。
  2. 用户进程将内核缓冲区的数据拷贝到用户空间缓冲区。

传统数据传送机制

例如:从某台机器将一份一个文件通过网络传输到另外一台机器,用Java语言来描述发送端的逻辑,大致如下:

try (
        Socket socket = serverSocket.accept();
        OutputStream outputStream = socket.getOutputStream();
) {

    FileInputStream inputStream = new FileInputStream(FILE_PATH);

    byte[] buffer = new byte[4096];
    while (inputStream.read(buffer) >= 0) {
        outputStream.write(buffer);
    }
}

上面的代码看似简单,实际上底层经历了四次数据的拷贝,2次系统调用和4次上下文切换,一次系统调用伴随着两次线程上下文切换。

  1. 第一次拷贝:将磁盘文件读取到操作系统内核缓冲区。
  2. 第二次拷贝:将内核缓冲区的数据拷贝到应用程序的缓冲区。
  3. 第三步拷贝:将应用程序缓冲区中的数据拷贝到到套接字发送缓冲区(属于操作系统内核的缓冲区)。
  4. 第四次拷贝:将套接字发送缓冲区的数据拷贝到网卡,由网卡进行网络传输。

整个拷贝过程如下图:

零拷贝技术的原理与在java中应用_零拷贝

分析上述的过程,虽然引入DMA来接管CPU的中断请求,但四次copy是存在“不必要的拷贝”的。实际上并不需要第二次和第三次数据副本。应用程序除了缓存数据并将其传输回套接字缓冲区之外什么都不做。数据完全可以直接从读缓冲区传输到套接字缓冲区。显然,第二次和第三次数据拷贝其实在这种场景下没有什么帮助反而带来开销,这也正是零拷贝出现的背景和意义。

同时,read和write都属于系统调用,每次调用都牵涉到两次上下文切换:

零拷贝技术的原理与在java中应用_sendfile_02

总结下,传统的数据传送所消耗的成本:4次拷贝和4次上下文切换。4次拷贝中有两次是DMA拷贝,两次是CPU拷贝。

linux下的零拷贝

零拷贝只是为了减少IO流程中不必要的拷贝,而不是不需要拷贝,当然零拷贝需要OS支持,也就是需要内核暴露api。

mmap内存映射

硬盘上文件的位置和应用程序缓冲区(application buffers)进行映射(建立一种一一对应关系),由于mmap()将文件直接映射到用户空间,所以实际读取文件时根据这个映射关系,直接将文件从硬盘拷贝到用户空间,只进行了一次数据拷贝,不再有文件内容从硬盘拷贝到内核空间的一个缓冲区。

零拷贝技术的原理与在java中应用_sendfile_03

mmap内存映射会经历:3次拷贝和4次上下文切换。4次拷贝中有2次DMA拷贝,1次CPU拷贝。

mmap是一个系统调用,其函数原型如下:

#include <sys/mman.h>

void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);

sendfile

当调用sendfile()时,DMA将磁盘数据复制到内核文件读缓冲区,然后将内核中的文件读缓冲区直接拷贝到套接字缓冲区;但是数据并未被真正复制到套接字缓冲区内。取而代之的是,只有记录数据位置和长度的描述符被加入到套接字缓冲区中。DMA模块将数据直接从内核缓冲区传递给协议引擎,从而消除了遗留的最后一次复制。

一旦数据全都拷贝到套接字缓冲区,sendfile()系统调用将会返回,表示数据已经拷贝完成,这样套接字缓冲区里的数据就能在网络上传输了。

零拷贝技术的原理与在java中应用_sendfile_04

sendfile会经历:3次拷贝和2次上下文切换。3次拷贝中有2次DMA拷贝,1次CPU拷贝。

sendfile是一个系统调用,其函数原型如下:

#include <sys/sendfile.h>

ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

splice

数据从磁盘读取到内核缓冲区后,在内核缓冲区直接可将其转成内核空间其他数据缓冲区,而不需要拷贝到用户空间。

零拷贝技术的原理与在java中应用_FileChannel_05

如下图所示,从磁盘读取到内核缓冲区后,在内核空间直接与套接字缓冲区建立pipe管道。

注意splice和sendfile的不同,sendfile是将磁盘数据加载到内核缓冲区后,需要一次CPU拷贝,拷贝到套接字缓冲区。而splice是更进一步,连这个CPU拷贝也不需要了,直接将两个内核空间的缓冲区建立pipe。

splice会经历:2次拷贝和2次上下文切换。2次拷贝中包括0次cpu拷贝和2次DMA拷贝。

splice是一个系统调用,其函数原型如下:

#include <fcntl.h>

ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags);

Java生态圈中的零拷贝

Linux提供的零拷贝技术Java并不是全支持,只支持2种:mmap内存映射、sendfile。

NIO中的mmap

NIO中的FileChannel.map()方法其实就是采用了操作系统中的内存映射方式,底层就是调用Linux mmap()实现的。

将硬盘上的文件和用户缓冲区的内存做了一个地址映射,不经过内存缓冲区。这种方式适合读取大文件,同时也能对文件内容进行更改,但是如果其后要通过SocketChannel发送,还是需要CPU进行数据的拷贝。

下面用一个例子来验证下:

package com.morris.zerocopy;

import java.io.File;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;

/**
 * nio之mmap
 */
public class MappedByteBufferDemo {

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

        File f = new File("MappedByteBufferDemo.java");
        System.out.println("file size:" + f.length());

        MappedByteBuffer byteBuffer = new RandomAccessFile(f, "r").getChannel().map(FileChannel.MapMode.READ_ONLY, 0, f.length());

        byte[] bytes = new byte[(int) f.length()];
        byteBuffer.get(bytes);

        System.out.println(new String(bytes));

        byteBuffer.clear();
    }
}

编译后,使用strace命令对其产生的系统调用进行跟踪,关键信息如下:

# strace -ff -o out java MappedByteBufferDemo
write(1, "file size:657", 13)           = 13
open("MappedByteBufferDemo.java", O_RDONLY) = 4
mmap(NULL, 657, PROT_READ, MAP_SHARED, 4, 0) = 0x7fc5fed1e000

发现其底层确实使用了mmap系统调用。

NIO中的sendfile

Java NIO中提供的FileChannel拥有transferTo和transferFrom两个方法,可直接把FileChannel中的数据拷贝到另外一个Channel,或者直接把另外一个Channel中的数据拷贝到FileChannel。该接口常被用于高效的网络 或文件的数据传输和大文件拷贝。

在操作系统支持的情况下,通过该方法传输数据并不需要将源数据从内核态拷贝到用户态,再从用户态拷贝到目标通道的内核态,同时也避免了两次用户态和内核态间的上下文切换,也即使用了“零拷贝”,所以其性能一般高于Java IO中提供的方法。

下面用一个例子来验证下:

package com.morris.zerocopy;

import java.io.File;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;

/**
 * nio之sendfile
 */
public class FileChannelDemo {

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

        File srcFile = new File("FileChannelDemo.java");
        File descFile = new File("FileChannelDemo2.java");
        System.out.println("file size:" + srcFile.length());

        FileChannel srcFileChannel = new RandomAccessFile(srcFile, "r").getChannel();
        FileChannel descFileChannel = new RandomAccessFile(descFile, "rw").getChannel();

        srcFileChannel.transferTo(0, srcFile.length(), descFileChannel);
    }
}

编译后,使用strace命令对其产生的系统调用进行跟踪,关键信息如下:

write(1, "file size:713", 13)           = 13
open("FileChannelDemo.java", O_RDONLY)  = 4
open("FileChannelDemo2.java", O_RDWR|O_CREAT, 0666) = 6
sendfile(6, 4, [0] => [713], 713)       = 713

发现其底层确实使用了sendfile系统调用。

Netty的零拷贝实现

在网络通信上,Netty的接收和发送ByteBuffer采用直接内存,使用堆外直接内存进行Socket读写,不需要进行字节缓冲区的二次拷贝。如果使用传统的堆内存进行Socket读写,JVM会将堆内存Buffer拷贝一份到直接内存中(为什么拷贝?因为JVM会发生GC垃圾回收,数据的内存地址会发生变化,直接将堆内的内存地址传给内核,内存地址一旦变了就内核读不到数据了),然后才写入Socket中。相比于堆外直接内存,消息在发送过程中多了一次缓冲区的内存拷贝。

在缓存操作上,Netty提供了CompositeByteBuf类,它可以将多个ByteBuf合并为一个逻辑上的ByteBuf,避免了各个ByteBuf之间的拷贝。通过wrap操作,我们可以将byte[]数组、ByteBuf、ByteBuffer等包装成一个Netty ByteBuf对象,进而避免了拷贝操作。

ByteBuf支持slice操作,因此可以将ByteBuf分解为多个共享同一个存储区域的ByteBuf,避免了内存的拷贝。

在文件传输上,Netty的通过FileRegion包装的FileChannel.tranferTo实现文件传输,它可以直接将文件缓冲区的数据发送到目标Channel,避免了传统通过循环write方式导致的内存拷贝问题。