被朋友问到什么是零拷贝,我一脸懵逼...

1,431 阅读7分钟

前言

我们的Web应用多多少少都会处理一些静态内容,需要先从磁盘中读取到数据,在不经过修改后将此数据写入到套接字,伪代码如下:

read(file, tmp_buf, len);
write(socket, tmp_buf, len);

虽然看似简单,但是它的效率却不高,因为在这两个调用之后,数据已经被至少复制了四次,并且执行了大概相同数量的用户/内核态上下文切换,那么啥是用户/内核态呢?用户态是指当程序运行在3级特权级上时,因为这是最低特权级,是普通的用户进程运行的特权级,反过来,当程序运行在0级特权级上时,就可以称之为运行在内核态。而为了使应用程序访问到内核管理的资源,内核必须提供一组通用的访问接口,这些接口就叫系统调用,当我们需要做IO操作如open、read、write、就需要通过系统调用来和内核进行交互,但是系统调用的开销很大,要尽量减少系统调用的次数,因为系统调用会从用户态进入到内核态,用户态和内核态的频繁切换,会消耗大量的CPU资源,会影响数据传输的性能。用户态切换到内核态的还有俩种方式,异常和外围设备的中断。

那这里特权又是指什么?

从80286处理器开始,Intel引入了保护模式,特权级就是保护模式中的一个重要概念,操作系统的核心代码运行在最高特权级(0特权级)上,而用户程序运行在最低特权级(3特权级上),特权级1、2一般用于运行系统服务程序。

对于上面的例子,我们可以把他分为以下几个步骤:

  1. read会进行系统调用,将导致上下文从用户模式切换到内核模式,然后由DMA从磁盘读取文件内容并将数据存储到内核地址空间缓冲区中。

  2. 将数据从内核缓冲区复制到用户缓冲区,然后read系统调用返回,导致上下文从内核切换回用户模式。

  3. write系统调用导致上下文从用户模式切换到内核模式,执行第三次复制,将数据放入内核地址空间缓冲区,这次是将数据放入另一个缓冲区中,这个缓冲区专门与套接字关联。

  4. write系统调用返回。

可以看出,首先内核读出磁盘中数据,然后将数据跨越内核推到应用程序,应用程序再次跨越内核将数据推回,写出到套接字。在这里应用程序实际上担当了一个中介角色,即将磁盘文件的数据转入套接字,所以在内核上下文和应用程序上下文之间复制数据是多余的,那么有什么办法可以将数据直接从内核上下文复制到内核上下文呢?

答案就是使用零拷贝,零拷贝技术可以使内核直接将数据从磁盘文件拷贝到套接字,而无需通过应用程序,这不仅大大地提高了应用程序的性能,而且还减少了内核与用户模式间的上下文切换。说简单一点就是避免CPU将数据从一块存储拷贝到另外一块存储,减少不必要的拷贝,这就是”零拷贝“的意思

Java 中零拷贝技术

transferTo

我们可以通过java.nio.channels.FileChannel中的transferTo()方法来在Linux系统上进行零拷贝,transferTo()方法直接将字节从它被调用的通道上传输到另外一个可写字节通道上,数据无需流经应用程序,在内部,它依赖底层操作系统对零拷贝的支持,Linux系统中,会被传递到sendfile()系统调用中,sendfile不仅减少了数据复制,还减少了上下文切换,可以将上下文切换次数从四次减少到两次,数据拷贝的次数从四次减少到三次。

步骤如下:

  1. sendfile系统调用使DMA引擎将文件内容复制到内核缓冲区中,然后在被内核复制到与套接字关联的内核缓冲区中。
  2. DMA将Socket缓冲区拷贝到网卡buffer中。

public class Test {
    public static void main(String[] args) {
        long l = System.currentTimeMillis();
        transferTo("/home/HouXinLin/apps/gradle/gradle-6.1.1-all.zip", "/home/HouXinLin/temp.zip");
        System.out.println(System.currentTimeMillis() - l);
    }


    private static void stream(String src, String dest) {
        try {
            BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream(new File(src)));
            BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(new FileOutputStream(new File(dest)));
            byte[] temp = new byte[2048];
            int size = 0;
            while ((size = bufferedInputStream.read(temp)) > 0) {
                bufferedOutputStream.write(temp, 0, size);
            }
            bufferedInputStream.close();
            bufferedOutputStream.close();

        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }

    }

    private static void copy(String src, String dest) {
        try {
            Files.copy(Paths.get(src), new FileOutputStream(new File(dest)));
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    private static void transferTo(String src, String dest) {
        try {
            FileChannel readChannel = FileChannel.open(Paths.get(src), StandardOpenOption.READ);
            FileChannel writeChannel = FileChannel.open(Paths.get(dest), StandardOpenOption.WRITE, StandardOpenOption.CREATE);
            readChannel.transferTo(0, readChannel.size(), writeChannel);
            readChannel.close();
            writeChannel.close();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }


}

经过测试,其他两种方式平均都在上百毫秒以上(文件大小138.5MB),而transferTo都在50多毫秒。

map

在说map方法前,先说说什么是mmap,mmap是Linux提供的一种内存映射文件方法,mmap系统调用使DMA引擎将文件内容复制到内核缓冲区中,然后与用户进程共享缓冲区,就不需要在内核和用户内存空间之间执行任何复制。mmap就是代替了read操作。

tmp_buf = mmap(file, len);
write(socket, tmp_buf, len);

这样的话我们可以减少了内核复制数据量的一半,当传输大量数据时,这种方式有很棒的效果,但是,这种方式是有缺陷的,存在隐患,在内存中映射文件时,当另一个进程将同一个文件截断时,那么write系统调用会因为访问非法地址被SIGBUS信号终止,SIGBUS默认会杀死进程,服务器可能因此被终止。

FileChannel.map方法可以把一个文件从position位置开始的size大小的区域映射为内存映像文件,返回MappedByteBuffer,MappedByteBuffer继承于ByteBuffer,map方法底层是通过mmap实现的,因此将文件内存从磁盘读取到内核缓冲区后,用户空间和内核空间共享该缓冲区。

他有三个参数,分别为:MapMode、Position、size

MapMode:映射的模式,可选项包括:READ_ONLY,READ_WRITE,PRIVATE。

Position:从哪个位置开始映射,字节数的位置。

Size:从position开始向后多少个字节。
 private static void map(String src, String dest){
     try {
         FileChannel readChannel = FileChannel.open(Paths.get(src), StandardOpenOption.READ);
         MappedByteBuffer map = readChannel.map(FileChannel.MapMode.READ_ONLY, 0, readChannel.size());
         FileChannel writeChannel = FileChannel.open(Paths.get(dest), StandardOpenOption.WRITE, StandardOpenOption.CREATE);
         writeChannel.write(map);
         readChannel.close();
         writeChannel.close();
     } catch (Exception e) {
         e.printStackTrace();
     }
 }

这种速度也非常快,经过测试,和transferTo方法一样。

零拷贝给我们带来的好处

  1. 减少甚至完全避免不必要的CPU拷贝,从而让CPU解脱出来去执行其他的任务
  2. 减少内存带宽的占用
  3. 通常零拷贝技术还能够减少用户空间和操作系统内核空间之间的上下文切换

查看系统调用

在程序访问硬件设备,如读取磁盘文件,接收网络数据等等时,必须将用户态模式切换至内核态模式,通过系统调用访问硬件设备,而strace就可以跟踪到这个进程产生的系统调用,包括参数,返回值,执行消耗的时间。

我们将上面的程序打包成jar,然后执行如下命令:

strace java -jar Demo.jar 

从输出中可以看到,内部会调用mmap这个函数。