了解零拷贝的基本概念与重要性

零拷贝是一个在技术讨论中频繁出现的话题,尤其是在面试过程中,许多大厂都会问到相关内容。例如,Kafka和RocketMQ的高性能都与零拷贝有着密切关系。最近在技术讨论群中,几位朋友分享了阿里和虾皮的面试真题,发现它们也涵盖了零拷贝的相关知识。因此,本文将与大家一起深入学习零拷贝的原理。

传统输入输出(IO)执行流程

作为服务端开发者,你很可能实现过文件下载功能。对于一个Web程序而言,前端的请求使得服务端需要将存储在主机磁盘中的文件发送到已连接的socket。关键实施代码如下:

while((n = read(diskfd, buf, BUF_SIZE)) > 0)  
    write(sockfd, buf , n);  

传统IO流程包括read和write的操作:

  • read:将数据从磁盘读取到内核缓冲区,再拷贝到用户缓冲区。
  • write:将数据写入socket缓冲区,最终写入网卡设备。

流程图解析

图片

在传统IO的读写流程中,涉及到四次上下文切换(用户态与内核态的切换)和四次数据拷贝(两次CPU拷贝及两次DMA拷贝)。那么,什么是DMA拷贝呢?接下来我们将回顾零拷贝所涉及的操作系统知识点。

零拷贝相关知识点概述

1. 内核空间与用户空间

我们电脑中运行的程序必须经过操作系统才能执行某些关键操作,如磁盘和内存的读写。这是因为这些操作具有一定风险,不能随意由应用程序执行。因此,操作系统为每个进程分配了内存空间,分为用户空间和内核空间。内核空间是操作系统内核可以访问的区域,而用户空间则是应用程序所能访问的区域。

2. 用户态与内核态

  • 进程运行在内核空间,称为内核态。
  • 进程运行在用户空间,称为用户态。

3. 上下文切换的含义

CPU上下文是指CPU寄存器和程序计数器等信息的集合,它们是运行程序时必需的环境。上下文切换指的是保存当前任务的上下文并加载新任务的上下文,以便让CPU转到新任务执行。

图片

4. 虚拟内存的概念

现代操作系统使用虚拟内存,通过虚拟地址替代物理地址。使用虚拟内存可以使虚拟空间大于物理空间,并允许多个虚拟内存指向同一个物理地址。这使得内核空间与用户空间的虚拟地址可以映射到同一物理地址,从而减少IO的数据拷贝。

图片

5. 直接内存访问(DMA)

DMA(Direct Memory Access)是一个独立的芯片,允许外设与内存直接进行数据传输,而不需要CPU介入。这种方式使得CPU可以腾出手来处理其他任务,提高了效率。

图片

零拷贝的实现方式

零拷贝并不是完全不拷贝数据,而是减少用户态与内核态的切换次数以及CPU拷贝的次数。零拷贝的实现方式包括:

  • mmap与write
  • sendfile
  • 带有DMA收集拷贝的sendfile

1. mmap与write的零拷贝实现

mmap函数原型如下:

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

mmap利用虚拟内存特性,将内核中的读缓冲区和用户空间缓冲区映射在同一物理地址。流程如下:

图片

通过mmapwrite的组合可以实现零拷贝,IO过程中发生了四次上下文切换和三次数据拷贝(包括两次DMA拷贝和一次CPU拷贝)。

2. sendfile的零拷贝实现

sendfile是Linux 2.1内核版本引入的系统调用,API如下:

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

该函数在内核中操作,避免了内核缓冲区与用户缓冲区之间的数据拷贝。其流程如下:

图片

使用sendfile实现零拷贝过程中的数据拷贝包括两次DMA拷贝和一次CPU拷贝。

3. sendfile与DMA scatter/gather的零拷贝实现

在Linux 2.4版本中,sendfile功能进行了优化,采用了SG-DMA技术,从内核缓冲区直接读取数据到网卡,减少了CPU的拷贝次数,流程如下:

图片

通过这种方式,IO过程中只发生两次上下文切换和两次DMA拷贝,真正实现了零拷贝。

Java提供的零拷贝方法

  • Java NIO对mmap的支持
  • Java NIO对sendfile的支持

1. Java NIO对mmap的支持

Java NIO中有一个MappedByteBuffer类,可以实现内存映射,底层调用Linux内核的mmap API。

mmap示例代码如下:

public class MmapTest {  
    public static void main(String[] args) {  
        try {  
            FileChannel readChannel = FileChannel.open(Paths.get("./jay.txt"), StandardOpenOption.READ);  
            MappedByteBuffer data = readChannel.map(FileChannel.MapMode.READ_ONLY, 0, 1024 * 1024 * 40);  
            FileChannel writeChannel = FileChannel.open(Paths.get("./siting.txt"), StandardOpenOption.WRITE, StandardOpenOption.CREATE);  
            writeChannel.write(data);  
            readChannel.close();  
            writeChannel.close();  
        } catch (Exception e) {  
            System.out.println(e.getMessage());  
        }  
    }  
}

2. Java NIO对sendfile的支持

FileChannel的transferTo()/transferFrom()方法底层调用sendfile()。如Kafka项目中使用的方式:

@Override  
public long transferFrom(FileChannel fileChannel, long position, long count) throws IOException {  
   return fileChannel.transferTo(position, count, socketChannel);  
}  

sendfile示例代码如下:

public class SendFileTest {  
    public static void main(String[] args) {  
        try {  
            FileChannel readChannel = FileChannel.open(Paths.get("./jay.txt"), StandardOpenOption.READ);  
            long len = readChannel.size();  
            long position = readChannel.position();  
            FileChannel writeChannel = FileChannel.open(Paths.get("./siting.txt"), StandardOpenOption.WRITE, StandardOpenOption.CREATE);  
            readChannel.transferTo(position, len, writeChannel);  
            readChannel.close();  
            writeChannel.close();  
        } catch (Exception e) {  
            System.out.println(e.getMessage());  
        }  
    }  
}