netty系列(2)- 零拷贝

1. 零拷贝定义

零拷贝的定义:

Zero-copy, 就是在操作数据时, 不需要将数据 buffer 从一个内存区域拷贝到另一个内存区域. 少了一次内存的拷贝, 减少了cpu的执行,节省了内存带宽。

"Zero-copy" describes computer operations in which the CPU does not perform the task of copying data from one memory area to another. This is frequently used to save CPU cycles and memory bandwidth when transmitting a file over a network.

零拷贝带来的好处:

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

依赖操作系统

零拷贝实际的实现并没有真正的标准,取决于操作系统如何实现这一点。零拷贝完全依赖于操作系统。操作系统支持,就有;不支持,就没有。不依赖Java本身。

2. 传统I/O模式

拿一个业务场景举例:开发者需要将静态内容(类似图片、数据表、文件)展示给远程的用户。看起来当然是很简单的。但是如果我们深入到操作系统的层面,就会发现实际的微观操作要更复杂,具体来说有以下步骤:

  1. JVM向OS发出read()系统调用,触发上下文切换,从用户态切换到内核态。
  2. 从外部存储(如硬盘)读取文件内容,通过直接内存访问(DMA)存入内核地址空间的缓冲区。
  3. 将数据从内核缓冲区拷贝到用户空间缓冲区,read()系统调用返回,并从内核态切换回用户态。
  4. JVM向OS发出write()系统调用,触发上下文切换,从用户态切换到内核态。
  5. 将数据从用户缓冲区拷贝到内核中与目的地Socket关联的缓冲区。
  6. 数据最终经由Socket通过DMA传送到网络协议引擎(如网卡)缓冲区,write()系统调用返回,并从内核态切换回用户态。

这个过程一共发生了4次上下文切换(严格来讲是模式切换),并且数据也被来回拷贝了4次,其中4次数据拷贝中包括了2次DMA拷贝和2次CPU拷贝。我们都知道,上下文切换是CPU密集型的工作,数据拷贝是I/O密集型的工作。如果一次简单的传输就要像上面这样复杂的话,效率是相当低下的。零拷贝机制的终极目标,就是消除冗余的上下文切换和数据拷贝,提高效率。

DMA(Direct Memory Access) ———— 直接内存访问 :DMA是允许外设组件将I/O数据直接传送到主存储器中并且传输不需要CPU的参与,以此将CPU解放出来去完成其他的事情。
而用户空间与内核空间之间的数据传输并没有类似DMA这种可以不需要CPU参与的传输工具,因此用户空间与内核空间之间的数据传输是需要CPU全程参与的。所有也就有了通过零拷贝技术来减少和避免不必要的CPU数据拷贝过程。

3. 零拷贝:sendfile

3.1. 3次拷贝2次切换(linux2.1+)

sendfile() copies data between one file descriptor and another.
Because this copying is done within the kernel, sendfile() is more efficient than the combination of read(2) and write(2), which would require transferring data to and from user space.

通过上面的分析可以看出,第2、3次拷贝(也就是从内核空间到用户空间的来回复制)是没有意义的,数据应该可以直接从内核缓冲区直接送入Socket缓冲区。零拷贝机制就实现了这一点。不过零拷贝需要由操作系统直接支持,不同OS有不同的实现方法。大多数Unix-like系统都是提供了一个名为sendfile()的系统调用,现在的调用步骤如下:

  1. JVM向OS发出read()系统调用,触发上下文切换,从用户态切换到内核态。
  2. 从外部存储(如硬盘)读取文件内容,通过直接内存访问(DMA)存入内核地址空间的缓冲区。
  3. 通过sendfile()系统调用,将数据从内核缓冲区直接拷贝到与目的地Socket关联的缓冲区。
  4. 数据最终经由Socket通过DMA传送到网络协议引擎(如网卡)缓冲区,write()系统调用返回,并从内核态切换回用户态。

不仅拷贝的次数变成了3次,上下文切换的次数也减少到了2次,效率比传统方式高了很多。可见确实是消除了从内核空间到用户空间的来回复制,因此“zero-copy”这个词实际上是站在内核的角度来说的,并不是完全不会发生任何拷贝。

3.2. 2次拷贝2次切换(linux2.4+)

还能不能再继续优化呢? 例如直接从内核缓冲区拷贝到网络协议栈?

有的。但这需要底层操作系统的支持。从Linux 2.4版本开始,操作系统底层提供了scatter/gather这种DMA的方式来从内核空间缓冲区中将数据直接读取到网络协议引擎中,而无需将内核空间缓冲区中的数据再拷贝一份到内核空间socket相关联的缓冲区中。

总的来说,带有DMA收集拷贝功能的sendfile实现的I/O只使用了2次用户空间与内核空间的上下文切换,以及2次数据的拷贝,而且这2次的数据拷贝都是非CPU拷贝。这样一来我们就实现了最理想的零拷贝I/O传输了,不需要任何一次的CPU拷贝,以及最少的上下文切换。现在的调用步骤如下:

  1. JVM向OS发出read()系统调用,触发上下文切换,从用户态切换到内核态。
  2. 从外部存储(如硬盘)读取文件内容,通过直接内存访问(DMA)存入内核地址空间的缓冲区。
  3. 带有DMA收集拷贝功能的sendfile,将数据从内核缓冲区直接拷贝到网络协议引擎(如网卡)缓冲区,write()系统调用返回,并从内核态切换回用户态。

3.3. java nio实现

在nio框架中最核心的三个概念是:Selector、Channel和Buffer,正常来说都是通过Channel写入或输出Buffer里面的数据的。而如果两个通道中有一个是FileChannel,那你可以直接将数据从一个channel(译者注:channel中文常译作通道)传输到另外一个channel。方法有transferTo/transferFrom,系统级别实现的原理就是基于本段的sendfile。

  • transferFrom:FileChannel的transferFrom()方法可以将数据从源通道传输到FileChannel中
  • transferTo:FileChannel的transferTo()方法将数据从FileChannel传输到其他的channel中。

示例代码1 transferTo:提供下载接口,下载磁盘中的文件,

    public void downloadFile(HttpServletResponse response) {
        File file = new File(LOCAL_FILE_PATH + getFilename());
        try (OutputStream outputStream = response.getOutputStream();
                WritableByteChannel writableByteChannel = Channels.newChannel(outputStream);
                FileInputStream fileInputStream = new FileInputStream(file);
                FileChannel fileChannel = fileInputStream.getChannel()) {
            response.setHeader("Content-Type", Files.probeContentType(Paths.get(file.getAbsolutePath())));
            response.setHeader("Content-Disposition", "attachment;filename=" + new String(file.getName().getBytes("utf-8"), "ISO8859-1"));
            response.setContentLength((int) file.length());
            fileChannel.transferTo(0, fileChannel.size(), writableByteChannel);
        } catch (IOException ioException) {
            log.error("下载文件失败", ioException);
            throw new Exception(...);
        }
    }

示例代码1 transferFrom:将上述下载url中的文件写入磁盘

    private void downloadFileFromUrl(String downloadUrl, String filePath) {
        URL url = null;
        try {
            url = new URL(downloadUrl);
        } catch (MalformedURLException e) {
            log.error("URL地址错误", e);
            throw new Exception(...);
        }
        try (InputStream inputStream = url.openStream();
                FileOutputStream fileOutputStream = new FileOutputStream(filePath);
                ReadableByteChannel rbc = Channels.newChannel(inputStream)) {
            fileOutputStream.getChannel().transferFrom(rbc, 0, Long.MAX_VALUE);
        } catch (IOException e) {
            log.error("文件下载出错", e);
            throw new Exception(...);
        }
    }

nio中的Scatter/Gatter:

  • 分散(scatter)从Channel中读取是指在读操作时将读取的数据写入多个buffer中。因此,Channel将从Channel中读取的数据“分散(scatter)”到多个Buffer中。
  • 聚集(gather)写入Channel是指在写操作时将多个buffer的数据写入同一个Channel,因此,Channel 将多个Buffer中的数据“聚集(gather)”后发送到Channel。

scatter / gather经常用于需要将传输的数据分开处理的场合,例如传输一个由消息头和消息体组成的消息,你可能会将消息体和消息头分散到不同的buffer中,这样你可以方便的处理消息头和消息体。

4. 零拷贝:mmap

上面讲的机制看起来一切都很好,但它还是有个缺点:如果我想在传输时修改数据本身,就无能为力了。

4.1. 3次拷贝4次切换

不过,很多操作系统也提供了内存映射机制,对应的系统调用为mmap()/munmap()。通过它可以将文件数据映射到内核地址空间,直接进行操作,操作完之后再刷回去。还是上文的那个例子:

  1. 发出mmap系统调用,触发上下文切换,从用户态切换到内核态。
  2. 从外部存储(如硬盘)读取文件内容,通过直接内存访问(DMA)存入内核地址空间的缓冲区。
  3. mmap系统调用返回,用户空间和内核空间共享这个内核缓冲区,而不需要将数据从内核空间拷贝到用户空间,用户空间就可以像在操作自己缓冲区中数据一般操作这个由内核空间共享的缓冲区数据。但此时也发生了一次上下文切换,内核态切换回用户态。
  4. JVM向OS发出write()系统调用,触发上下文切换,从用户态切换到内核态。
  5. 将数据从内核缓冲区拷贝到内核中与目的地Socket关联的缓冲区。
  6. 数据最终经由Socket通过DMA传送到网络协议引擎(如网卡)缓冲区,write()系统调用返回,并从内核态切换回用户态。

通过mmap,虽然上下文切换还是4次,但是省去了一次拷贝,一共执行了3次拷贝,其中3次数据拷贝中包括了2次DMA拷贝和1次CPU拷贝。

对文件执行随机访问时,如果使用read()或write(),则意味着较低的 cache 命中率。这种情况下使用mmap()通常将更高效。

多个进程同时访问同一个文件时(无论是顺序访问还是随机访问),如果使用mmap(),那么操作系统缓冲区的文件内容可以在多个进程之间共享,从操作系统角度来看,使用mmap()可以大大节省内存。

但是,它需要在快表(TLB)中始终维护着所有数据对应的地址空间,直到刷写完成,因此处理缺页的overhead也会更大。在使用该机制时,需要权衡效率。

4.2. java nio实现

FileChannel的map方法会返回一个MappedByteBuffer。MappedByteBuffer是一个直接字节缓冲器,该缓冲器的内存是一个文件的内存映射区域。map方法底层是通过mmap实现的,因此将文件内存从磁盘读取到内核缓冲区后,用户空间和内核空间共享该缓冲区。

MappedByteBuffer map(int mode,long position,long size); 

MappedByteBuffer内存映射文件是一种允许Java程序直接从内存访问的一种特殊的文件。我们可以将整个文件或者整个文件的一部分映射到内存当中,那么接下来是由操作系统来进行相关的页面请求并将内存的修改写入到文件当中。我们的应用程序只需要处理内存的数据,这样可以实现非常迅速的I/O操作。

可以把文件的从position开始的size大小的区域映射为内存映像文件,mode指出了 可访问该内存映像文件的方式:READ_ONLY,READ_WRITE,PRIVATE。

  • READ_ONLY,(只读): 试图修改得到的缓冲区将导致抛出 ReadOnlyBufferException.(MapMode.READ_ONLY)
  • READ_WRITE(读/写): 对得到的缓冲区的更改最终将传播到文件;该更改对映射到同一文件的其他程序不一定是可见的。 (MapMode.READ_WRITE)
  • PRIVATE(专用): 对得到的缓冲区的更改不会传播到文件,并且该更改对映射到同一文件的其他程序也不是可见的;相反,会创建缓冲区已修改部分的专用副本。 (MapMode.PRIVATE)

示例代码:两个线程操作同一份文件。写线程通过MappedByteBuffer先写数据,写完暂时不退出,读线程马上读取MappedByteBuffer中的数据。可以发现在某一段时间,两个线程通过MappedByteBuffer实现不同线程间的内存共享。

public class MmapDemo {
    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            RandomAccessFile f = null;
            try {
                f = new RandomAccessFile("E:/hello.txt", "rw");
                FileChannel fc = f.getChannel();
                MappedByteBuffer buf = fc.map(FileChannel.MapMode.READ_WRITE, 0, 20);
                System.out.println("写线程:开始工作 .....");
                buf.put("how are you?".getBytes());
                System.out.println("写线程:我写完了,10秒后关闭线程");
                Thread.sleep(10000);
                System.out.println("写线程:现在关闭");
                fc.close();
                f.close();
            } catch (Exception e) {
                e.printStackTrace();
            }
        });

        Thread thread2 = new Thread(() -> {
            try {
                Thread.sleep(2000);
                RandomAccessFile f = new RandomAccessFile("E:/hello.txt", "rw");
                FileChannel fc = f.getChannel();
                MappedByteBuffer buf = fc.map(FileChannel.MapMode.READ_WRITE, 0, fc.size());
                System.out.println("读线程:开始读取共享缓冲区数据");
                while (buf.hasRemaining()) {
                    System.out.print((char) buf.get());
                }
                System.out.println("\n读线程:读完了,现在关闭");
            } catch (Exception e) {
                e.printStackTrace();
            }
        });

        thread1.start();
        thread2.start();
    }
}

5. sendfile VS mmap

mmap和sendfile都属于零拷贝的实现方式。在具体的选择上,要根据实际的情况来进行考虑。

  1. mmap适合小数据量读写,sendFile适合大文件传输。重点在于“读写”和“传输”,如果需要修改数据,也就只能用mmap了。
  2. mmap需要4次上下文切换,3次数据拷贝;sendFile需要2次上下文切换,3次(或者2次)数据拷贝。
  3. sendFile可以利用DMA方式减少CPU拷贝;mmap只能减少用户层和内核层之间的拷贝,不能减少CPU拷贝。

在进行数据传输时,不同的开源应用采用了不同的实现方式:

  • sendFile使用者:Tomcat内部文件拷贝,Tomcat的心跳保活,kafka,pulsar 下载文件。
  • mmap使用者:rocketMQ消费消息。

6. Netty的零拷贝

在操作系统层面上的零拷贝是指避免在用户态与内核态之间来回拷贝数据的技术。 Netty中的零拷贝与操作系统层面上的零拷贝不完全一样, Netty的零拷贝完全是在用户态(Java层面)的,更多是数据操作的优化。Netty的零拷贝主要体现在五个方面:

  1. Netty的接收和发送ByteBuffer使用直接内存进行Socket读写,不需要进行字节缓冲区的二次拷贝。如果使用JVM的堆内存进行Socket读写,JVM会将堆内存Buffer拷贝一份到直接内存中,然后才写入Socket中。相比于使用直接内存,消息在发送过程中多了一次缓冲区的内存拷贝。
  2. Netty的文件传输调用FileRegion包装的transferTo方法,可以直接将文件缓冲区的数据发送到目标Channel,避免通过循环write方式导致的内存拷贝问题。
  3. Netty提供CompositeByteBuf类, 可以将多个ByteBuf合并为一个逻辑上的ByteBuf, 避免了各个ByteBuf之间的拷贝。

    ByteBuf header = ...
    ByteBuf body = ...
    CompositeByteBuf compositeByteBuf = Unpooled.compositeBuffer();
    compositeByteBuf.addComponents(true, header, body);
  4. 通过wrap操作, 我们可以将byte[]数组、ByteBuf、ByteBuffer等包装成一个Netty ByteBuf对象, 进而避免拷贝操作。

    byte[] bytes = ...
    ByteBuf byteBuf = Unpooled.wrappedBuffer(bytes);
  5. ByteBuf支持slice操作,可以将ByteBuf分解为多个共享同一个存储区域的ByteBuf, 避免内存的拷贝。

    ByteBuf byteBuf = ...
    ByteBuf header = byteBuf.slice(0, 5);
    ByteBuf body = byteBuf.slice(5, 10);

你可能感兴趣的:(nettynio)