万字图文揭秘零拷贝 !

DMA 的缺陷

直接内存访问(DMA)是个好主意,在减轻 CPU 负载和避免直接数据复制方面效果很好。但仍有改进的空间。

典型的 read() 过程如上图所示。首先,当应用程序调用 read() 命令时,会发生从用户态到内核态的模式切换。然后 CPU 发起 DMA 传输,DMA 发起从磁盘的 I/O 传输。要完成此 DMA 传输,数据应传输到磁盘缓存,以便 DMA 能够将数据传输到内核缓冲区。

DMA将数据传输到内核缓冲区完成后,然后向CPU发送中断请求,通知CPU数据传输已完成,CPU接收到中断后停止正在执行的任务,转而将数据从内核缓冲区传输到用户缓冲区。最后从内核态切换到用户态。

我们来看一个更复杂的例子,从磁盘读取数据并将其写入互联网接口。此操作可能是client-server模式下 Web 应用程序中最常见的操作之一。

在这个例子中,首先执行 read() 命令并导致 CPU 模式切换,然后触发 DMA 数据从磁盘复制到内核缓冲区。然后 CPU 负责将数据复制到用户缓冲区,再从内核态切换到用户态。

write() 命令对网络接口和套接字缓冲区执行类似操作:CPU从用户态切换到内核态,CPU将数据从用户缓冲区复制到套接字缓冲区,然后从内核态切换回用户态,由 DMA 将数据从套接字缓冲区复制到网络接口。

这个过程绝对不能说是高效的,因为总共4次数据复制(2次CPU复制,2次DMA复制),4次用户态与内核态之间的切换。

零拷贝原理(Zero-copy)

前面已经说了 DMA 的局限性,在 DMA 的帮助下,即使 CPU 不需要进行任何计算,也需要进行2次 CPU 复制,以及四次用户态与内核态的切换。

零拷贝的目的很简单,就是消除或减少 CPU 在内核缓冲区和用户缓冲区之间不必要的数据复制,以及模式切换的次数,从而实现性能的提升。

零拷贝的实现

使用 mmap()

mmap介绍

mmap是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。

实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必再调用read,write等系统调用函数。

如上图所示,应用程序调用 mmap()将文件映射到进程的地址空间(内存映射区域),应用程序就可以直接使用指针来访问内存映射区域中的数据达到访问文件数据的效果。避免了将数据多次进行CPU数据复制。

mmap系统调用

#include <sys/mman.h>
void* mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset);

参数说明:

  • addr:指定映射的起始地址。通常为 NULL,表示让内核选择地址。注意,这个地址指的是进程的虚拟地址空间中的地址,而不是文件在磁盘上的地址。
  • length:要映射的字节数。
  • prot:映射区域的保护标志,决定了内存区域的访问权限,其值可以是:PROT_READ:允许读取映射区域的内容;PROT_WRITE:允许写入映射区域的内容;PROT_EXEC:页内容可执行;PROT_NONE:不允许对映射区域进行任何访问。。
  • flags:映射对象的特性。常见的标志包括:MAP_SHARED:映射区域的修改会写入文件,并且对其他映射此文件的进程可见;MAP_PRIVATE:映射区域的修改不会写入文件,也不会对其他进程可见;MAP_ANONYMOUS:映射区域不与任何文件关联,通常用于分配内存。
  • fd:文件描述符,表示要映射的文件。
  • offset:文件映射的起始偏移量,必须是页大小的整数倍。

下图可以帮助我们理解这些参数。

mmap 函数的返回值是一个 void * 类型的指针,指向被映射到文件的内存映射区域的起始地址,有了这个指针,后续就可以通过它来访问映射区域中的数据,也就是对应映射文件的内容。

下面是一个使用 mmap 的简单示例,演示如何将文件映射到内存,并读取其内容:

#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
#include <string.h>

int main() {
    const char *filepath = "example.txt";
    int fd = open(filepath, O_RDWR);
    if (fd == -1) {
        perror("open");
        exit(EXIT_FAILURE);
    }

    // 获取文件大小
    struct stat sb;
    if (fstat(fd, &sb) == -1) {
        perror("fstat");
        exit(EXIT_FAILURE);
    }

    // 将文件映射到内存,后续通过mapped读写数据
    char *mapped = mmap(NULL, sb.st_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
    if (mapped == MAP_FAILED) {
        perror("mmap");
        exit(EXIT_FAILURE);
    }

    // 关闭文件描述符,映射成功后文件描述符不再需要
    close(fd);

    // 读取文件内容并打印
    printf("File contents:\n%s\n", mapped);

    // 修改文件内容
    char *new_content = "Modified by mmap\n";
    strncpy(mapped, new_content, strlen(new_content));

    // 刷新映射区域到文件
    if (msync(mapped, sb.st_size, MS_SYNC) == -1) {
        perror("msync");
        exit(EXIT_FAILURE);
    }

    // 解除映射
    if (munmap(mapped, sb.st_size) == -1) {
        perror("munmap");
        exit(EXIT_FAILURE);
    }

    return 0;
}

这个示例展示了如何使用 mmap 实现文件的读取和写入操作。通过将文件映射到内存中,我们可以通过修改内存区域来修改文件内容,并通过 msync 函数将修改同步到文件中。

使用 sendfile()

sendfile介绍

Linux 内核 2.1 为我们提供了一个新的系统调用 sendfile(),用于替代 read() 和 write(),只需1个系统调用而不是2个,我们就可以省去2次用户态与内核态的模式切换。

使用 sendfile(),需要进行3次数据复制(1次CPU复制,2次DMA复制),以及2次用户态与内核态的切换,相比最初的read() 和 write(),节省了1次CPU数据复制,2次用户态与内核态的切换。

sendfile系统调用

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

参数说明:

  • out_fd:为待写入文件的文件描述符
  • in_fd:源文件的文件描述符,即要复制哪个文件的内容
  • offset:指定从读入文件的哪个位置开始读,如果为NULL,表示文件的默认起始位置。
  • count:两个描述符之间拷贝的字节数(bytes)

以下是在 C 语言中使用 sendfile() 函数进行文件拷贝的简单示例:

#include <stdio.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/sendfile.h>
#include <unistd.h>

int main() {
    int source_fd, dest_fd;
    struct stat stat_source;
    off_t offset = 0;
    ssize_t bytes_sent;

    // 打开源文件和目标文件
    source_fd = open("source.txt", O_RDONLY);
    dest_fd = open("destination.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);

    // 获取源文件的大小
    fstat(source_fd, &stat_source);

    // 使用 sendfile 将源文件内容发送到目标文件
    bytes_sent = sendfile(dest_fd, source_fd, &offset, stat_source.st_size);

    if (bytes_sent == -1) {
        perror("sendfile");
        return 1;
    }

    printf("Successfully copied %ld bytes\n", bytes_sent);

    // 关闭文件描述符
    close(source_fd);
    close(dest_fd);

    return 0;
}

除了在可以使用sendfile在本地的两个文件拷贝数据外,特别是在网络编程中常用于将文件内容发送给客户端,这种情况下,out_fd是一个socket套接字文件描述符。

使用 sendfile() 和 DMA Gather

Linux 2.4 对 sendfile() 系统调用进行了一些改进,其中最重要的就是 DMA Scatter/Gather 的出现,通过这项改进,我们终于可以消除上述场景中的所有 CPU 拷贝,实现真正的 Zero-copy。

上图显示了这一过程,当应用程序调用 sendfile() 时,DMA 控制器通过 DMA scatter 将数据从磁盘复制到内核缓冲区,然后 CPU 将文件描述符和数据长度传到 套接字缓冲区,这样网卡的 SG-DMA 控制器就可以直接将内核缓存中的数据拷贝到网卡的缓冲区里,此过程不需要将数据从操作系统内核缓冲区拷贝到 socket 缓冲区中。

使用 sendfile() 和 DMA Gather只需要2次数据复制(2次DMA复制),以及2次用户态与内核态的切换,相比最初的read() 和 write(),节省了一半操作。

这就是所谓的零拷贝(Zero-copy)技术,因为我们没有在CPU层面去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的。

Java零拷贝实验

在Java中,FileChannel类提供了使用mmap()和sendfile()机制的API。在下面的实验中,我在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));
}

从结果来看,两种类型的零拷贝实现都有显着的改进。mmap() 的速度提高了 81%,sendfile() 的速度提高了 91%,这也是为什么人们在业界大量使用这种技术的原因。

更多高质量原创技术文章可扫码关注公众号:“非科班大厂码农”
在这里插入图片描述

  • 10
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值