Netty 原始碼剖析之 unSafe.read 方法

weixin_33751566發表於2018-03-18
4236553-e55f46526d13babd.jpg

目錄:

  1. NioSocketChannel$NioSocketChannelUnsafe 的 read 方法
  2. 首先看 ByteBufAllocator
  3. 再看 RecvByteBufAllocator.Handle
  4. 兩者如何配合進行記憶體分配
  5. 如何讀取到 ByteBuf
  6. 總結

前言

在之前的文章 Netty 核心元件 Pipeline 原始碼分析(二)一個請求的 pipeline 之旅中,我們知道了當客戶端請求進來的時候,boss 執行緒會將 Socket 包裝後交給 worker 執行緒,worker 執行緒會將這個 Socket 註冊 selector 的讀事件,當讀事件進來的時候,會呼叫 unsafe 的 read 方法,這個方法的主要作用是讀取 Socket 緩衝區的記憶體,幷包裝成 Netty 的 ByteBuf 物件,最後傳遞進 pipeline 中的所有節點完成處理。

今天,我們就要好好的看看這個 read方法的實現。

1. NioSocketChannel$NioSocketChannelUnsafe 的 read 方法

原始碼如下:

public final void read() {
    final ChannelConfig config = config();
    final ChannelPipeline pipeline = pipeline();
    // 用來處理記憶體的分配:池化或者非池化 UnpooledByteBufAllocator
    final ByteBufAllocator allocator = config.getAllocator();
    // 用來計算此次讀迴圈應該分配多少記憶體 AdaptiveRecvByteBufAllocator 自適應計算緩衝分配
    final RecvByteBufAllocator.Handle allocHandle = recvBufAllocHandle();
    allocHandle.reset(config);// 重置為0

    ByteBuf byteBuf = null;
    boolean close = false;
    try {
        do {
            byteBuf = allocHandle.allocate(allocator);
            allocHandle.lastBytesRead(doReadBytes(byteBuf));
            if (allocHandle.lastBytesRead() <= 0) {// 如果上一次讀到的位元組數小於等於0,清理引用和跳出迴圈
                // nothing was read. release the buffer.
                byteBuf.release();// 引用 -1
                byteBuf = null;
                close = allocHandle.lastBytesRead() < 0;// 如果遠端已經關閉連線
                if (close) {
                    // There is nothing left to read as we received an EOF.
                    readPending = false;
                }
                break;
            }

            allocHandle.incMessagesRead(1);//  totalMessages += amt;
            readPending = false;
            pipeline.fireChannelRead(byteBuf);
            byteBuf = null;
        } while (allocHandle.continueReading());

        allocHandle.readComplete();
        pipeline.fireChannelReadComplete();

        if (close) {
            closeOnRead(pipeline);
        }
    } catch (Throwable t) {
        handleReadException(pipeline, byteBuf, t, close, allocHandle);
    } finally {
        if (!readPending && !config.isAutoRead()) {
            removeReadOp();
        }
    }
}

程式碼很長,怎麼辦呢?當然是拆解,然後逐個擊破。步驟如下:

  1. 獲取到 Channel 的 config 物件,並從該物件中獲取記憶體分配器,還有"計算記憶體分配器"。
  2. 計算記憶體分配器 重置。
  3. 進入一個迴圈,迴圈體的作用是:使用記憶體分配器獲取資料容器-----ByteBuf,呼叫 doReadBytes 方法將資料讀取到容器中,如果這次讀取什麼都沒有或遠端連線關閉,則跳出迴圈。還有,如果滿足了跳出推薦,也要結束迴圈,不能無限迴圈,預設16 次,預設引數來自 AbstractNioByteChannel 的 屬性 ChannelMetadata 型別的 METADATA 例項。每讀取一次就呼叫 pipeline 的 channelRead 方法,為什麼呢?因為由於 TCP 傳輸如果包過大的話,丟失的風險會更大,導致重傳,所以,大的資料流會分成多次傳輸。而 channelRead 方法也會被呼叫多次,因此,使用 channelRead 方法的時候需要注意,如果資料量大,最好將資料放入到快取中,讀取完畢後,再進行處理。
  4. 跳出迴圈後,呼叫 allocHandle 的 readComplete 方法,表示讀取已完成,並記錄讀取記錄,用於下次分配合理記憶體。
  5. 呼叫 pipeline 的方法。

接下來就一步步看。

2. 首先看 ByteBufAllocator

首先看看這個節點的定義:

Implementations are responsible to allocate buffers. Implementations of this interface are expected to be hread-safe.
實現負責分配緩衝區。這個介面的實現應該是執行緒安全的。

4236553-34d07961151497cb.png
定義瞭如上方法

通過這個介面,可以看出來,主要作用是建立 ByteBuf,這個 ByteBuf 是 Netty 用來替代 NIO 的 ByteBuffer 的,是儲存資料的快取區。其中,這個介面有一個預設實現 ByteBufUtil.DEFAULT_ALLOCATOR :該實現根據配置建立一個 池化或非池化的快取區分配器。該引數是 io.netty.allocator.type

同時,由於很多方法都是過載的,那就說說上面的主要方法作用:

buffer() // 返回一個 ByteBuf 物件,預設直接記憶體。如果平臺不支援,返回堆記憶體。
heapBuffer()// 返回堆記憶體快取區
directBuffer()// 返回直接記憶體緩衝區
compositeBuffer() // 返回一個複合緩衝區。可能同時包含堆記憶體和直接記憶體。
ioBuffer() // 噹噹支援 Unsafe 時,返回直接記憶體的 Bytebuf,否則返回返回基於堆記憶體,當使用 PreferHeapByteBufAllocator 時返回堆記憶體

3. 再看 RecvByteBufAllocator.Handle

首先看這個介面:

4236553-f6f18e4fac080d3d.png

上圖中, Handle 是 RecvByteBufAllocator 的內部介面。而 RecvByteBufAllocator 是如何定義的呢?

Creates a new handle. The handle provides the actual operations and keeps the internal information which is required for predicting an optimal buffer capacity.
建立一個新的控制程式碼。控制程式碼提供了實際操作,並保留了用於預測最佳緩衝區容量所需的內部資訊。

該介面只定義了一個方法:newHandle()。

而 handle 的作用是什麼呢?


ByteBuf allocate(ByteBufAllocator alloc);//建立一個新的接收緩衝區,其容量可能大到足以讀取所有入站資料和小到資料足夠不浪費它的空間。
int guess();// 猜測所需的緩衝區大小,不進行實際的分配
void reset(ChannelConfig config);// 每次開始讀迴圈之前,重置相關屬性
void incMessagesRead(int numMessages);// 增加本地讀迴圈的次數
void lastBytesRead(int bytes); // 設定最後一次讀到的位元組數
int lastBytesRead(); // 最後一次讀到的位元組數
void attemptedBytesRead(int bytes); // 設定讀操作嘗試讀取的位元組數
void attemptedBytesRead(); // 獲取嘗試讀取的位元組數
boolean continueReading(); // 判斷是否需要繼續讀
void readComplete(); // 讀結束後呼叫

從上面的方法中,可以看出,該介面的主要作用就是計算位元組數,如同 RecvByteBufAllocator 的文件說的那樣,根據預測和計算最佳大小的快取區,確保不浪費。

4. 兩者如何配合進行記憶體分配

在預設的 config (NioSocketChannelConfig)中,allocator 來自 ByteBufAllocator 介面的預設例項,allocHandle 來自 AdaptiveRecvByteBufAllocator 自適應迴圈快取分配器 的內部類 HandleImpl。

好,知道了他們的預設實現,我們一個方法看看。

首先看 reset 方法:

public void reset(ChannelConfig config) {
    this.config = config;
    maxMessagePerRead = maxMessagesPerRead();
    totalMessages = totalBytesRead = 0;
}

設定了上次獲取的最大訊息讀取次數(預設16),將之前計算的讀取訊息總數歸零。該方法如同他的名字,歸零重置。

再看看 allocHandle.allocate(allocator) 方法的實現。

public ByteBuf allocate(ByteBufAllocator alloc) {
    return alloc.ioBuffer(guess());
}

我們剛剛說的 ioBuffer 方法,該方法預設返回直接記憶體緩衝區。而 guess() 方法返回一個猜測的大小,一個 nextReceiveBufferSize 屬性,預設 1024,也就是說,預設建立一個 1024 大小的直接記憶體緩衝區。這個值的設定來自 HandleImpl 的構造方法,儲存在一個 SIZE_TABLE 的陣列中。

我們還是看看 RecvByteBufAllocator 的實現類 AdaptiveRecvByteBufAllocator 的具體內容吧

static final int DEFAULT_MINIMUM = 64; // 快取區最小值
static final int DEFAULT_INITIAL = 1024; // 緩衝區初始值
static final int DEFAULT_MAXIMUM = 65536; // 緩衝區最大值

private static final int INDEX_INCREMENT = 4;// 當發現快取過小,陣列下標自增值
private static final int INDEX_DECREMENT = 1;// 當發現緩衝區過大,陣列下標自減值

private static final int[] SIZE_TABLE;

static {
    List<Integer> sizeTable = new ArrayList<Integer>();
    for (int i = 16; i < 512; i += 16) {
        sizeTable.add(i);
    }

    for (int i = 512; i > 0; i <<= 1) {
        sizeTable.add(i);
    }

    SIZE_TABLE = new int[sizeTable.size()];
    for (int i = 0; i < SIZE_TABLE.length; i ++) {
        SIZE_TABLE[i] = sizeTable.get(i);
    }
}

樓主在上面的程式碼中寫了註釋,這個 SIZE_TABLE 的作用是儲存快取區大小的一個 int 陣列,從 static 塊中可以看到,這個陣列從16開始,同時遞增16,直到值到了 512,也就是下標 31 的地方,遞增策略變為了 每次 * 2,直到溢位。最終的陣列長度為 53。而對應的值接近 int 最大值。

好,回到 allocate 方法中,進入到 ioBuffer 方法檢視:

public ByteBuf ioBuffer(int initialCapacity) {
    if (PlatformDependent.hasUnsafe()) {
        return directBuffer(initialCapacity);
    }
    return heapBuffer(initialCapacity);
}

判斷,如果平臺支援 unSafe,就使用直接記憶體,否則使用堆記憶體,初始大小就是我們剛剛說的 1024。而這個判斷的標準是:如果嘗試獲取 Unsafe 的時候有異常了,則賦值給一個 UNSAFE_UNAVAILABILITY_CAUSE 物件,否則賦值為 null,Netty 通過這個判 Null 確認平臺是否支援 Unsafe。

我們繼續看看 directBuffer 方法的實現:


public ByteBuf directBuffer(int initialCapacity) {
    return directBuffer(initialCapacity, DEFAULT_MAX_CAPACITY);
}
// 
public ByteBuf directBuffer(int initialCapacity, int maxCapacity) {
    if (initialCapacity == 0 && maxCapacity == 0) {
        return emptyBuf;
    }
    validate(initialCapacity, maxCapacity);
    return newDirectBuffer(initialCapacity, maxCapacity);
}
// 
protected ByteBuf newDirectBuffer(int initialCapacity, int maxCapacity) {
    final ByteBuf buf;
    if (PlatformDependent.hasUnsafe()) {
        buf = noCleaner ? new InstrumentedUnpooledUnsafeNoCleanerDirectByteBuf(this, initialCapacity, maxCapacity) :
                new InstrumentedUnpooledUnsafeDirectByteBuf(this, initialCapacity, maxCapacity);
    } else {
        buf = new InstrumentedUnpooledDirectByteBuf(this, initialCapacity, maxCapacity);
    }
    return disableLeakDetector ? buf : toLeakAwareBuffer(buf);
}

由於方法層層遞進,樓主將程式碼合在一起,最終呼叫的是 newDirectBuffer,根據 noCleaner 引數決定建立一個 ByteBuf,這個屬性怎麼來的呢?當 unsafe 不是 null 的時候,會嘗試獲取 DirectByteBuffer 的構造器,如果成功獲取,則 noCleaner 屬性為 true。

這個 noCleaner 屬性的詳細介紹請看這裡Netty 記憶體回收之 noCleaner 策略.

預設情況下就是 true,那麼,也就是建立了一個 InstrumentedUnpooledUnsafeNoCleanerDirectByteBuf 物件,該物件構造如下:

@1
InstrumentedUnpooledUnsafeNoCleanerDirectByteBuf(
        UnpooledByteBufAllocator alloc, int initialCapacity, int maxCapacity) {
    super(alloc, initialCapacity, maxCapacity);
}

@2
UnpooledUnsafeNoCleanerDirectByteBuf(ByteBufAllocator alloc, int initialCapacity, int maxCapacity) {
    super(alloc, initialCapacity, maxCapacity);
}

@3
public UnpooledUnsafeDirectByteBuf(ByteBufAllocator alloc, int initialCapacity, int maxCapacity) {
    super(maxCapacity);
    if (alloc == null) {
        throw new NullPointerException("alloc");
    }
    if (initialCapacity < 0) {
        throw new IllegalArgumentException("initialCapacity: " + initialCapacity);
    }
    if (maxCapacity < 0) {
        throw new IllegalArgumentException("maxCapacity: " + maxCapacity);
    }
    if (initialCapacity > maxCapacity) {
        throw new IllegalArgumentException(String.format(
                "initialCapacity(%d) > maxCapacity(%d)", initialCapacity, maxCapacity));
    }

    this.alloc = alloc;
    setByteBuffer(allocateDirect(initialCapacity), false);
}

@4
static ByteBuffer newDirectBuffer(long address, int capacity) {
    ObjectUtil.checkPositiveOrZero(capacity, "capacity");
    return (ByteBuffer) DIRECT_BUFFER_CONSTRUCTOR.newInstance(address, capacity);
}

最終使用了 DirectByteBuffer 的構造器進行反射建立。而這個構造器是沒有預設的 new 建立的 Cleaner 物件的。因此稱為 noCleaner。

建立完畢後,呼叫 setByteBuffer ,將這個 DirectByteBuffer 包裝一下。

回到 newDirectBuffer 方法。

最後根據 disableLeakDetector 屬性判斷釋放進行自動記憶體回收(也就是當你忘記回收的時候,幫你回收),原理這裡簡單的說一下,使用虛引用進行跟蹤。 FastThreadLocal 的記憶體回收類似。我們將在以後的文章中詳細說明此策略。

到這裡,建立 ByteBuf 的過程就結束了。

可以說,大部分工作都是 allocator 做的,allocHandle 的作用就是提供瞭如何分配一個合理的記憶體的策略。

5. 如何讀取到 ByteBuf

回到 read 方法,doReadBytes(byteBuf) 就是將 Channel 的內容讀取到容器中,並返回一個讀取到的位元組數。

程式碼如下:

protected int doReadBytes(ByteBuf byteBuf) throws Exception {
    final RecvByteBufAllocator.Handle allocHandle = unsafe().recvBufAllocHandle();
    allocHandle.attemptedBytesRead(byteBuf.writableBytes());
    return byteBuf.writeBytes(javaChannel(), allocHandle.attemptedBytesRead());
}

獲取到 記憶體預估器,設定一個 attemptedBytesRead 屬性為 ByteBuf 的可寫位元組數。這個引數可用於後面分配記憶體時的一些考量。

然後呼叫 byteBuf.writeBytes()方法。傳入了 NIO 的 channel,還有剛剛的可寫位元組數。進入到該方法檢視:

@1
public int writeBytes(ScatteringByteChannel in, int length) throws IOException {
    ensureWritable(length);
    int writtenBytes = setBytes(writerIndex, in, length);
    if (writtenBytes > 0) {
        writerIndex += writtenBytes;
    }
    return writtenBytes;
}

首先對長度進行校驗,確保可寫長度大於0,如果被併發了導致容量不夠,將這個底層的 ByteBuffer 的容量增加傳入的長度。

關於 ByteBuf 的 wirteIndex ,如下圖:

4236553-db077a19e426423e.png

回到 writeBytes 方法,呼叫 setBytes 方法,將流中輸入寫入到緩衝區。方法如下:

public int setBytes(int index, ScatteringByteChannel in, int length) throws IOException {
    ensureAccessible();
    ByteBuffer tmpBuf = internalNioBuffer();
    tmpBuf.clear().position(index).limit(index + length);
    try {
        return in.read(tmpBuf);
    } catch (ClosedChannelException ignored) {
        return -1;
    }
}

非常熟悉的 NIO 操作。

首先獲取到內部 ByteBuffer 的共享緩衝區,賦值給臨時的 tmpNioBuf 屬性。然後返回這個引用。將這個引用清空,並將指標移動到給定 index 為止,然後 limit 方法設定快取區大小。

最後呼叫 Channel 的 read 方法,將Channel 資料讀入到 ByteBuffer 中。讀的過程時執行緒安全的,內部使用了 synchronized 關鍵字控制寫入 buffer 的過程。返回了讀到的位元組數。

回到 writeBytes 方法,得到位元組數之後,將這個位元組數追加到 writerIndex 屬性,表示可寫位元組變小了。

回到 read 方法。allocHandle 得到讀取到的位元組數,呼叫 lastBytesRead 方法,該方法的作用時調整下一次分配記憶體的大小。進入到該方法檢視:

public void lastBytesRead(int bytes) {
    // If we read as much as we asked for we should check if we need to ramp up the size of our next guess.
    // This helps adjust more quickly when large amounts of data is pending and can avoid going back to
    // the selector to check for more data. Going back to the selector can add significant latency for large
    // data transfers.
    if (bytes == attemptedBytesRead()) {
        record(bytes);
    }
    super.lastBytesRead(bytes);
}

Netty 寫了註釋:

如果我們讀的內容和我們要求的一樣多,我們應該檢查一下是否需要增加下一個猜測的大小。
這有助於在等待大量資料時更快地進行調整,並且可以避免返回選擇器以檢查更多資料。回到選擇器可以為大型資料傳輸新增顯著的延遲。

當獲取的位元組數和預估的一樣大,則需要進行擴容。看看 record 方法實現:

private void record(int actualReadBytes) {
    if (actualReadBytes <= SIZE_TABLE[max(0, index - INDEX_DECREMENT - 1)]) {
        if (decreaseNow) {
            index = max(index - INDEX_DECREMENT, minIndex);
            nextReceiveBufferSize = SIZE_TABLE[index];
            decreaseNow = false;
        } else {
            decreaseNow = true;
        }
    } else if (actualReadBytes >= nextReceiveBufferSize) {
        index = min(index + INDEX_INCREMENT, maxIndex);
        nextReceiveBufferSize = SIZE_TABLE[index];
        decreaseNow = false;
    }
}

如果實際讀取到的位元組數小於等於預估的位元組 下標 - 2(排除2以下),則將容量縮小一個下標。如果實際讀取到的位元組數大於等於預估的。則將下標增加 4,下次建立的 Buffer 容量也相應增加。如果不滿足這兩個條件,什麼都不做。

回答 lastBytesRead 方法,該方法記錄了讀取到的總位元組數並且更新了最後一次的讀取位元組數。總位元組數會用來判斷是否可以結束讀取迴圈。如果什麼都沒有讀到,將最多持續到讀 16(預設) 次。

回到 read 方法。

如果最後一次讀取到位元組數小於等於0,跳出迴圈,不做 channelRead 操作。
反之,將 totalMessages 加1,這個就是用來記錄迴圈次數,判斷不能超過 16次。

呼叫 fireChannelRead 方法,方法結束後,將這個 Buffer 的引用置為null,

判斷是否需要繼續讀取,帶入如下:

public boolean continueReading(UncheckedBooleanSupplier maybeMoreDataSupplier) {
    return config.isAutoRead() &&
           (!respectMaybeMoreData || maybeMoreDataSupplier.get()) &&
           totalMessages < maxMessagePerRead &&
           totalBytesRead > 0;
}

幾個條件:

  1. 首先是否自動讀取。
  2. 且猜測是否還有更多資料,如果實際讀取的和預估的一致,說明可能還有資料沒讀,需要再次迴圈。
  3. 如果讀取次數為達到 16 次,繼續讀取。
  4. 如果讀取到的總數大於0,說明有資料,繼續讀取。

這裡的迴圈的主要原因就像我們剛剛說的,TCP 傳輸過大資料容易丟包(頻寬限制),因此會將大包分好幾次傳輸,還有就是可能預估的緩衝區不夠大,沒有充分讀取 Channel 的內容。

6. 總結

NioSocketChannel$NioSocketChannelUnsafe 的實現看 read 方法。每個 ByteBuf 都會由一個 Config 例項中的 ByteBufAllocator 物件建立,池化或非池化,直接記憶體或堆記憶體,這些都根據系統是否支援或引數設定,底層使用的是 NIO 的 API。今天我們看的是非池化的直接記憶體。同時,為了節省記憶體,為每個 ByteBufAllocator 配置了一個 handle,用於計算和預估緩衝區大小。

還有一個需要注意的地方就是 noCleaner 策略。這是 Netty 的一個優化。針對預設的直接記憶體建立和銷燬做了優化--------不使用 JDK 的 cleaner 策略。

最終讀取資料到封裝了 NIO ByteBuffer 例項的 Netty 的 ByteBuf 中,其中,如果資料量超過 1024,則會讀取超過兩次,但最多不超過 16 次, 這個次數可以設定,也就是說,可能會呼叫超過2次 fireChannelRead 方法,使用的時候需要注意(存起來一起在 ChannelReadComplete 使用之類的方法)。

好,關於 Netty 讀取 Socket 資料到容器中的邏輯,就到這裡。

good luck!!!

相關文章