什么是零拷贝

零拷贝是老生常谈的问题,无论是Kafka还是Netty等都用到了零拷贝的知识,那究竟什么是零拷贝呢

什么是零拷贝

“零”:表示次数是0,它表示拷贝数据的次数是0
“拷贝”:指数据从一个存储区域转移到另一个存储区域
合起来,那零拷贝就是不需要将数据从一个存储区域复制到另一个存储区域。

零拷贝是指计算机执行IO操作时,CPU不需要将数据从一个存储区域复制到另一个存储区域,进而减少上下文切换以及CPU的拷贝时间。它是一种IO操作优化技术。

传统IO的执行流程

比如想实现一个下载功能,服务端的任务就是:将服务器主机磁盘中的文件从已连接的socket中发出去,关键代码如下

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

传统的IO流程包括read以及write的过程
read:将数据从磁盘读取到内核缓存区中,在拷贝到用户缓冲区
write:先将数据写入到socket缓冲区中,最后写入网卡设备

流程图如下
在这里插入图片描述
1.应用程序调用read函数,向操作系统发起IO调用,上下文从用户态切换至内核态
2.DMA控制器把数据从磁盘中读取到内核缓冲区
3.CPU把内核缓冲区数据拷贝到用户应用缓冲区,上下文从内核态切换至用户态,此时read函数返回
4.用户应用进程通过write函数,发起IO调用,上下文从用户态切换至内核态
5.CPU将缓冲区的数据拷贝到socket缓冲区
6.DMA控制器将数据从socket缓冲区拷贝到网卡设备,上下文从内核态切换至用户态,此时write函数返回

从流程图中可以看出传统的IO流程包括***4次上下文的切换***,4次拷贝数据(两次CPU拷贝以及两次DMA拷贝)

前置知识

内核空间和用户空间

我们电脑上跑着的应用程序,其实是需要经过操作系统,才能做一些特殊操作,如磁盘文件读写、内存的读写等等。因为这些都是比较危险的操作,不可以由应用程序乱来,只能交给底层操作系统来。

因此,操作系统为每个进程都分配了内存空间,一部分是用户空间,一部分是内核空间。内核空间是操作系统内核访问的区域,是受保护的内存空间,而用户空间是用户应用程序访问的内存区域。 以32位操作系统为例,它会为每一个进程都分配了4G(2的32次方)的内存空间。

内核空间:主要提供进程调度、内存分配、连接硬件资源等功能
用户空间:提供给各个程序进程的空间,它不具有访问内核空间资源的权限,如果应用程序需要使用到内核空间的资源,则需要通过系统调用来完成。进程从用户空间切换到内核空间,完成相关操作后,再从内核空间切换回用户空间。

用户态和内核态

如果进程运行于内核空间,被称为进程的内核态。
如果进程运行于用户空间,被称为进程的用户态。

什么是上下文切换

什么是上下文

CPU 寄存器,是CPU内置的容量小、但速度极快的内存。而程序计数器,则是用来存储 CPU 正在执行的指令位置、或者即将执行的下一条指令位置。它们都是 CPU 在运行任何任务前,必须的依赖环境,因此叫做CPU上下文。

什么是上下文切换

它是指,先把前一个任务的CPU上下文(也就是CPU寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。

一般我们说的上下文切换,就是指内核(操作系统的核心)在CPU上对进程或者线程进行切换。进程从用户态到内核态的转变,需要通过系统调用来完成。系统调用的过程,会发生CPU上下文的切换。
在这里插入图片描述

虚拟内存

现代操作系统使用虚拟内存,即虚拟地址取代物理地址,使用虚拟内存可以有2个好处:

1.虚拟内存空间可以远远大于物理内存空间
2.多个虚拟内存可以指向同一个物理地址

正是多个虚拟内存可以指向同一个物理地址,可以把内核空间和用户空间的虚拟地址映射到同一个物理地址,这样的话,就可以减少IO的数据拷贝次数,示意图如下
在这里插入图片描述

DMA技术

DMA,英文全称是Direct Memory Access,即直接内存访问。DMA本质上是一块主板上独立的芯片,允许外设设备和内存存储器之间直接进行IO数据传输,其过程不需要CPU的参与

简单的说它就是帮住CPU转发一下IO请求以及拷贝数据,那为什么需要它呢?其实主要是效率问题。它帮忙CPU做事情,这时候,CPU就可以闲下来去做别的事情,提高了CPU的利用效率。大白话解释就是,CPU老哥太忙太累啦,所以他找了个小弟(名叫DMA) ,替他完成一部分的拷贝工作,这样CPU老哥就能着手去做其他事情。

下面看下DMA具体是做了哪些工作
在这里插入图片描述
1.用户应用程序调read函数,向操作系统发起IO调用,进入阻塞状态等待数据返回
2.CPU接到指令后,对DMA控制器发起指令调度
3.DMA收到请求后,将请求发送给磁盘
4.磁盘将数据放入磁盘控制缓冲区并通知DMA
5.DMA将数据从磁盘控制器缓冲区拷贝到内核缓冲区
6.DMA向CPU发送数据读完的信号,CPU负责将数据从内核缓冲区拷贝到用户缓冲区
7.用户应用进程由内核态切回用户态,解除阻塞状态

如何实现零拷贝

零拷贝并不是没有拷贝数据,而是减少用户态、内核态的切换次数以及CPU拷贝次数;实现零拷贝主要有三种方式分别是
1.mmap + write
2.sendfile
3.带有DMA收集拷贝功能的sendfile

mmap

mmap的函数原型如下

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

addr:指定映射的虚拟内存地址
length:映射的长度
prot:映射内存的保护模式
flags:指定映射的类型
fd:进行映射的文件句柄
offset:文件偏移量

前面一小节,我们介绍了虚拟内存,可以把内核空间和用户空间的虚拟地址映射到同一个物理地址,从而减少数据拷贝次数!mmap就是用了虚拟内存这个特点,它将内核中的读缓冲区与用户空间的缓冲区进行映射,所有的IO都在内核中完成。mmap+write实现的零拷贝流程如下:
在这里插入图片描述
1.用户进程通过调用mmap方法向操作系统内核发起IO调用,上下文从用户态切换至内核态
2.CPU利用DMA控制器,将数据从硬盘拷贝到内核缓冲区
3.上下文从内核态切换回用户态,mmap方法返回
4.用户进程通过调用write方法向操作系统内核再次发起IO调用,上下文从用户态切换至内核态
5.CPU将内核缓冲区的数据拷贝到socket缓冲区
6.CPU利用DMA控制器,将数据从socket缓冲器拷贝到网卡,上下文从内核态切换至用户态,write方法返回

可以发现,mmap+write实现的零拷贝其中发生了4次上线文切换以及3次拷贝(2次DMA拷贝+1次cpu拷贝)

sendfile

sendfile是Linux2.1版本后内核引入 的一个系统调用函数,原型如下

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

out_fd:为待写入内容的文件描述符
in_fd:为待读出内容的文件描述符
offset:文件偏移量
count:指定在fdout和fdin之间传输的字节数

sendfile表示在两个文件描述符之间传输数据,它是在操作系统内核中操作的,避免了数据从内核缓冲区和用户缓冲区之间的拷贝操作,因此可以使用它来实现零拷贝。sendfile实现的零拷贝流程如下:
在这里插入图片描述
1.用户进程发起sendfile系统调用,上下文从用户态切换至内核态
2.DMA控制器将数据从硬盘拷贝到内核缓冲区
3.CPU将读缓冲区中的数据拷贝到socket缓冲区
4.DMA控制器异步把数据从socket缓冲器拷贝到网卡
5.上下文从内核态切换至用户态,sendfile函数返回

可以发现,sendfile实现的零拷贝仅仅发生了2次上下文切换以及3次拷贝(2次DMA拷贝+1次CPU拷贝)

sendfile +DMA scatter/gather实现的零拷贝
linux2.4版本后,对sendfile做了优化升级,引入SG-DMA技术,其实就是对DMA拷贝加入了scatter/gather操作,它可以直接从内核空间缓冲区中将数据读取到网卡,这样的话还可以省去CPU拷贝。
在这里插入图片描述
1.用户进程发起sendfile系统调用,上下文从用户态切换至内核态
2.DMA控制器将数据从磁盘拷贝到内核缓冲器
3.CPU把内核缓冲区中的文件描述符信息(包括内核缓冲区的内存地址和偏移量)直接发送到socket缓冲区
4.DMA控制器根据文件描述符信息直接把数据从内核缓冲区拷贝到网卡
5.上下文切换至用户态,sendfile返回

可以发现sendfile + DMA scatter/gather实现的零拷贝发生了2次上下文切换以及2次数据拷贝,这就是真正的零拷贝技术,全程没有通过CPU来搬运数据,所有的数据都是通过DMA进行传输的。

java提供的零拷贝方式

mmap

Java NIO有一个MappedByteBuffer的类可以用来实现内存映射。它的底层是调用的linux内核的mmap的API

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());
        }
    }
}

sendfile

FileChannel的transferTo()/transferFrom(),底层就是sendfile() 系统调用函数。Kafka 这个开源项目就用到它,平时面试的时候,回答面试官为什么这么快,就可以提到零拷贝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());
        }
    }
}