六.NIO与零拷贝

1.简介

1.1 解释

可以这么理解,能减少不必要的数据拷贝次数,就算是“零拷贝”。
Linux2.4内核新增sendfile系统调用。磁盘数据通过DMA(direct memory access)拷贝到内核Buffer,直接通过DMA拷贝到NIC (network interface controller)Buffer,无需CPU拷贝,这是操作系统意义上的零拷贝。

1.2 Linux I/O 机制

六.NIO与零拷贝_第1张图片
用户进程需要读取磁盘数据,需要CPU中断,发起IO请求,每次的IO中断,都带来CPU的上下文切换

1.3 DMA

为了解决CPU的上下文切换,出现了DMA(显卡、网卡、声卡都是支持 DMA ),直接内存读取。可以理解为,让硬件直接跳过CPU的调度,直接访问主内存

六.NIO与零拷贝_第2张图片

1.3Linux I/O 最新流程

六.NIO与零拷贝_第3张图片

  • DMA等待数据准备好,把磁盘数据读取到操作系统内核缓冲区
  • 用户进程,将内核缓冲区数据copy到用户空间

2.传统Java IO

直接上代码

//服务端读取 html 里的内容后变成字节数组
File file = new File("index.html");
RandomAccessFile raf = new RandomAccessFile(file, "rw");
 
byte[] arr = new byte[(int) file.length()];
raf.read(arr);

//监听 8080 端口,接收请求处理
Socket socket = new ServerSocket(8080).accept();
//html 里的字节流写到 socket 中
socket.getOutputStream().write(arr);

读写流程图
六.NIO与零拷贝_第4张图片
备注:上半部分表示用户态和内核态的上下文切换。下半部分表示数据复制操作

  • read 调用导致用户态到内核态的一次变化,同时,第一次复制开始:DMA(不使用CPU 拷贝数据到内存,而是 DMA 引擎传输数据到内存)引擎从磁盘读取 index.html 文件,并将数据放入到内核缓冲区。
  • 发生第二次数据拷贝,即:将内核缓冲区的数据拷贝到用户缓冲区,同时,发生了一次用内核态到用户态的上下文切换。
  • 发生第三次数据拷贝,我们调用 write 方法,系统将用户缓冲区的数据拷贝到 socket 缓冲区。此时,又发生了一次用户态到内核态的上下文切换。
  • 第四次拷贝,数据异步的从 socket 缓冲区,使用 DMA 引擎拷贝到网络协议引擎。这一段,不需要进行上下文切换。
    write 方法返回,再次从内核态切换到用户态

总结:传统IO,发生了4次数据拷贝,3次上下文切换

3.零拷贝

目的:减少 IO 流程中不必要的拷贝

零拷贝需要 OS 支持,也就是需要 kernel 暴露 api,虚拟机不能操作内核。

Linux 支持的(常见)零拷贝

3.1 mmap 内存映射

mmap 通过内存映射,将文件映射到内核缓冲区,同时,用户空间可以共享内核空间的数据。这样,在进行网络传输时,就可以减少内核空间到用户控件的拷贝次数。
直接贴图:
六.NIO与零拷贝_第5张图片
如上图,user buffer 和 kernel buffer 共享 index.html。如果你想把硬盘的 index.html 传输到网络中,再也不用拷贝到用户空间,再从用户空间拷贝到 socket 缓冲区。
现在,你只需要从内核缓冲区拷贝到 socket 缓冲区即可,这将减少一次内存拷贝(从 4 次变成了 3 次),但不减少上下文切换次数。

3.2 sendfile

Linux 2.1 版本提供了 sendFile 函数
其基本原理:数据根本不经过用户态,直接从内核缓冲区进入到 Socket Buffer,同时,由于和用户态完全无关,就减少了一次上下文切换。
六.NIO与零拷贝_第6张图片
如上图,我们进行 sendFile 系统调用时,

  • 数据被 DMA 引擎从文件复制到内核缓冲区,然后调用 write 方法时,从内核缓冲区进入到socket,这时,是没有上下文切换的,因为都在内核空间。
  • 最后,数据从 socket 缓冲区进入到协议栈。此时,数据经过了 3 次拷贝,2 次上下文切换。

3.3 Sendfile With DMA Scatter/Gather Copy

Scatter/Gather 可以看作是 sendfile 的增强版,批量 sendfile。
简单直接-直接从内核缓冲区拷贝到网络协议栈,避免了从内核缓冲区拷贝到 socket buffer 的操作,直接拷贝到协议栈,从而再一次减少了数据拷贝。
上图
六.NIO与零拷贝_第7张图片
现在,index.html 要从文件进入到网络协议栈,只需 2 次拷贝:

  • 第一次使用 DMA 引擎从文件拷贝到内核缓冲区。
  • 第二次从内核缓冲区将数据拷贝到网络协议栈。
    内核缓存区只会拷贝一些 offset 和 length 信息到 socket buffer,基本无消耗。

3.4 mmap 和 sendFile 的区别

  • mmap 适合小数据量读写,sendFile 适合大文件传输。
  • sendFile 可以利用 DMA 方式,减少 CPU 拷贝,mmap 则不能(必须从内核拷贝到 Socket 缓冲区)。
  • RocketMQ 在消费消息时,使用了 mmap。Kafka 使用了 sendFile。

4. NIO应用

4.1 MappedByteBuffer

Java NlO 中 的 Channel (通道)就相当于操作系统中的内核缓冲区,有可能是读缓冲区,也有可能是网络缓冲区,而 Buffer 就相当于操作系统中的用户缓冲区

MappedByteBuffer mappedByteBuffer = new RandomAccessFile(file, "r") 
.getChannel() 
.map(FileChannel.MapMode.READ_ONLY, 0, len);

NIO 中的 FileChannel.map() 方法其实就是采用了操作系统中的内存映射方式,底层就是调用 Linux mmap() 实现的

4.2 sendfile

  • FileChannel.transferTo() 方法直接将当前通道内容传输到另一个通道,没有涉及到 Buffer 的任何操作,NIO中的 Buffer 是 JVM 堆或者堆外内存,但不论如何他们都是操作系统内核空间的内存。
  • transferTo() 的实现方式就是通过系统调用 sendfile() (当然这是Linux中的系统调用)。

// 使用sendfile:读取磁盘文件,并网络发送
FileChannel sourceChannel = new RandomAccessFile(source, "rw").getChannel();
SocketChannel socketChannel = SocketChannel.open(sa);
sourceChannel.transferTo(0, sourceChannel.size(), socketChannel);

ZeroCopyFile实现文件复制:

class ZeroCopyFile {
     
    public void copyFile(File src, File dest) {
     
        try (FileChannel srcChannel = new FileInputStream(src).getChannel();
             FileChannel destChannel = new FileInputStream(dest).getChannel()) {
     
            srcChannel.transferTo(0, srcChannel.size(), destChannel);
        } catch (IOException e) {
     
            e.printStackTrace();
        }
    }
}

Java NIO 提供的 FileChannel.transferTo 和 transferFrom 并不保证一定能使用零拷贝。实际上是否能使用零拷贝与操作系统相关,如果操作系统提供 sendfile 这样的零拷贝系统调用,则这两个方法会通过这样的系统调用充分利用零拷贝的优势,否则并不能通过这两个方法本身实现零拷贝

4.3 Netty

Netty 中也用到了 FileChannel.transferTo 方法,所以 Netty 的零拷贝也包括上面讲的操作系统级别的零拷贝。

传统的 ByteBuffer,如果需要将两个 ByteBuffer 中的数据组合到一起,我们需要首先创建一个size=size1+size2 大小的新的数组,然后将两个数组中的数据拷贝到新的数组中。但是使用 Netty 提供的组合 ByteBuf,就可以避免这样的操作,因为 CompositeByteBuf 并没有真正将多个 Buffer 组合起来,而是保存了它们的引用,从而避免了数据的拷贝,实现了零拷贝。

CompositeByteBuf:将多个缓冲区显示为单个合并缓冲区的虚拟缓冲区。

建议使用 ByteBufAllocator.compositeBuffer() 或者 Unpooled.wrappedBuffer(ByteBuf…),而不是显式调用构造函数。
源码:

public abstract class ByteToMessageDecoder extends ChannelInboundHandlerAdapter {
     
    public static final ByteToMessageDecoder.Cumulator MERGE_CUMULATOR = new ByteToMessageDecoder.Cumulator() {
     
        public ByteBuf cumulate(ByteBufAllocator alloc, ByteBuf cumulation, ByteBuf in) {
     
            ByteBuf var5;
            try {
     
                ByteBuf buffer;
                if (cumulation.writerIndex() <= cumulation.maxCapacity() - in.readableBytes() && cumulation.refCnt() <= 1 && !cumulation.isReadOnly()) {
     
                    buffer = cumulation;
                } else {
     
                    buffer = ByteToMessageDecoder.expandCumulation(alloc, cumulation, in.readableBytes());
                }

                buffer.writeBytes(in);
                var5 = buffer;
            } finally {
     
                in.release();
            }

            return var5;
        }
    };
  
  // 可以看出来这里用了ByteBufAllocator 来分配readable的空间,并写入累积器中
  static ByteBuf expandCumulation(ByteBufAllocator alloc, ByteBuf cumulation, int readable) {
     
        ByteBuf oldCumulation = cumulation;
        cumulation = alloc.buffer(cumulation.readableBytes() + readable);
        cumulation.writeBytes(oldCumulation); // 将原始累积器的数据copy到新的累积器
        oldCumulation.release(); // 释放原始的累积器
        return cumulation;
    }
    
  ...
}

写文件 Region

从这里我们可以看出 netty 也调用了 FileChannelDe tansferTo 方法:

public class DefaultFileRegion extends AbstractReferenceCounted implements FileRegion {
     
    private FileChannel file;

  public long transferTo(WritableByteChannel target, long position) throws IOException {
     
        long count = this.count - position;
        if (count >= 0L && position >= 0L) {
     
            if (count == 0L) {
     
                return 0L;
            } else if (this.refCnt() == 0) {
     
                throw new IllegalReferenceCountException(0);
            } else {
     
                this.open();
                long written = this.file.transferTo(this.position + position, count, target);
                if (written > 0L) {
     
                    this.transferred += written;
                } else if (written == 0L) {
     
                    validate(this, position);
                }

                return written;
            }
        } else {
     
            throw new IllegalArgumentException("position out of range: " + position + " (expected: 0 - " + (this.count - 1L) + ')');
        }
    }
    
    ...
}

你可能感兴趣的:(netty,java,netty)