Netty源碼分析系列之writeAndFlush()下

掃描下方二維碼或者微信搜索公衆號菜鳥飛呀飛,即可關注微信公衆號,閱讀更多Spring源碼分析Java併發編程Netty源碼系列文章。

微信公衆號

前言

在上一篇文章中(Netty 源碼分析系列之 writeAndFlush()上)分析了 netty 將數據寫出流程的前半部分:write()方法源碼,知道了在這個過程中,數據只是被存放到了 NioSocketChannel 對象的 ChannelOutboundBuffer 緩衝區中,還沒有被髮送到操作系統的套接字中。只有當調用了 flush()方法後,纔會真正將數據發送到套接字中。那麼 flush()方法的源碼又是如何執行的呢?這就是本文分析的重點。

還是以上篇文章的 demo 爲例,客戶端 channel 的 pipeline 的結構圖如下所示。(關於 demo 的代碼可以直接去查看上篇文章)

Demo示例pipeline結構圖

源碼分析

從 tail 節點的 writeAndFlush()方法開始,從 tail 節點的 write()方法向前傳播執行,當 write()方法執行完之後,就會調用 invokeFlush0() 方法,該方法就是觸發執行 flush()方法。上面的 pipeline 的結構中,由於 BizHandler 是 InBound 類型,在寫數據的過程中不會觸發執行它,另外由於 UserEncoder 我們沒有重寫 flush()方法,因此默認情況下,它啥也不幹,直接再往前一個節點傳播執行 flush 方法,因此最終調用的是 head 節點的 flush()方法。head 節點的 flush()方法源碼如下。

public void flush(ChannelHandlerContext ctx) {
    unsafe.flush();
}

直接調用的是 unsafe 對象的 flush()方法,由於此時是 NioSocketChannel 對象,因此 unsafe 是 NioSocketChannelUnsafe 對象的實例,又因爲 NioSocketChannelUnsafe 繼承自抽象類 AbstrractUnsafe,且 flush()方法定義在抽象類中,因此最終執行的是如下代碼。

public final void flush() {
    assertEventLoop();
    ChannelOutboundBuffer outboundBuffer = this.outboundBuffer;
    if (outboundBuffer == null) {
        return;
    }
    // 改變待寫隊列的指針指向,
    outboundBuffer.addFlush();
    flush0();
}

在上面的代碼片段中,主要有兩行核心邏輯代碼,第一處是調用 outboundBuffer 對象的 addFlush() 方法,改變寫隊列的三個指針指向;第二處是調用 flush0() 方法,真正將數據寫到套接字緩衝區中。下面詳細分析下這兩處核心邏輯。

在上一篇文章中((Netty 源碼分析系列之 writeAndFlush()上))我們從源碼中知道,outboundBuffer 這個緩衝區內部實際上是維護了一個隊列,這個隊列依靠三個指針來維護。初始狀態下,這三個指針都是指向 null,當調用一次 write()方法向 outboundBuffer 緩衝區中寫入一次數據對象後(這個數據對象會被封裝成一個 Entry),就會將 unflushedEntrytailEntry 這兩個指針指向剛剛寫進來的這個數據所代表的 Entry,而此時 flushedEntry 這個指針仍然是指向 null。

那麼什麼時候會改變 flushedEntry 指針的指向呢? 那就是當調用 addFlush() 方法的時候,就會改變 flushedEntry 指針。addFlush()方法的作用就是將前面 write()寫進到緩衝區的數據移動到刷新隊列中,即:將 unflushedEntry 指針指向的數據改變成由 flushedEntry 指針指向,只有 flushedEntry 指針指向的數據纔會真正地被髮送到套接字中。用文字描述可能有點難以理解,可以結合下面的圖來理解。

指針變化示意圖

(上一篇文章中也畫了一張三個指針變化的關係圖,但是由於疏忽,那張圖的最後一行的指針指向畫的不對,微信公衆號沒有提供修改的功能,所以閱讀到這兒朋友注意下。)

下面看下 addFlush()方法的具體源碼實現。

public void addFlush() {
    Entry entry = unflushedEntry;
    if (entry != null) {
        if (flushedEntry == null) {
            // 將flushed的指針指向第一個unFlushed的數據
            flushedEntry = entry;
        }
        do {
            // 記錄目前有多少個數據等待被Flush
            flushed ++;
            // 現將promise設置爲不可取消狀態
            if (!entry.promise.setUncancellable()) {
                int pending = entry.cancel();
                // 遞減待寫的字節數,如果待寫的字節數低於了最低水位線,那麼就將channel的寫狀態設置爲可寫狀態
                decrementPendingOutboundBytes(pending, false, true);
            }
            entry = entry.next;
        } while (entry != null);

        // 所有unFlushed的數據均已經被標識成flushed了,所以unFlushed可以指針可以指向null了
        unflushedEntry = null;
    }
}

這段代碼中,通過循環,將 unflushedEntry 指向的數據變爲由 flushedEntry 指向,由於 unflushedEntry 執行的是第一個被 write()寫進來的數據,因爲 write()可能被多次調用,這樣隊列中就會有多個數據,因此使用了 do…while 循環,這樣讓 flushedEntry 指針指向第一個數據,然後再使用 flushed 這個變量來記錄一下有多少個數據被從 unflushedEntry 指向的隊列中移動到了 flushedEntry 指向的隊列中,最後將 unflushedEntry 指向 null。

在數據被 write()進隊列之後,被 flush()之前,數據是可以被取消的,可以通過 promise.cancle() 方法,取消數據的寫出。但是當開始調用 flush()方法後,就不能再取消了,因爲這一步即將將數據寫入到操作系統的套接字中,所以再改變三個指針之前,需要將 promise 的狀態設置爲不可取消狀態:promise.setUncancellable()

當 setUncancellable()返回 false 時,表示的是 promise 之前已經被取消了,所以此時需要遞減待寫的字節數,如果緩衝區中待寫的字節數低於了最低水位線,那麼就將 channel 的寫狀態設置爲可寫狀態。(前面在調用 write()方法向緩衝區中寫數據時,會累計字節數,當超過最高水位線的時候,會將 channel 設置爲不可寫狀態)。這裏遞減字節數使用的方法是 decrementPendingOutboundBytes()方法,其中 netty 默認的最高水位線是 64KB,最低水位線是 32KB。

第一處邏輯:addFlush()方法分析完了,接下來分析第二處核心邏輯:flush0()方法。當調用 flush0()方法時,首先會調用 AbstractNioUnsafe 類的 flush0()方法。源碼如下。

protected final void flush0() {
	// 如果channel註冊了OP_WRITE事件,那麼就會返回true
    if (!isFlushPending()) {
        super.flush0();
    }
}

在該方法中會先判斷當前的 NioSocketChannel 是否註冊了 OP_WRITE 事件,如果註冊了 OP_WRITE 事件,那就不調用父類的 flush0()方法。如果沒有註冊,就調用父類的 flush0()方法。通過 isFlushPending()方法可以判斷當前 channel 是否註冊了 OP_WRITE 事件。其源碼如下。

private boolean isFlushPending() {
    SelectionKey selectionKey = selectionKey();
    return selectionKey.isValid() && (selectionKey.interestOps() & SelectionKey.OP_WRITE) != 0;
}

通常情況下,isFlushPending()方法會返回 false,因此會繼續調用父類的 flush0()方法,即會調用 AbstractUnsafe 類的 flush0()方法,該方法的源碼看着很長,實際上就一行核心邏輯:doWrite(outboundBuffer);由於當前的 Channel 是 NioSocketChannel,所以會調用 NioSocketChannel 類的 doWrite()方法,這個方法纔是真正將數據寫到套接字中。源碼很長,執行流程和邏輯見如下注釋。

protected void doWrite(ChannelOutboundBuffer in) throws Exception {
    // 獲取JDK原生的SocketChannel
    SocketChannel ch = javaChannel();
    // 獲取自旋寫的次數
    int writeSpinCount = config().getWriteSpinCount();
    do {
        if (in.isEmpty()) {
            clearOpWrite();
            return;
        }

        int maxBytesPerGatheringWrite = ((NioSocketChannelConfig) config).getMaxBytesPerGatheringWrite();
        // 將ByteBuf轉換成ByteBuffer. ByteBuffer是JDK中的類
        ByteBuffer[] nioBuffers = in.nioBuffers(1024, maxBytesPerGatheringWrite);
        /**
         * 獲取ByteBuffer[]數組中,真正有幾個ByteBuffer對象
         * (因爲數組的長度可能是1024,但是數組中大部分元素時空的,所以不能直接通過nioBuffers.length來獲取元素個數)
         * 而是需要通過in.nioBufferCount()來獲取,這個方法返回的是ChannelOutboundBuffer對象的nioBufferCount屬性值
         * 這個屬性值又是什麼時候被初始化的呢?是在上面調用in.nioBuffers()方法時進行賦值的
         */
        int nioBufferCnt = in.nioBufferCount();
        switch (nioBufferCnt) {
            case 0:
                // 如果ByteBuffers中沒有內容可寫,那麼就調用普通寫方法:doWrite0(),因爲可能還有其他的內容可以寫
                // 上面的in.nioBuffers()方法只會處理ByteBuf類型的數據,如果in中的數據全部不是ByteBuf類型,例如可能是FileRegion類型,
                // 那麼此時nioBufferCnt就會爲0,那麼我們就需要調用doWrite0()方法進行普通寫
                writeSpinCount -= doWrite0(in);
                break;
            case 1: {
                ByteBuffer buffer = nioBuffers[0];
                int attemptedBytes = buffer.remaining();
                // 採用原生的JDK的channel將數據寫出去
                // 如果數據沒有被寫出去(可能是因爲套接字的緩衝區滿了等原因),那麼就會返回一個小於等於0的數
                final int localWrittenBytes = ch.write(buffer);
                // 如果小於等於0,說明數據沒有被寫出去,那麼就需要將channel感興趣的時間設置爲OP_WRITE事件,方便下次多路複用器將channel輪詢出來的時候,能繼續寫數據
                if (localWrittenBytes <= 0) {
                    incompleteWrite(true);
                    return;
                }
                // 根據上次寫的數據量的大小,來調整下次可寫的最大字節數
                adjustMaxBytesPerGatheringWrite(attemptedBytes, localWrittenBytes, maxBytesPerGatheringWrite);
                // 將flushedEntry指針指向的數據清空
                in.removeBytes(localWrittenBytes);
                --writeSpinCount;
                break;
            }
            default: {
                // 當ByteBuffer[]數組中有多個ByteBuffer待寫的時候,就批量的寫
                long attemptedBytes = in.nioBufferSize();
                // 調用JDK原生NIO的API,將數據寫入到套接字中
                final long localWrittenBytes = ch.write(nioBuffers, 0, nioBufferCnt);
                if (localWrittenBytes <= 0) {
                    incompleteWrite(true);
                    return;
                }
                // Casting to int is safe because we limit the total amount of data in the nioBuffers to int above.
                // 根據上次寫的數據量的大小,來調整下次最大的字節數限制
                adjustMaxBytesPerGatheringWrite((int) attemptedBytes, (int) localWrittenBytes,
                        maxBytesPerGatheringWrite);
                // 清除已經寫出去的數據
                in.removeBytes(localWrittenBytes);
                --writeSpinCount;
                break;
            }
        }
    } while (writeSpinCount > 0);
    // 如果writeSpinCount的值小於0表示可能還有數據沒有被寫完,因此還需要繼續註冊OP_WRITE事件
    // 如果數據寫完了,writeSpinCount的值會等於0或者大於0
    incompleteWrite(writeSpinCount < 0);
}

首先獲取了 JDK 中原生 NIO 的 SocketChannel 對象,後面就是通過 JDK 的原生 API 將數據發送到套接字。

還從配置中獲取了自旋的次數,這個次數默認是 16 次,我們可以通過配置客戶端 channel 時,通過 DefaulSocketChannelConfig 對象配置。

這個自旋次數是用來幹什麼的呢?當我們使用 netty 向外發送數據時,有時候數據量比較少,但是有時候數據量會比較大。每次通過 JDK 的原生 API 向套接字中寫數據時,每次只能寫一部分。因此當數據量比較大的時候,我們需要分好幾次調用 JDK 原生 API,才能將我們要發送的數據全部發送完,但是如果數據量特別大,那麼循環調用 JDK 原生 API 的次數就越大,這樣就會一直讓 NioEventLoop 線程等在這兒,爲了讓其他任務得到 NioEventLoop 線程的執行,這裏就需要設置一個最大的循環次數了,也就是我們配置的這個自旋寫的次數,默認 16 次。當所有數據發送完成或者自旋次數超過 16 次時,這裏的循環寫均會被中斷。

然後會從配置中獲取最大能寫多少字節的數據:maxBytesPerGatheringWrite,第一次默認是 Integer.MAX_VALUE。後面這個值會動態的調整

在使用 JDK 的原生 API 寫數據時,只接受 ByteBuffer 類型的數據,而我們前面的數據時 ByteBuf 類型,所以接下來需要將 ByteBuf 轉換成 ByteBuffer 類型,怎麼轉換的呢?就是調用這一行代碼:in.nioBuffers()。這一行代碼後面分析。

接着通過 nioBufferCnt 的值,來判斷進入哪一個 switch 分支。當要寫出的數據對象不是 ByteBuf 時,例如文件類型(在 netty 中就是 FIleRegion 類型),nioBufferCnt 的值會爲 0,所以會調用 doWrite0()方法進行數據的寫出,最終也是通過 JDK 的原生 API 寫入套接字中,對於文件對象,最終會使用 tranferTo() 方法,這樣方法性能更優,具有零拷貝的特點。nioBufferCnt 爲 0 的情況很少,因此不是今天討論的重點。

當 nioBufferCnt 爲 1 或者大於 1 時,執行的邏輯幾乎一模一樣,區別是最終會使用 JDK 中 NioSocketChannel 不同的 write()方法。當爲 1 時,表示只有一個 ByteBuffer 對象可寫,因此調用的是 write(ByteBuffer buf) 方法,當大於 1 時,表示有多個 ByteBuffer 可以寫,因此調用的是 write(ByteBuffer[] srcs, int offset, int length) 方法。

由於兩個分支邏輯幾乎相同,因此選擇其中一個分支進行分析,我們選擇 case:1 這個分支。

ByteBuffer buffer = nioBuffers[0];
int attemptedBytes = buffer.remaining();
// 採用原生的JDK的channel將數據寫出去
// 如果數據沒有被寫出去(可能是因爲套接字的緩衝區滿了等原因),那麼就會返回一個小於等於0的數
final int localWrittenBytes = ch.write(buffer);
// 如果小於等於0,說明數據沒有被寫出去,那麼就需要將channel感興趣的時間設置爲OP_WRITE事件,方便下次多路複用器將channel輪詢出來的時候,能繼續寫數據
if (localWrittenBytes <= 0) {
    incompleteWrite(true);
    return;
}
// 根據上次寫的數據量的大小,來調整下次可寫的最大字節數
adjustMaxBytesPerGatheringWrite(attemptedBytes, localWrittenBytes, maxBytesPerGatheringWrite);
// 將flushedEntry指針指向的數據清空
in.removeBytes(localWrittenBytes);
--writeSpinCount;

先獲取到 buffer 中可寫的字節數,然後調用 JDK 原生 API:ch.write(buffer)將數據發送到套接字中,該 API 會返回成功寫出去的字節數,如果返回的數小於等於 0,說明數據沒有被寫出去,數據沒有被寫出去可能是因爲套接字的緩衝區滿了等原因,此時就需要將 channel 感興趣的事件設置爲 OP_WRITE 事件,方便下次多路複用器將 channel 輪詢出來的時候,能繼續寫數據,所以會調用 incompleteWrite(true) 方法。

protected final void incompleteWrite(boolean setOpWrite) {
    // 如果數據沒有寫完,就需要將channel感興趣的事件設置爲OP_WRITE事件
    if (setOpWrite) {
        setOpWrite();
    } else {
        // 如果數據已經寫完了,那就清除channel的OP_WRITE事件
        clearOpWrite();
        eventLoop().execute(flushTask);
    }
}

如果數據寫出去了,就會根據當前發送的數據量的大小,來動態調整最大字節數的限制。netty 可以說在數據的讀寫上將優化做到了極致,它會根據本次寫出去的數據量的大小來猜測下一次數據量的大小,如果本次寫出去的數據量大,那麼 netty 就會認爲你下次發送的數據會更大,就會將最大字節數限制擴大 2 倍;如果發送的數量小,netty 就認爲你的需求小,所以就會將最大字節數限制縮小 2 倍。

private void adjustMaxBytesPerGatheringWrite(int attempted, int written, int oldMaxBytesPerGatheringWrite) {
    // attempted 和written相等,那麼就表示要寫的數據全部寫完了,因此netty就認爲你要寫的數據量很大,下次可能還會寫更多的數據,
    // 因此將最大的字節數限制調整到當前數據量的兩倍,也就是attempted向左移一位
    if (attempted == written) {
        if (attempted << 1 > oldMaxBytesPerGatheringWrite) {
            ((NioSocketChannelConfig) config).setMaxBytesPerGatheringWrite(attempted << 1);
        }
    }
    // 如果已經的數據小於attempted的半,且attempted大於4096,那麼就將最大字節數限制縮小到attempted的一半。
    else if (attempted > MAX_BYTES_PER_GATHERING_WRITE_ATTEMPTED_LOW_THRESHOLD && written < attempted >>> 1) {
        ((NioSocketChannelConfig) config).setMaxBytesPerGatheringWrite(attempted >>> 1);
    }
}

將數據發送出去後,就需要改變 ChannelOutboundBuffer 這個緩衝區中 flushedEntry 的指針了。如何改變的呢?就是根據本次寫出去的字節數,來反算出 flushedEntry 指針指向的 Entry 中數據有沒有被寫完,如果沒有寫完,就不移動指針,等待下次自旋寫的時候會繼續發送這個 entry 裏面的數據。如果寫完了,就判斷 flushedEntry 隊列後面還有沒數據可寫,如果有則將指針指向下一個 entry,如果沒有,就指向 null。最後還會調用 decrementPendingOutboundBytes()方法來遞減緩衝區中的字節數,如果字節數低於最低水位線,那麼 channel 的狀態將會被設置成可寫狀態。這一段的邏輯全部邏輯都是在 in.removeBytes()這一行代碼中完成的,有興趣的朋友可以深入研究下。

最後將自旋次數 writeSpinCount 減 1。

當 do…while 執行完成後,如果 writeSpinCount 的值小於 0 表示可能還有數據沒有被寫完,因此還需要繼續註冊 OP_WRITE 事件,如果數據寫完了,writeSpinCount 的值會等於 0 或者大於 0。所以接下來會調用 incompleteWrite()方法,傳入的參數是一個 boolean 值,表示數據有沒有被寫完。該方法在前面已經分析了一半,現在分析後面一半邏輯。

protected final void incompleteWrite(boolean setOpWrite) {
    // 如果數據沒有寫完,就需要將channel感興趣的事件設置爲OP_WRITE事件
    if (setOpWrite) {
        setOpWrite();
    } else {
        // 如果數據已經寫完了,那就清除channel的OP_WRITE事件
        clearOpWrite();
        eventLoop().execute(flushTask);
    }
}

當傳入的參數爲 true 時,表示數據沒有寫完,就需要將 channel 感興趣的事件設置爲 OP_WRITE 事件。當傳入的參數爲 false 時,表示數據寫完了,需要清除 OP_WRITE 事件,同時讓 NioEventLoop 執行一個異步任務。

好了,doWrite()方法的源碼分析完了,下面來分析一下前面提到了 in.nioBuffers()方法的源碼。該方法的作用就是將 ByteBuf 轉變成 ByteBuffer(ByteBuf 是 netty 中的類,ByteBuffer 是 JDK 提供的類),方法的執行流程見下面代碼合注釋。

public ByteBuffer[] nioBuffers(int maxCount, long maxBytes) {
    assert maxCount > 0;
    assert maxBytes > 0;
    long nioBufferSize = 0;
    int nioBufferCount = 0;
    final InternalThreadLocalMap threadLocalMap = InternalThreadLocalMap.get();
    ByteBuffer[] nioBuffers = NIO_BUFFERS.get(threadLocalMap);
    Entry entry = flushedEntry;
    while (isFlushedEntry(entry) && entry.msg instanceof ByteBuf) {
        // 當數據被write之後,flush之前,是有可能被取消的,因此這裏需要判斷數據是否被取消
        if (!entry.cancelled) {
            ByteBuf buf = (ByteBuf) entry.msg;
            final int readerIndex = buf.readerIndex();
            // 數據的字節長度
            final int readableBytes = buf.writerIndex() - readerIndex;

            if (readableBytes > 0) {
                // 超過了最大限制且nioBufferCount的數量不爲0,則停止循環
                if (maxBytes - readableBytes < nioBufferSize && nioBufferCount != 0) {
                    break;
                }
                // 累計flush的字節數
                nioBufferSize += readableBytes;
                int count = entry.count;
                if (count == -1) {
                    // buf.nioBufferCount(),對於對外內存而言,會返回1
                    entry.count = count = buf.nioBufferCount();
                }
                // 判斷ByteBuffer數組是否需要擴容
                // neededSpace表示的是需要的大小,去maxCount和nioBufferCount + count 這兩者之間的最小值,是爲了保證需要的數組大小最大不能超過1024
                int neededSpace = min(maxCount, nioBufferCount + count);
                // 如果需要的ByteBuffer數組的大小大於了 nioBuffers現在的數數組長度,就進行擴容
                if (neededSpace > nioBuffers.length) {
                    // 將ByteBuffer數組擴容
                    nioBuffers = expandNioBufferArray(nioBuffers, neededSpace, nioBufferCount);
                    // 然後擴容後的ByteBuffer數組存放在ThreadLocal中,這樣就不用每次創建ByteBuffer數組了
                    NIO_BUFFERS.set(threadLocalMap, nioBuffers);
                }
                if (count == 1) {
                    ByteBuffer nioBuf = entry.buf;
                    if (nioBuf == null) {
                        // 將數據封裝成ByteBuffer
                        entry.buf = nioBuf = buf.internalNioBuffer(readerIndex, readableBytes);
                    }
                    nioBuffers[nioBufferCount++] = nioBuf;
                } else {
                    nioBufferCount = nioBuffers(entry, buf, nioBuffers, nioBufferCount, maxCount);
                }
                if (nioBufferCount == maxCount) {
                    break;
                }
            }
        }
        entry = entry.next;
    }
    this.nioBufferCount = nioBufferCount;
    this.nioBufferSize = nioBufferSize;

    return nioBuffers;
}

這段代碼很長,但主要乾了兩件事。第一,將 ByteBuf 轉變成 ByteBuffer;第二,累加 nioBufferCount 的數量。在這期間,會涉及到 ByteBuffer 數組的擴容,具體細節,可以參考上面代碼中的註釋。

推薦

總結

至此,writeAndFlush()方法的執行邏輯全部分析完了,本文主要分析的是 flush()方法的執行流程。綜合本文和上篇文章來看,pipeline 中的 head 節點十分重要,在它裏面實現了很多重要的功能,無論是 write()過程還是 flush()過程,都經過 head 節點做了最要的處理,最後通過 head 節點將數據發送到套接字。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章