Netty 之 Zero-copy 的實現(下)

上一篇說到了 CompositeByteBuf ,這一篇接着上篇的講下去。java

FileRegion

讓咱們先看一個Netty官方的examplesegmentfault

// netty-netty-4.1.16.Final\example\src\main\java\io\netty\example\file\FileServerHandler.java
public void channelRead0(ChannelHandlerContext ctx, String msg) throws Exception {
    RandomAccessFile raf = null;
    long length = -1;
    try {
        raf = new RandomAccessFile(msg, "r");
        length = raf.length();
    } catch (Exception e) {
        ctx.writeAndFlush("ERR: " + e.getClass().getSimpleName() + ": " + e.getMessage() + '\n');
        return;
    } finally {
        if (length < 0 && raf != null) {
            raf.close();
        }
    }

    ctx.write("OK: " + raf.length() + '\n');
    if (ctx.pipeline().get(SslHandler.class) == null) {
        // SSL not enabled - can use zero-copy file transfer.
        ctx.write(new DefaultFileRegion(raf.getChannel(), 0, length));
    } else {
        // SSL enabled - cannot use zero-copy file transfer.
        ctx.write(new ChunkedFile(raf));
    }
    ctx.writeAndFlush("\n");
}

能夠看到在沒開啓SSL的狀況下handler是經過 DefaultFileRegion 類傳輸文件的,而 DefaultFileRegionFileRegion 接口的一個實現, FileRegion 的註釋是這麼寫的:緩存

A region of a file that is sent via a Channel which supports zero-copy file transfer.app

FileRegion 內部封裝了 Java NIO 的 FileChannel.transferTo() 方法,要了解 FileRegionZero-copy 的原理,咱們得先了解 transferTo() 方法。框架

讓咱們看一段傳輸文件的通常寫法吧。less

File.read(file, buf, len);
Socket.send(socket, buf, len);

儘管上面的代碼看起來很簡單,但在內部實際包含了4次用戶態-內核態上下文切換,和4次數據拷貝。dom

上下文切換示意圖

數據拷貝示意圖

其中步驟有:異步

  1. read() 調用致使了一次用戶態到內核態的上下文切換,在內部,一個 sys_read() (或等價函數)被執行來從文件中讀取數據。第一次拷貝是由 DMA 引擎將數據從磁盤文件存儲到內核地址空間緩衝區。
  2. 被請求長度的數據從內核的讀緩衝區拷貝到用戶緩衝區,而且 read() 調用返回。這個返回致使又一次從內核態到用戶態的上下文切換。如今數據是存儲在用戶地址空間緩衝區。
  3. send() 調用引發了一次從用戶態到內核態的上下文切換。第三次拷貝又一次將數據放進內核地址空間緩衝區,儘管這一次是放進另外一個不一樣的緩衝區,和目標socket聯繫在一塊兒。
  4. send() 系統調用返回,產生了第四次上下文切換。第四次拷貝由 DMA 引擎獨立異步地將數據從內核緩衝區傳遞給協議引擎。

看到這裏可能有些讀者會問,read() 函數爲何不直接將數據拷貝到用戶地址空間的緩衝區,而要經內核地址空間的緩衝區轉一次手,這不是白白多了一次拷貝操做嗎?socket

對IO函數有了解的童鞋確定知道,在IO函數的背後有一個緩衝區 buffer ,咱們日常的讀和寫操做並非直接和底層硬件設備打交道,而是經過一塊叫緩衝區的內存區域緩存數據來間接讀寫。咱們知道,和CPU、高速緩存、內存比,磁盤、網卡這些設備屬於慢速設備,交換一次數據要花不少時間,同時會消耗總線傳輸帶寬,因此咱們要儘可能下降和這些設備打交道的頻率,而使用緩衝區中轉數據就是爲了這個目的。async

引用參考文獻[2]中的話:

Using the intermediate buffer on the read side allows the kernel buffer to act as a "readahead cache" when the application hasn't asked for as much data as the kernel buffer holds. This significantly improves performance when the requested data amount is less than the kernel buffer size. The intermediate buffer on the write side allows the write to complete asynchronously.

大意是說,在讀一側的中間緩衝區能夠做爲預讀緩存顯著提升當請求數據大小小於內核緩衝區大小時的讀性能,在寫一側的中間緩衝區能夠容許寫操做異步完成。

不過,當讀請求數據的大小大於內核緩衝區時這個策略自己會變成一個性能瓶頸,數據在到達應用程序前會在磁盤、內核緩衝區、用戶緩衝區之間反覆屢次拷貝。

讓咱們從新思考下上面的過程,會發現第二次和第三次的拷貝實際上是沒必要要的,咱們爲何不直接從讀緩衝區將數據傳輸到socket緩衝區呢?實際上這就是 transferTo() 所作的。

public void transferTo(long position, long count, WritableByteChannel target);

transferTo() 方法將數據從一個文件channel傳輸到一個可寫channel。在內部它依賴於操做系統對 Zero-copy 的支持,在UNIX/Linux系統上, transferTo() 實際會調用 sendfile() 這個系統函數,將數據從一個文件描述符傳輸到另外一個。

#include <sys/socket.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

transferTo上下文切換

transferTo數據拷貝

能夠看到咱們將上下文切換已經從4次減小到2次,同時把數據拷貝從4次減小到3次(只有1次 CPU 參與,另外2次 DMA 引擎完成),那麼咱們可不能夠把這惟一一次CPU參與的數據拷貝也省掉呢?

若是網卡支持 gather operations 內核就能夠進一步減小數據拷貝。在 Linux kernels 2.4 及更新的版本,socket 描述符已經爲適應這個需求作了變化。如今這個方法不只減小了上下文切換,並且消除了CPU參與的數據拷貝。API接口是同樣的,可是實質已經發生了變化:

  1. transferTo() 方法引發 DMA 引擎將文件內容拷貝到內核緩衝區。
  2. 沒有數據從內核緩衝區拷貝到socket緩衝區,只有攜帶位置和長度信息的描述符被追加到socket緩衝區上, DMA 引擎直接將內核緩衝區的數據傳遞到協議引擎,全程無需CPU拷貝數據。

transferTo和gather operation數據拷貝

到這裏你們對 transferTo() 實現 Zero-copy 的原理應該很清楚了吧, FileRegion 是對 transferTo() 的一個封裝,因此也是同樣的。

DirectByteBuffer

DirectByteBuffer 是 Java NIO 用於實現堆外內存的一個很重要的類,而 NettyDirectByteBuffer 做爲PooledDirectByteBufUnpooledDirectByteBuf 的內部數據容器(區別於 HeapByteBuf 直接用 byte[] 做爲數據容器),以使用和操縱堆外內存。要了解 DirectByteBuffer 怎麼實現 Zero-copy,咱們要先了解 DirectByteBuffer 這個類和堆外內存。

DirectByteBuffer繼承關係

DirectByteBuffer 類自己仍是位於Java內存模型的堆中,堆內存是JVM能夠直接管控、操縱的內存,而 DirectByteBuffer 中的 unsafe.allocateMemory(size) 是一個native方法,這個方法分配的是堆外內存,經過 C 的 malloc 來進行分配的。分配的內存是在系統本地的內存,並不在Java的內存中,也不屬於JVM管控範圍,因此在 DirectByteBuffer 必定會存在某種方式操縱堆外內存。

DirectByteBuffer 的父類 Buffer 中有個 address 屬性:

// Used only by direct buffers
// NOTE: hoisted here for speed in JNI GetDirectBufferAddress
long address;

address 只會被直接緩存給使用到。之因此將 address 屬性升級放在 Buffer 中,是爲了在JNI調用 GetDirectBufferAddress 時提升效率。

address 表示分配的堆外內存的地址,JNI對這個堆外內存的操做都是經過這個 address 實現的。

在回答爲何堆外內存能夠實現 Zero-copy 前,咱們先要明確一個結論,那就是 操做系統不能直接訪問Java堆的內存區域

JNI方法訪問的內存區域是一個已經肯定的內存區域,若是該內存地址指向的是一個Java堆內存的話,在操做系統正在訪問這個內存地址時,JVM在這個時候進行了GC操做,GC常常會進行先標記再壓縮的操做,即將可回收的空間作標記,而後清空標記位置的內存,而後會進行一個壓縮,壓縮會涉及到對象的移動,以騰出一塊更加完整、連續的內存空間,以容納更大的新對象,可是這個移動的過程會使JNI調用的數據錯亂。

爲了解決上述的問題,通常會作一個堆內存與堆外內存之間數據拷貝的操做:好比咱們要完成一個從文件中讀數據到堆內存的操做,即 FileChannelImpl.read(HeapByteBuffer) ,這裏實際上File I/O會將數據讀到堆外內存中,而後堆外內存再將數據拷貝到堆內存,這樣咱們就讀到了文件中的內容。

FileChannelImpl.read

static int read(FileDescriptor var0, ByteBuffer var1, long var2, NativeDispatcher var4) throws IOException {
    if (var1.isReadOnly()) {
        throw new IllegalArgumentException("Read-only buffer");
    } else if (var1 instanceof DirectBuffer) {
        return readIntoNativeBuffer(var0, var1, var2, var4);
    } else {
        // 分配臨時的堆外內存
        ByteBuffer var5 = Util.getTemporaryDirectBuffer(var1.remaining());

        int var7;
        try {
            // File I/O 操做會將數據讀入到堆外內存中
            int var6 = readIntoNativeBuffer(var0, var5, var2, var4);
            var5.flip();
            if (var6 > 0) {
                // 將堆外內存的數據拷貝到堆外內存中
                var1.put(var5);
            }

            var7 = var6;
        } finally {
            // 裏面會調用DirectBuffer.cleaner().clean()來釋放臨時的堆外內存
            Util.offerFirstTemporaryDirectBuffer(var5);
        }

        return var7;
    }
}

而寫操做則反之,咱們會將堆內存的數據先寫到堆外內存,而後操做系統會將堆外內存的數據寫入到堆內存。

若是咱們直接使用堆外內存,即直接在堆外分配一塊內存來存儲數據,這樣就能夠避免堆內存和堆外內存之間的數據拷貝,進行I/O操做時直接將堆外內存地址傳給JNI的I/O函數就行了。

這裏引用一段 stackoverflow 裏關於 ByteBuffer.allocate() vs. ByteBuffer.allocateDirect() 的討論:

Operating systems perform I/O operations on memory areas. These memory areas, as far as the operating system is concerned, are contiguous sequences of bytes. It's no surprise then that only byte buffers are eligible to participate in I/O operations. Also recall that the operating system will directly access the address space of the process, in this case the JVM process, to transfer the data. This means that memory areas that are targets of I/O perations must be contiguous sequences of bytes. In the JVM, an array of bytes may not be stored contiguously in memory, or the Garbage Collector could move it at any time. Arrays are objects in Java, and the way data is stored inside that object could vary from one JVM implementation to another.

這也是堆外內存 DirectByteBuffer 被引進的緣由。

可是同時,建立和銷燬一塊堆外內存的花銷要比堆內存昂貴得多,這是由於堆外內存的建立和銷燬要經過系統相關的 native 方法,而不是在 Java 堆上直接由 JVM 操控。爲了更有效地重用堆外內存,Netty 引入了內存池機制手動管理內存,這是一個 Java 版的 Jemalloc,後面有機會再寫篇文章專門介紹這個,由於我如今也不是很懂(先挖個坑)。

總結

到這裏關於 Netty 實現 Zero-copy 的4種機制,切片共用,組合緩衝區,操做系統層的零拷貝以及堆外內存已經介紹完了,由於本人也是最近剛開始學習 Netty 框架,對不少知識點掌握得還不是很通透,若是文章寫得有什麼不妥的地方還請你們不吝賜教。

參考

[1] 對於 Netty ByteBuf 的零拷貝(Zero Copy) 的理解
[2] Efficient data transfer through zero copy
[3] 堆外內存 之 DirectByteBuffer 詳解

相關文章
相關標籤/搜索