【笔记】java零拷贝

143 阅读7分钟

这是我参与2022首次更文挑战的第18天,活动详情查看:2022首次更文挑战

参考

mp.weixin.qq.com/s/SzK_AiVQo…

www.zhihu.com/question/27…

正文

根据参考的文章中的需求:

服务端的文件通过socket发送出去。

对应的流程如下:

sequenceDiagram
	participant us as 用户空间
	participant up as 内核空间
	participant HW as 硬件设备
	Note left of us:读文件
	note over up:内核缓冲区
	us ->>+up:read()
	note over HW:硬件
	up->>+HW:请求
	HW->>-up:DMA拷贝
	note over us:用户缓冲区
	up->>-us:CPU拷贝
	note left of us:写文件
	note over up:socket缓冲区
	us->>+up:write(),CPU拷贝
	note over HW:网卡
	up->>+HW:DMA拷贝
	HW->>-up:返回
	up->>-us:返回

这里能看到有4次CPU上下文切换,2次CPU拷贝和2次DMA拷贝。

一般来说,普通的IO是通过小的字节数组,一次读取若干数据刷入到缓冲区中。

重要相关概念

内核空间 - 内核态,用户空间 - 用户态

这个也较好理解:

内核空间:只有操作系统内核可以访问的空间

用户空间:用户应用程序访问的内存区域

而内核态和用户态,指的就是进程在哪个空间上运行。

CPU上下文,CPU上下文切换

上下文:CPU寄存器(类比JVM内存区域中,虚拟机栈的程序计数器

上下文切换:一般指CPU在内核态和用户态之间的切换;需要通过系统调用完成。

虚拟内存

操作系统分配的内存实际上为虚拟内存,和物理内存是n:1的关系。

可以类比JVM中的句柄访问的方式(不过那边反而可能对于历史的所有位置可能是1:n的关系,因为指针对应的物理地址会修改):

graph LR
操作系统管理-->虚拟内存1--> 物理内存1
操作系统管理-->虚拟内存2--> 物理内存2
操作系统管理-->虚拟内存3--> 物理内存2
操作系统管理-->虚拟内存4--> 物理内存2
操作系统管理-->虚拟内存5--> 物理内存1

DMA

DMA全称是 Direct Memory Access,直接内存访问,是主板上的一个独立芯片,允许设备和内存存储器间直接进行IO,而不需要CPU参与。

再次看一下上面的传统IO流程图,这里以read流程为例:

sequenceDiagram
    participant up as 用户进程
    participant CPU
    participant DMA
    participant hd as 磁盘
    activate up
    up->>CPU:read调用,用户态切换内核态
    activate CPU
    CPU->>DMA:发起IO请求
    deactivate CPU
    activate DMA
    DMA->>hd:发起IO请求
    deactivate DMA
    activate hd
    hd->>hd:将数据放入磁盘控制缓冲区
    hd->>DMA:通知DMA控制器
    deactivate hd
    activate DMA
    DMA->>DMA:数据从磁盘控制器缓冲区拷贝到内核缓冲区
    DMA->>CPU:发起数据读完信号
    deactivate DMA
    activate CPU
    CPU->>CPU:数据从内核缓冲区拷贝到用户缓冲区
    CPU->>up:read调用返回,内核态切换用户态
    deactivate CPU
    deactivate up

这里DMA:

主要就是帮忙CPU转发一下IO请求,以及拷贝数据,提高CPU利用效率:因为CPU可以做很多事,如果在IO请求上。

零拷贝实现方式

零拷贝定义:

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

1.mmap + write

前面介绍了虚拟内存:操作系统可以把同一块物理内存绑定到多个虚拟内存上。

mmap是将读缓冲区的地址和用户缓冲区的地址进行映射,内核缓冲区和应用缓冲区共享,所以节省了一次CPU拷贝‘’并且用户进程内存是虚拟的,只是映射到内核的读缓冲区,可以节省一半的内存空间。

基于这个需求,我们可以把用户缓冲区内核缓冲区用这一概念,绑定到同一块物理内存上,这样子就可以减少数据从内核缓冲区到用户缓冲区的一次读写:

sequenceDiagram
	participant us as 用户空间
	participant up as 内核空间
	participant HW as 硬件设备
	Note left of us:读文件
	note over up:内核缓冲区(用户缓冲区)
	us ->>+up:read()
	note over HW:硬件
	up->>+HW:请求
	HW->>-up:DMA拷贝
	note over us:用户缓冲区(内核缓冲区)
	up->>-us:返回
	note left of us:写文件
	us->>+up:write()
	note over up:socket缓冲区
	up->>up:CPU拷贝(内核缓冲区->socket缓冲区)
	note over HW:网卡
	up->>+HW:DMA拷贝
	HW->>-up:返回
	up->>-us:返回

这样子相较传统的IO模式:

  • 减少了一次CPU拷贝(从内核缓冲区到用户缓冲区的读写)
    • 第二次是从内核缓冲区(用户缓冲区)到socket缓冲区的读写,实质上并没有发生改变
  • 并且,减少了一半的内存空间。

这种方式需要三次数据拷贝(CPU1,DMA2),4次CPU上下文切换。

但,基于上面的模式:

  • 我们只是需要:把数据发送出去,实质上并不需要通过用户空间

基于这个前提,是否可以再做优化?

2.sendfile

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

sendfile就可以实现上面的优化:

  • 所有操作都在内核空间进行,CPU上下文的切换就仅在用户空间请求和内核空间返回结果上。

流程如下:

sequenceDiagram
	participant us as 用户空间
	participant up as 内核空间
	participant HW as 硬件设备
	Note left of us:senfile
	note over up:内核缓冲区
	us ->>+up:sendfile()
	note over HW:硬件
	up->>+HW:请求
	HW->>-up:DMA拷贝
	note over up:socket缓冲区
	up->>up:CPU拷贝(内核缓冲区->socket缓冲区)
	note over HW:网卡
	up->>+HW:DMA拷贝
	HW->>-up:返回
	up->>-us:sendfile返回

这样子就省下了2次CPU上下文切换了,这种方式:

  • 总计2次CPU上下文切换,3次拷贝(CPU1,DMA2)

但观察这个流程,如果要更优化一些,我们是否可以省去从内核缓冲区到socket缓冲区的CPU拷贝,而不需要再在内核空间分配空间做一次转运?

  • 直接从硬件到网卡并不现实,因为这样子就需要做针对硬件的特殊处理

3.sendfile+DMA scatter/gather

linux 2.4版本之后,对sendfile做了优化升级,引入SG-DMA技术,其实就是对DMA拷贝加入了scatter/gather操作,它可以直接从内核空间缓冲区中将数据读取到网卡。

对应流程如下:

sequenceDiagram
	participant us as 用户空间
	participant up as 内核空间
	participant HW as 硬件设备
	Note left of us:senfile
	note over up:内核缓冲区
	us ->>+up:sendfile()
	note over HW:硬件
	up->>+HW:请求
	HW->>-up:DMA拷贝
	up->>up:内核缓冲区的文件描述符信息发送到socket缓冲区
	note over HW:网卡
	up->>+HW:SG-DMA拷贝
	HW->>-up:返回
	up->>-us:sendfile返回

这种方式是对DMA的最佳使用范式了,因为在这里就完全没有CPU拷贝了,全部IO都到DMA上了。

  • 之所以不再需要CPU拷贝,是因为没有发生在内存中(用户空间和内核空间)的数据拷贝了。

总结

方式CPU拷贝次数DMA拷贝次数上下文切换次数
传统IO224
mmap+write124
sendfile122
sendfile+DMA scatter/gather022

Java支持

java中提供了2种零拷贝方式:

  • mmap
  • sendflie

mmap

java的NIO对MMAP提供了支持 MapperdByteBuffer,使用demo如下:

public class Mmap {

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

            FileChannel readChannel = FileChannel.open(Paths.get("src","disa.txt"), StandardOpenOption.READ);
            MappedByteBuffer data = readChannel.map(FileChannel.MapMode.READ_WRITE, 0, readChannel.size());
            FileChannel writeChannel = FileChannel.open(Paths.get("src","bia.txt"), StandardOpenOption.WRITE, StandardOpenOption.CREATE);
            //数据传输
            writeChannel.write(data);
            readChannel.close();
            writeChannel.close();
    }
}

实际上,这里的mapperdByteBuffer的参数如下:

  • 开启模式,这里指的是对readChannel的读取方式,只读,只写,或读写模式
    • 这里必须对应前面channel的方式,如果前面是读模式,那么这里就不能是读写,只能是只读
  • 起始位置,这里指的是读取的起始位置
  • 读取长度,这里指的是从读取起始位置开始,读取多少字节

关于path,这里我的文件结构是:

src

  • zeroCopy
    • Mmap.class
  • disa

在demo的语境中,mmap实质上是省去了从内核空间到用户空间的转写

sendfile

NIO同样提供了对于sendfile的支持,对应的API为 fileChannel.transferTo/transferFrom,demo如下:

public static void main(String[] args) throws Exception {
    FileChannel readChannel = FileChannel.open(Paths.get("src","disa"), StandardOpenOption.READ);
    long len = readChannel.size();
    long position = readChannel.position();

    FileChannel writeChannel = FileChannel.open(Paths.get("src","bia"), StandardOpenOption.WRITE, StandardOpenOption.CREATE);
    //数据传输
    readChannel.transferTo(position, len, writeChannel);
    readChannel.close();
    writeChannel.close();
}

性能对比

这里用JMH做性能对比,并把文件替换成一个108M的文件来进行读写,参赛选手:

  • 传统IO(fileStream)
  • mmap(这里修改了一下方式,并不一次性创建很大的buffer区)
  • sendfile

JMH的介绍以及使用可参考:www.zhihu.com/question/27…

这里就开单线程不用多线程了,测试代码如下:

@BenchmarkMode(Mode.AverageTime)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 5)
@Threads(1)
@Fork(1)
@State(value = Scope.Benchmark)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public class Mmap {

    public static final long length =  1024 * 1024 * 2 ;//1K

    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
                .include(Mmap.class.getSimpleName())
                .build();
        new Runner(opt).run();
    }


    @Benchmark
    public static void standard() throws Exception{
        File f = new File("src\\disa");
        FileInputStream fis = new FileInputStream(f);
        FileOutputStream fos = new FileOutputStream(new File("src\\bia"));
        byte[] b = new byte[(int)length];
        int s;
        while((s = fis.read(b))!=-1){
            fos.write(b,0,s);
        }
        fis.close();
        fos.close();
    }

    @Benchmark
    public static void mmap() throws Exception{
        FileChannel readChannel = FileChannel.open(Paths.get("src","disa"), StandardOpenOption.READ);

        long start = 0;
        long total = readChannel.size();
        FileChannel writeChannel = FileChannel.open(Paths.get("src","bia"), StandardOpenOption.WRITE, StandardOpenOption.CREATE);
        //数据传输
        while(start < total){
            long l = Math.min(total - start,length);
            MappedByteBuffer data = readChannel.map(FileChannel.MapMode.READ_ONLY,start,l);
            start += l;
            writeChannel.write(data);
        }
        readChannel.close();
        writeChannel.close();
    }

    @Benchmark
    public static void sendFile() throws Exception{
        FileChannel readChannel = FileChannel.open(Paths.get("src","disa"), StandardOpenOption.READ);
        long len = readChannel.size();
        long position = readChannel.position();

        FileChannel writeChannel = FileChannel.open(Paths.get("src","bia"), StandardOpenOption.WRITE, StandardOpenOption.CREATE);
        //数据传输
        readChannel.transferTo(position, len, writeChannel);
        readChannel.close();
        writeChannel.close();
    }

score:

BenchmarkModeCntScoreErrorUnits
zeroCopy.Mmap.mmapavgt5221.854± 333.769ms/op
zeroCopy.Mmap.sendFileavgt572.284± 40.739ms/op
zeroCopy.Mmap.standardavgt5171.231± 126.379ms/op

这里有个小插曲:

基于上面的实现,我们知道mmap实际上需要向操作系统申请一块空间来做转存,而这块空间是在堆外分配的,这里可以看JDK中的注释:

Maps a region of this channel's file directly into memory.

因此,如果在循环体中来分配mapBB,实际上是每次都要去新申请一块内存空间,这样子反而更慢。

这里更换一下mmap的分配模式,并在每个方法退出时,都把文件删除:

    public static void mmap() throws Exception{
        FileChannel readChannel = FileChannel.open(Paths.get("src","disa"), StandardOpenOption.READ);
        long total = readChannel.size();
        FileChannel writeChannel = FileChannel.open(Paths.get("src","bia"), StandardOpenOption.WRITE, StandardOpenOption.CREATE);
        //数据传输
        MappedByteBuffer data = readChannel.map(FileChannel.MapMode.READ_ONLY,0,total);
        writeChannel.write(data);
        readChannel.close();
        writeChannel.close();
        new File("src\\bia").delete();
    }

并把整体测试拉到10次,结果:

BenchmarkModeCntScoreErrorUnits
zeroCopy.Mmap.mmapavgt10334.484± 179.737ms/op
zeroCopy.Mmap.sendFileavgt10254.879± 250.466ms/op
zeroCopy.Mmap.standardavgt10428.532± 149.874ms/op

这个结果就比较符合我们的认知了:在效率上,sendFile>mmap>standard。

不过有个地方仍需要注意:

  • 这个测试的结果并不是完全准确的,和机器实时运行状态也有关系。

因此,这里能看到sendFile的误差非常大(和上面的结果相比),几乎达到了100%。

适用场景

从上面的步骤来看,mmap如果要使用快,就得一次性直接写到内存空间中,因此其实并不适合大文件读写的场景,参见:bbs.csdn.net/topics/3301…