一文搞懂 Netty 傳送資料全流程 | 你想知道的細節全在這裡

2022-07-07 12:08:02

歡迎關注公眾號:bin的技術小屋,如果大家在看文章的時候發現圖片載入不了,可以到公眾號檢視原文

本系列Netty原始碼解析文章基於 4.1.56.Final版本

《Netty如何高效接收網路資料》一文中,我們介紹了 Netty 的 SubReactor 處理網路資料讀取的完整過程,當 Netty 為我們讀取了網路請求資料,並且我們在自己的業務執行緒中完成了業務處理後,就需要將業務處理結果返回給使用者端了,那麼本文我們就來介紹下 SubReactor 如何處理網路資料傳送的整個過程。

我們都知道 Netty 是一款高效能的非同步事件驅動的網路通訊框架,既然是網路通訊框架那麼它主要做的事情就是:

  • 接收使用者端連線。

  • 讀取連線上的網路請求資料。

  • 向連線傳送網路響應資料。

前邊系列文章在介紹Netty的啟動以及接收連線的過程中,我們只看到 OP_ACCEPT 事件以及 OP_READ 事件的註冊,並未看到 OP_WRITE 事件的註冊。

  • 那麼在什麼情況下 Netty 才會向 SubReactor 去註冊 OP_WRITE 事件呢?

  • Netty 又是怎麼對寫操作做到非同步處理的呢?

本文筆者將會為大家一一揭曉這些謎底。我們還是以之前的 EchoServer 為例進行說明。

@Sharable
public class EchoServerHandler extends ChannelInboundHandlerAdapter {

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        //此處的msg就是Netty在read loop中從NioSocketChannel中讀取到的ByteBuffer
        ctx.write(msg);
    }

}

我們將在《Netty如何高效接收網路資料》一文中讀取到的 ByteBuffer (這裡的 Object msg),直接傳送回給使用者端,用這個簡單的例子來揭開 Netty 如何傳送資料的序幕~~

在實際開發中,我們首先要通過解碼器將讀取到的 ByteBuffer 解碼轉換為我們的業務 Request 類,然後在業務執行緒中做業務處理,在通過編碼器對業務 Response 類編碼為 ByteBuffer ,最後利用 ChannelHandlerContext ctx 的參照傳送響應資料。

本文我們只聚焦 Netty 寫資料的過程,對於 Netty 編解碼相關的內容,筆者會在後續的文章中專門介紹。

1. ChannelHandlerContext

通過前面幾篇文章的介紹,我們知道 Netty 會為每個 Channel 分配一個 pipeline ,pipeline 是一個雙向連結串列的結構。Netty 中產生的 IO 非同步事件會在這個 pipeline 中傳播。

Netty 中的 IO 非同步事件大體上分為兩類:

  • inbound事件:入站事件,比如前邊介紹的 ChannelActive 事件, ChannelRead 事件,它們會從 pipeline 的頭結點 HeadContext 開始一直向後傳播。

  • outbound事件:出站事件,比如本文中即將要介紹到的 write事件 以及 flush 事件,出站事件會從相反的方向從後往前傳播直到 HeadContext 。最終會在 HeadContext 中完成出站事件的處理。

    • 本例中用到的 channelHandlerContext.write() 會使 write 事件從當前 ChannelHandler 也就是這裡的 EchoServerHandler 開始沿著 pipeline 向前傳播。

    • 而 channelHandlerContext.channel().write() 則會使 write 事件從 pipeline 的尾結點 TailContext 開始向前傳播直到 HeadContext 。

而 pipeline 這樣一個雙向連結串列資料結構中的型別正是 ChannelHandlerContext ,由 ChannelHandlerContext 包裹我們自定義的 IO 處理邏輯 ChannelHandler。

ChannelHandler 並不需要感知到它所處的 pipeline 中的上下文資訊,只需要專心處理好 IO 邏輯即可,關於 pipeline 的上下文資訊全部封裝在 ChannelHandlerContext中。

ChannelHandler 在 Netty 中的作用只是負責處理 IO 邏輯,比如編碼,解碼。它並不會感知到它在 pipeline 中的位置,更不會感知和它相鄰的兩個 ChannelHandler。事實上 ChannelHandler也並不需要去關心這些,它唯一需要關注的就是處理所關心的非同步事件

而 ChannelHandlerContext 中維護了 pipeline 這個雙向連結串列中的 pre 以及 next 指標,這樣可以方便的找到與其相鄰的 ChannelHandler ,並可以過濾出一些符合執行條件的 ChannelHandler。正如它的命名一樣, ChannelHandlerContext 正是起到了維護 ChannelHandler 上下文的一個作用。而 Netty 中的非同步事件在 pipeline 中的傳播靠的就是這個 ChannelHandlerContext 。

這樣設計就使得 ChannelHandlerContext 和 ChannelHandler 的職責單一,各司其職,具有高度的可延伸性。

2. write事件的傳播

我們無論是在業務執行緒或者是在 SubReactor 執行緒中完成業務處理後,都需要通過 channelHandlerContext 的參照將 write事件在 pipeline 中進行傳播。然後在 pipeline 中相應的 ChannelHandler 中監聽 write 事件從而可以對 write事件進行自定義編排處理(比如我們常用的編碼器),最終傳播到 HeadContext 中執行傳送資料的邏輯操作。

前邊也提到 Netty 中有兩個觸發 write 事件傳播的方法,它們的傳播處理邏輯都是一樣的,只不過它們在 pipeline 中的傳播起點是不同的。

  • channelHandlerContext.write() 方法會從當前 ChannelHandler 開始在 pipeline 中向前傳播 write 事件直到 HeadContext。

  • channelHandlerContext.channel().write() 方法則會從 pipeline 的尾結點 TailContext 開始在 pipeline 中向前傳播 write 事件直到 HeadContext 。

在我們清楚了 write 事件的總體傳播流程後,接下來就來看看在 write 事件傳播的過程中Netty為我們作了些什麼?這裡我們以 channelHandlerContext.write() 方法為例說明。

3. write方法傳送資料

abstract class AbstractChannelHandlerContext implements ChannelHandlerContext, ResourceLeakHint {

    @Override
    public ChannelFuture write(Object msg) {
        return write(msg, newPromise());
    }

    @Override
    public ChannelFuture write(final Object msg, final ChannelPromise promise) {
        write(msg, false, promise);
        return promise;
    }

}

這裡我們看到 Netty 的寫操作是一個非同步操作,當我們在業務執行緒中呼叫 channelHandlerContext.write() 後,Netty 會給我們返回一個 ChannelFuture,我們可以在這個 ChannelFutrue 中新增 ChannelFutureListener ,這樣當 Netty 將我們要傳送的資料傳送到底層 Socket 中時,Netty 會通過 ChannelFutureListener 通知我們寫入結果。

    @Override
    public void channelRead(final ChannelHandlerContext ctx, final Object msg) {
        //此處的msg就是Netty在read loop中從NioSocketChannel中讀取到的ByteBuffer
        ChannelFuture future = ctx.write(msg);
        future.addListener(new ChannelFutureListener() {
            @Override
            public void operationComplete(ChannelFuture future) throws Exception {
                Throwable cause = future.cause();
                if (cause != null) {
                     處理異常情況
                } else {                    
                     寫入Socket成功後,Netty會通知到這裡
                }
            }
        });
}

當非同步事件在 pipeline 傳播的過程中發生異常時,非同步事件就會停止在 pipeline 中傳播。所以我們在日常開發中,需要對寫操作異常情況進行處理。

  • 其中 inbound 類非同步事件發生異常時,會觸發exceptionCaught事件傳播
    exceptionCaught 事件本身也是一種 inbound 事件,傳播方向會從當前發生異常的 ChannelHandler 開始一直向後傳播直到 TailContext。

  • 而 outbound 類非同步事件發生異常時,則不會觸發exceptionCaught事件傳播。一般只是通知相關 ChannelFuture。但如果是 flush 事件在傳播過程中發生異常,則會觸發當前發生異常的 ChannelHandler 中 exceptionCaught 事件回撥。

我們繼續迴歸到寫操作的主線上來~~~

    private void write(Object msg, boolean flush, ChannelPromise promise) {
        ObjectUtil.checkNotNull(msg, "msg");

        ................省略檢查promise的有效性...............

        //flush = true 表示channelHandler中呼叫的是writeAndFlush方法,這裡需要找到pipeline中覆蓋write或者flush方法的channelHandler
        //flush = false 表示呼叫的是write方法,只需要找到pipeline中覆蓋write方法的channelHandler
        final AbstractChannelHandlerContext next = findContextOutbound(flush ?
                (MASK_WRITE | MASK_FLUSH) : MASK_WRITE);
        //用於檢查記憶體洩露
        final Object m = pipeline.touch(msg, next);
        //獲取pipeline中下一個要被執行的channelHandler的executor
        EventExecutor executor = next.executor();
        //確保OutBound事件由ChannelHandler指定的executor執行
        if (executor.inEventLoop()) {
            //如果當前執行緒正是channelHandler指定的executor則直接執行
            if (flush) {
                next.invokeWriteAndFlush(m, promise);
            } else {
                next.invokeWrite(m, promise);
            }
        } else {
            //如果當前執行緒不是ChannelHandler指定的executor,則封裝成非同步任務提交給指定executor執行,注意這裡的executor不一定是reactor執行緒。
            final WriteTask task = WriteTask.newInstance(next, m, promise, flush);
            if (!safeExecute(executor, task, promise, m, !flush)) {
                task.cancel();
            }
        }
    }

write 事件要向前在 pipeline 中傳播,就需要在 pipeline 上找到下一個具有執行資格的 ChannelHandler,因為位於當前 ChannelHandler 前邊的可能是 ChannelInboundHandler 型別的也可能是 ChannelOutboundHandler 型別的 ChannelHandler ,或者有可能壓根就不關心 write 事件的 ChannelHandler(沒有實現write回撥方法)。

這裡我們就需要通過 findContextOutbound 方法在當前 ChannelHandler 的前邊找到 ChannelOutboundHandler 型別並且覆蓋實現 write 回撥方法的 ChannelHandler 作為下一個要執行的物件。

3.1 findContextOutbound

  private AbstractChannelHandlerContext findContextOutbound(int mask) {
        AbstractChannelHandlerContext ctx = this;
        //獲取當前ChannelHandler的executor
        EventExecutor currentExecutor = executor();
        do {
            //獲取前一個ChannelHandler
            ctx = ctx.prev;
        } while (skipContext(ctx, currentExecutor, mask, MASK_ONLY_OUTBOUND));
        return ctx;
    }
    //判斷前一個ChannelHandler是否具有響應Write事件的資格
    private static boolean skipContext(
            AbstractChannelHandlerContext ctx, EventExecutor currentExecutor, int mask, int onlyMask) {

        return (ctx.executionMask & (onlyMask | mask)) == 0 ||
                (ctx.executor() == currentExecutor && (ctx.executionMask & mask) == 0);
    }

findContextOutbound 方法接收的引數是一個掩碼,這個掩碼錶示要向前查詢具有什麼樣執行資格的 ChannelHandler。因為我們這裡呼叫的是 ChannelHandlerContext 的 write 方法所以 flush = false,傳遞進來的掩碼為 MASK_WRITE,表示我們要向前查詢覆蓋實現了 write 回撥方法的 ChannelOutboundHandler。

3.1.1 掩碼的巧妙應用

Netty 中將 ChannelHandler 覆蓋實現的一些非同步事件回撥方法用 int 型的掩碼來表示,這樣我們就可以通過這個掩碼來判斷當前 ChannelHandler 具有什麼樣的執行資格。

final class ChannelHandlerMask {
    ....................省略......................

    static final int MASK_CHANNEL_ACTIVE = 1 << 3;
    static final int MASK_CHANNEL_READ = 1 << 5;
    static final int MASK_CHANNEL_READ_COMPLETE = 1 << 6;
    static final int MASK_WRITE = 1 << 15;
    static final int MASK_FLUSH = 1 << 16;

   //outbound事件掩碼集合
   static final int MASK_ONLY_OUTBOUND =  MASK_BIND | MASK_CONNECT | MASK_DISCONNECT |
            MASK_CLOSE | MASK_DEREGISTER | MASK_READ | MASK_WRITE | MASK_FLUSH;
    ....................省略......................
}

在 ChannelHandler 被新增進 pipeline 的時候,Netty 會根據當前 ChannelHandler 的型別以及其覆蓋實現的非同步事件回撥方法,通過 | 運算 向 ChannelHandlerContext#executionMask 欄位新增該 ChannelHandler 的執行資格。

abstract class AbstractChannelHandlerContext implements ChannelHandlerContext, ResourceLeakHint {

    //ChannelHandler執行資格掩碼
    private final int executionMask;

    ....................省略......................
}

類似的掩碼用法其實我們在前邊的文章《一文聊透Netty核心引擎Reactor的運轉架構》中也提到過,在 Channel 向對應的 Reactor 註冊自己感興趣的 IO 事件時,也是用到了一個 int 型的掩碼 interestOps 來表示 Channel 感興趣的 IO 事件集合。

    @Override
    protected void doBeginRead() throws Exception {

        final SelectionKey selectionKey = this.selectionKey;
        if (!selectionKey.isValid()) {
            return;
        }

        readPending = true;

        final int interestOps = selectionKey.interestOps();
        /**
         * 1:ServerSocketChannel 初始化時 readInterestOp設定的是OP_ACCEPT事件
         * 2:SocketChannel 初始化時 readInterestOp設定的是OP_READ事件
         * */
        if ((interestOps & readInterestOp) == 0) {
            //註冊監聽OP_ACCEPT或者OP_READ事件
            selectionKey.interestOps(interestOps | readInterestOp);
        }
    }
  • 用 & 操作判斷,某個事件是否在事件集合中:(readyOps & SelectionKey.OP_CONNECT) != 0

  • 用 | 操作向事件集合中新增事件:interestOps | readInterestOp

  • 從事件集合中刪除某個事件,是通過先將要刪除事件取反 ~ ,然後在和事件集合做 & 操作:ops &= ~SelectionKey.OP_CONNECT

這部分內容筆者會在下篇文章全面介紹 pipeline 的時候詳細講解,大家這裡只需要知道這裡的掩碼就是表示一個執行資格的集合。當前 ChannelHandler 的執行資格存放在它的 ChannelHandlerContext 中的 executionMask 欄位中。

3.1.2 向前查詢具有執行資格的ChannelOutboundHandler

  private AbstractChannelHandlerContext findContextOutbound(int mask) {
        //當前ChannelHandler
        AbstractChannelHandlerContext ctx = this;
        //獲取當前ChannelHandler的executor
        EventExecutor currentExecutor = executor();
        do {
            //獲取前一個ChannelHandler
            ctx = ctx.prev;
        } while (skipContext(ctx, currentExecutor, mask, MASK_ONLY_OUTBOUND));
        return ctx;
    }

    //判斷前一個ChannelHandler是否具有響應Write事件的資格
    private static boolean skipContext(
            AbstractChannelHandlerContext ctx, EventExecutor currentExecutor, int mask, int onlyMask) {

        return (ctx.executionMask & (onlyMask | mask)) == 0 ||
                (ctx.executor() == currentExecutor && (ctx.executionMask & mask) == 0);
    }

前邊我們提到 ChannelHandlerContext 不僅封裝了 ChannelHandler 的執行資格掩碼還可以感知到當前 ChannelHandler 在 pipeline 中的位置,因為 ChannelHandlerContext 中維護了前驅指標 prev 以及後驅指標 next。

這裡我們需要在 pipeline 中傳播 write 事件,它是一種 outbound 事件,所以需要向前傳播,這裡通過 ChannelHandlerContext 的前驅指標 prev 拿到當前 ChannelHandler 在 pipeline 中的前一個節點。

ctx = ctx.prev;

通過 skipContext 方法判斷前驅節點是否具有執行的資格。如果沒有執行資格則跳過繼續向前查詢。如果具有執行資格則返回並響應 write 事件。

在 write 事件傳播場景中,執行資格指的是前驅 ChannelHandler 是否是ChannelOutboundHandler 型別的,並且它是否覆蓋實現了 write 事件回撥方法。


public class EchoChannelHandler extends ChannelOutboundHandlerAdapter {

    @Override
    public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
        super.write(ctx, msg, promise);
    }
}

3.1.3 skipContext

該方法主要用來判斷當前 ChannelHandler 的前驅節點是否具有 mask 掩碼中包含的事件響應資格。

方法引數中有兩個比較重要的掩碼:

  • int onlyMask:用來指定當前 ChannelHandler 需要符合的型別。其中MASK_ONLY_OUTBOUND 為 ChannelOutboundHandler 型別的掩碼, MASK_ONLY_INBOUND 為 ChannelInboundHandler 型別的掩碼。
final class ChannelHandlerMask {

    //outbound事件的掩碼集合
    static final int MASK_ONLY_OUTBOUND =  MASK_BIND | MASK_CONNECT | MASK_DISCONNECT |
            MASK_CLOSE | MASK_DEREGISTER | MASK_READ | MASK_WRITE | MASK_FLUSH;

    //inbound事件的掩碼集合
    static final int MASK_ONLY_INBOUND =  MASK_CHANNEL_REGISTERED |
            MASK_CHANNEL_UNREGISTERED | MASK_CHANNEL_ACTIVE | MASK_CHANNEL_INACTIVE | MASK_CHANNEL_READ |
            MASK_CHANNEL_READ_COMPLETE | MASK_USER_EVENT_TRIGGERED | MASK_CHANNEL_WRITABILITY_CHANGED;
}

比如本小節中我們是在介紹 write 事件的傳播,那麼就需要在當前ChannelHandler 前邊首先是找到一個 ChannelOutboundHandler 型別的ChannelHandler。

ctx.executionMask & (onlyMask | mask)) == 0 用於判斷前一個 ChannelHandler 是否為我們指定的 ChannelHandler 型別,在本小節中我們指定的是 onluMask = MASK_ONLY_OUTBOUND 即 ChannelOutboundHandler 型別。如果不是,這裡就會直接跳過,繼續在 pipeline 中向前查詢。

  • int mask:用於指定前一個 ChannelHandler 需要實現的相關非同步事件處理回撥。在本小節中這裡指定的是 MASK_WRITE ,即需要實現 write 回撥方法。通過 (ctx.executionMask & mask) == 0 條件來判斷前一個ChannelHandler 是否實現了 write 回撥,如果沒有實現這裡就跳過,繼續在 pipeline 中向前查詢。

關於 skipContext 方法的詳細介紹,筆者還會在下篇文章全面介紹 pipeline的時候再次進行介紹,這裡大家只需要明白該方法的核心邏輯即可。

3.1.4 向前傳播write事件

通過 findContextOutbound 方法我們在 pipeline 中找到了下一個具有執行資格的 ChannelHandler,這裡指的是下一個 ChannelOutboundHandler 型別並且覆蓋實現了 write 方法的 ChannelHandler。

Netty 緊接著會呼叫這個 nextChannelHandler 的 write 方法實現 write 事件在 pipeline 中的傳播。

        //獲取下一個要被執行的channelHandler指定的executor
        EventExecutor executor = next.executor();
        //確保outbound事件的執行 是由 channelHandler指定的executor執行的
        if (executor.inEventLoop()) {
            //如果當前執行緒是指定的executor 則直接操作
            if (flush) {
                next.invokeWriteAndFlush(m, promise);
            } else {
                next.invokeWrite(m, promise);
            }
        } else {
            //如果當前執行緒不是channelHandler指定的executor,則封裝程非同步任務 提交給指定的executor執行
            final WriteTask task = WriteTask.newInstance(next, m, promise, flush);
            if (!safeExecute(executor, task, promise, m, !flush)) {
                task.cancel();
            }
        }

在我們向 pipeline 新增 ChannelHandler 的時候可以通過ChannelPipeline#addLast(EventExecutorGroup,ChannelHandler......) 方法指定執行該 ChannelHandler 的executor。如果不特殊指定,那麼執行該 ChannelHandler 的executor預設為該 Channel 繫結的 Reactor 執行緒。

執行 ChannelHandler 中非同步事件回撥方法的執行緒必須是 ChannelHandler 指定的executor。

所以這裡首先我們需要獲取在 findContextOutbound 方法查詢出來的下一個符合執行條件的 ChannelHandler 指定的executor。

EventExecutor executor = next.executor()

並通過 executor.inEventLoop() 方法判斷當前執行緒是否是該 ChannelHandler 指定的 executor。

如果是,那麼我們直接在當前執行緒中執行 ChannelHandler 中的 write 方法。

如果不是,我們就需要將 ChannelHandler 對 write 事件的回撥操作封裝成非同步任務 WriteTask 並提交給 ChannelHandler 指定的 executor 中,由 executor 負責執行。

這裡需要注意的是這個 executor 並不一定是 channel 繫結的 reactor 執行緒。它可以是我們自定義的執行緒池,不過需要我們通過 ChannelPipeline#addLast 方法進行指定,如果我們不指定,預設情況下執行 ChannelHandler 的 executor 才是 channel 繫結的 reactor 執行緒。

這裡Netty需要確保 outbound 事件是由 channelHandler 指定的 executor 執行的。

這裡有些同學可能會有疑問,如果我們向pipieline新增ChannelHandler的時候,為每個ChannelHandler指定不同的executor時,Netty如果確保執行緒安全呢??

大家還記得pipeline中的結構嗎?

outbound 事件在 pipeline 中的傳播最終會傳播到 HeadContext 中,之前的系列文章我們提到過,HeadContext 中封裝了 Channel 的 Unsafe 類負責 Channel 底層的 IO 操作。而 HeadContext 指定的 executor 正是對應 channel 繫結的 reactor 執行緒。

所以最終在 netty 核心中執行寫操作的執行緒一定是 reactor 執行緒從而保證了執行緒安全性。

忘記這段內容的同學可以在回顧下《Reactor在Netty中的實現(建立篇)》,類似的套路我們在介紹 NioServerSocketChannel 進行 bind 繫結以及 register 註冊的時候都介紹過,只不過這裡將 executor 擴充套件到了自定義執行緒池的範圍。

3.1.5 觸發nextChannelHandler的write方法回撥

            //如果當前執行緒是指定的executor 則直接操作
            if (flush) {
                next.invokeWriteAndFlush(m, promise);
            } else {
                next.invokeWrite(m, promise);
            }

由於我們在範例 ChannelHandler 中呼叫的是 ChannelHandlerContext#write 方法,所以這裡的 flush = false 。觸發呼叫 nextChannelHandler 的 write 方法。

    void invokeWrite(Object msg, ChannelPromise promise) {
        if (invokeHandler()) {
            invokeWrite0(msg, promise);
        } else {
            // 當前channelHandler雖然新增到pipeline中,但是並沒有呼叫handlerAdded
            // 所以不能呼叫當前channelHandler中的回撥方法,只能繼續向前傳遞write事件
            write(msg, promise);
        }
    }

這裡首先需要通過 invokeHandler() 方法判斷這個 nextChannelHandler 中的 handlerAdded 方法是否被回撥過。因為 ChannelHandler 只有被正確的新增到對應的 ChannelHandlerContext 中並且準備好處理非同步事件時, ChannelHandler#handlerAdded 方法才會被回撥。

這一部分內容筆者會在下一篇文章中詳細為大家介紹,這裡大家只需要瞭解呼叫 invokeHandler() 方法的目的就是為了確定 ChannelHandler 是否被正確的初始化。

    private boolean invokeHandler() {
        // Store in local variable to reduce volatile reads.
        int handlerState = this.handlerState;
        return handlerState == ADD_COMPLETE || (!ordered && handlerState == ADD_PENDING);
    }

只有觸發了 handlerAdded 回撥,ChannelHandler 的狀態才能變成 ADD_COMPLETE 。

如果 invokeHandler() 方法返回 false,那麼我們就需要跳過這個nextChannelHandler,並呼叫 ChannelHandlerContext#write 方法繼續向前傳播 write 事件。

    @Override
    public ChannelFuture write(final Object msg, final ChannelPromise promise) {
        //繼續向前傳播write事件,回到流程起點
        write(msg, false, promise);
        return promise;
    }

如果 invokeHandler() 返回 true ,說明這個 nextChannelHandler 已經在 pipeline 中被正確的初始化了,Netty 直接呼叫這個 ChannelHandler 的 write 方法,這樣就實現了 write 事件從當前 ChannelHandler 傳播到了nextChannelHandler。

    private void invokeWrite0(Object msg, ChannelPromise promise) {
        try {
            //呼叫當前ChannelHandler中的write方法
            ((ChannelOutboundHandler) handler()).write(this, msg, promise);
        } catch (Throwable t) {
            notifyOutboundHandlerException(t, promise);
        }
    }

這裡我們看到在 write 事件的傳播過程中如果發生異常,那麼 write 事件就會停止在 pipeline 中傳播,並通知註冊的 ChannelFutureListener。

從本文範例的 pipeline 結構中我們可以看到,當在 EchoServerHandler 呼叫 ChannelHandlerContext#write 方法後,write 事件會在 pipeline 中向前傳播到 HeadContext 中,而在 HeadContext 中才是 Netty 真正處理 write 事件的地方。

3.2 HeadContext

final class HeadContext extends AbstractChannelHandlerContext
            implements ChannelOutboundHandler, ChannelInboundHandler {
          
        @Override
        public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) {
            unsafe.write(msg, promise);
        }
 }

write 事件最終會在 pipeline 中傳播到 HeadContext 裡並回撥 HeadContext 的 write 方法。並在 write 回撥中呼叫 channel 的 unsafe 類執行底層的 write 操作。這裡正是 write 事件在 pipeline 中的傳播終點。

 protected abstract class AbstractUnsafe implements Unsafe {
        //待傳送資料緩衝佇列  Netty是全非同步框架,所以這裡需要一個緩衝佇列來快取使用者需要傳送的資料 
        private volatile ChannelOutboundBuffer outboundBuffer = new ChannelOutboundBuffer(AbstractChannel.this);
  
        @Override
        public final void write(Object msg, ChannelPromise promise) {
            assertEventLoop();
            //獲取當前channel對應的待傳送資料緩衝佇列(支援使用者非同步寫入的核心關鍵)
            ChannelOutboundBuffer outboundBuffer = this.outboundBuffer;

            ..........省略..................

            int size;
            try {
                //過濾message型別 這裡只會接受DirectBuffer或者fileRegion型別的msg
                msg = filterOutboundMessage(msg);
                //計算當前msg的大小
                size = pipeline.estimatorHandle().size(msg);
                if (size < 0) {
                    size = 0;
                }
            } catch (Throwable t) {
              ..........省略..................
            }
            //將msg 加入到Netty中的待寫入資料緩衝佇列ChannelOutboundBuffer中
            outboundBuffer.addMessage(msg, size, promise);
        }
    
 }

眾所周知 Netty 是一個非同步事件驅動的網路框架,在 Netty 中所有的 IO 操作全部都是非同步的,當然也包括本小節介紹的 write 操作,為了保證非同步執行 write 操作,Netty 定義了一個待傳送資料緩衝佇列 ChannelOutboundBuffer ,Netty 將這些使用者需要傳送的網路資料在寫入到 Socket 之前,先放在 ChannelOutboundBuffer 中快取。

每個使用者端 NioSocketChannel 對應一個 ChannelOutboundBuffer 待傳送資料緩衝佇列

3.2.1 filterOutboundMessage

ChannelOutboundBuffer 只會接受 ByteBuffer 型別以及 FileRegion 型別的 msg 資料。

FileRegion 是Netty定義的用來通過零拷貝的方式網路傳輸檔案資料。本文我們主要聚焦普通網路資料 ByteBuffer 的傳送。

所以在將 msg 寫入到 ChannelOutboundBuffer 之前,我們需要檢查待寫入 msg 的型別。確保是 ChannelOutboundBuffer 可接受的型別。

    @Override
    protected final Object filterOutboundMessage(Object msg) {
        if (msg instanceof ByteBuf) {
            ByteBuf buf = (ByteBuf) msg;
            if (buf.isDirect()) {
                return msg;
            }

            return newDirectBuffer(buf);
        }

        if (msg instanceof FileRegion) {
            return msg;
        }

        throw new UnsupportedOperationException(
                "unsupported message type: " + StringUtil.simpleClassName(msg) + EXPECTED_TYPES);
    }

在網路資料傳輸的過程中,Netty為了減少資料從 堆內記憶體 到 堆外記憶體 的拷貝以及緩解GC的壓力,所以這裡必須採用 DirectByteBuffer 使用堆外記憶體來存放網路傳送資料。

3.2.2 estimatorHandle計算當前msg的大小

public class DefaultChannelPipeline implements ChannelPipeline {
    //原子更新estimatorHandle欄位
    private static final AtomicReferenceFieldUpdater<DefaultChannelPipeline, MessageSizeEstimator.Handle> ESTIMATOR =
            AtomicReferenceFieldUpdater.newUpdater(
                    DefaultChannelPipeline.class, MessageSizeEstimator.Handle.class, "estimatorHandle");

    //計算要傳送msg大小的handler
    private volatile MessageSizeEstimator.Handle estimatorHandle;

    final MessageSizeEstimator.Handle estimatorHandle() {
        MessageSizeEstimator.Handle handle = estimatorHandle;
        if (handle == null) {
            handle = channel.config().getMessageSizeEstimator().newHandle();
            if (!ESTIMATOR.compareAndSet(this, null, handle)) {
                handle = estimatorHandle;
            }
        }
        return handle;
    }
}

在 pipeline 中會有一個 estimatorHandle 專門用來計算待傳送 ByteBuffer 的大小。這個 estimatorHandle 會在 pipeline 對應的 Channel 中的設定類建立的時候被初始化。

這裡 estimatorHandle 的實際型別為DefaultMessageSizeEstimator#HandleImpl

public final class DefaultMessageSizeEstimator implements MessageSizeEstimator {

    private static final class HandleImpl implements Handle {
        private final int unknownSize;

        private HandleImpl(int unknownSize) {
            this.unknownSize = unknownSize;
        }

        @Override
        public int size(Object msg) {
            if (msg instanceof ByteBuf) {
                return ((ByteBuf) msg).readableBytes();
            }
            if (msg instanceof ByteBufHolder) {
                return ((ByteBufHolder) msg).content().readableBytes();
            }
            if (msg instanceof FileRegion) {
                return 0;
            }
            return unknownSize;
        }
    }

這裡我們看到 ByteBuffer 的大小即為 Buffer 中未讀取的位元組數 writerIndex - readerIndex 。

當我們驗證了待寫入資料 msg 的型別以及計算了 msg 的大小後,我們就可以通過 ChannelOutboundBuffer#addMessage方法將 msg 寫入到ChannelOutboundBuffer(待傳送資料緩衝佇列)中。

write 事件處理的最終邏輯就是將待傳送資料寫入到 ChannelOutboundBuffer 中,下面我們就來看下這個 ChannelOutboundBuffer 內部結構到底是什麼樣子的?

3.3 ChannelOutboundBuffer

ChannelOutboundBuffer 其實是一個單連結串列結構的緩衝佇列,連結串列中的節點型別為 Entry ,由於 ChannelOutboundBuffer 在 Netty 中的作用就是快取應用程式待傳送的網路資料,所以 Entry 中封裝的就是待寫入 Socket 中的網路傳送資料相關的資訊,以及 ChannelHandlerContext#write 方法中返回給使用者的 ChannelPromise 。這樣可以在資料寫入Socket之後非同步通知應用程式。

此外 ChannelOutboundBuffer 中還封裝了三個重要的指標:

  • unflushedEntry :該指標指向 ChannelOutboundBuffer 中第一個待傳送資料的 Entry。

  • tailEntry :該指標指向 ChannelOutboundBuffer 中最後一個待傳送資料的 Entry。通過 unflushedEntry 和 tailEntry 這兩個指標,我們可以很方便的定位到待傳送資料的 Entry 範圍。

  • flushedEntry :當我們通過 flush 操作需要將 ChannelOutboundBuffer 中快取的待傳送資料傳送到 Socket 中時,flushedEntry 指標會指向 unflushedEntry 的位置,這樣 flushedEntry 指標和 tailEntry 指標之間的 Entry 就是我們即將傳送到 Socket 中的網路資料。

這三個指標在初始化的時候均為 null 。

3.3.1 Entry

Entry 作為 ChannelOutboundBuffer 連結串列結構中的節點元素型別,裡邊封裝了待傳送資料的各種資訊,ChannelOutboundBuffer 其實就是對 Entry 結構的組織和操作。因此理解 Entry 結構是理解整個 ChannelOutboundBuffer 運作流程的基礎。

下面我們就來看下 Entry 結構具體封裝了哪些待傳送資料的資訊。

    static final class Entry {
        //Entry的物件池,用來建立和回收Entry物件
        private static final ObjectPool<Entry> RECYCLER = ObjectPool.newPool(new ObjectCreator<Entry>() {
            @Override
            public Entry newObject(Handle<Entry> handle) {
                return new Entry(handle);
            }
        });

        //DefaultHandle用於回收物件
        private final Handle<Entry> handle;
        //ChannelOutboundBuffer下一個節點
        Entry next;
        //待傳送資料
        Object msg;
        //msg 轉換為 jdk nio 中的byteBuffer
        ByteBuffer[] bufs;
        ByteBuffer buf;
        //非同步write操作的future
        ChannelPromise promise;
        //已傳送了多少
        long progress;
        //總共需要傳送多少,不包含entry物件大小。
        long total;
        //pendingSize表示entry物件在堆中需要的記憶體總量 待傳送資料大小 + entry物件本身在堆中佔用記憶體大小(96)
        int pendingSize;
        //msg中包含了幾個jdk nio bytebuffer
        int count = -1;
        //write操作是否被取消
        boolean cancelled;
}

我們看到Entry結構中一共有12個欄位,其中1個靜態欄位和11個範例欄位。

下面筆者就為大家介紹下這12個欄位的含義及其作用,其中有些欄位會在後面的場景中使用到,這裡大家可能對有些欄位理解起來比較模糊,不過沒關係,這裡能看懂多少是多少,不理解也沒關係,這裡介紹只是為了讓大家混個眼熟,在後面流程的講解中,筆者還會重新提到這些欄位。

  • ObjectPool<Entry> RECYCLER:Entry 的物件池,負責建立管理 Entry 範例,由於 Netty 是一個網路框架,所以 IO 讀寫就成了它的核心操作,在一個支援高效能高吞吐的網路框架中,會有大量的 IO 讀寫操作,那麼就會導致頻繁的建立 Entry 物件。我們都知道,建立一個範例物件以及 GC 回收這些範例物件都是需要效能開銷的,那麼在大量頻繁建立 Entry 物件的場景下,引入物件池來複用建立好的 Entry 物件範例可以抵消掉由頻繁建立物件以及GC回收物件所帶來的效能開銷。

關於物件池的詳細內容,感興趣的同學可以回看下筆者的這篇文章《詳解Recycler物件池的精妙設計與實現》

  • Handle<Entry> handle:預設實現型別為 DefaultHandle ,用於資料傳送完畢後,物件池回收 Entry 物件。由物件池 RECYCLER 在建立 Entry 物件的時候傳遞進來。

  • Entry next:ChannelOutboundBuffer 是一個單連結串列的結構,這裡的 next 指標用於指向當前 Entry 節點的後繼節點。

  • Object msg:應用程式待傳送的網路資料,這裡 msg 的型別為 DirectByteBuffer 或者 FileRegion(用於通過零拷貝的方式網路傳輸檔案)。

  • ByteBuffer[] bufs:這裡的 ByteBuffer 型別為 JDK NIO 原生的 ByteBuffer 型別,因為 Netty 最終傳送資料是通過 JDK NIO 底層的 SocketChannel 進行傳送,所以需要將 Netty 中實現的 ByteBuffer 型別轉換為 JDK NIO ByteBuffer 型別。應用程式傳送的 ByteBuffer 可能是一個也可能是多個,如果傳送多個就用 ByteBuffer[] bufs 封裝在 Entry 物件中,如果是一個就用 ByteBuffer buf 封裝。

  • int count :表示待傳送資料 msg 中一共包含了多少個 ByteBuffer 需要傳送。

  • ChannelPromise promise:ChannelHandlerContext#write 非同步寫操作返回的 ChannelFuture。當 Netty 將待傳送資料寫入到 Socket 中時會通過這個 ChannelPromise 通知應用程式傳送結果。

  • long progress:表示當前的一個傳送進度,已經傳送了多少資料。

  • long total:Entry中總共需要傳送多少資料。注意:這個欄位並不包含 Entry 物件的記憶體佔用大小。只是表示待傳送網路資料的大小。

  • boolean cancelled:應用程式呼叫的 write 操作是否被取消。

  • int pendingSize:表示待傳送資料的記憶體佔用總量。待傳送資料在記憶體中的佔用量分為兩部分:

    • Entry物件中所封裝的待傳送網路資料大小。
    • Entry物件本身在記憶體中的佔用量。

3.3.2 pendingSize的作用

想象一下這樣的一個場景,當由於網路擁塞或者 Netty 使用者端負載很高導致網路資料的接收速度以及處理速度越來越慢,TCP 的滑動視窗不斷縮小以減少網路資料的傳送直到為 0,而 Netty 伺服器端卻有大量頻繁的寫操作,不斷的寫入到 ChannelOutboundBuffer 中。

這樣就導致了資料傳送不出去但是 Netty 伺服器端又在不停的寫資料,慢慢的就會撐爆 ChannelOutboundBuffer 導致OOM。這裡主要指的是堆外記憶體的 OOM,因為 ChannelOutboundBuffer 中包裹的待傳送資料全部儲存在堆外記憶體中。

所以 Netty 就必須限制 ChannelOutboundBuffer 中的待傳送資料的記憶體佔用總量,不能讓它無限增長。Netty 中定義了高低水位線用來表示 ChannelOutboundBuffer 中的待傳送資料的記憶體佔用量的上限和下限。注意:這裡的記憶體既包括 JVM 堆記憶體佔用也包括堆外記憶體佔用。

  • 當待傳送資料的記憶體佔用總量超過高水位線的時候,Netty 就會將 NioSocketChannel 的狀態標記為不可寫狀態。否則就可能導致 OOM。

  • 當待傳送資料的記憶體佔用總量低於低水位線的時候,Netty 會再次將 NioSocketChannel 的狀態標記為可寫狀態。

那麼我們用什麼記錄ChannelOutboundBuffer中的待傳送資料的記憶體佔用總量呢

答案就是本小節要介紹的 pendingSize 欄位。在談到待傳送資料的記憶體佔用量時大部分同學普遍都會有一個誤解就是隻計算待傳送資料的大小(msg中包含的位元組數) 而忽略了 Entry 範例物件本身在記憶體中的佔用量。

因為 Netty 會將待傳送資料封裝在 Entry 範例物件中,在大量頻繁的寫操作中會產生大量的 Entry 範例物件,所以 Entry 範例物件的記憶體佔用是不可忽視的。

否則就會導致明明還沒有到達高水位線,但是由於大量的 Entry 範例物件存在,從而發生OOM。

所以 pendingSize 的計算既要包含待傳送資料的大小也要包含其 Entry 範例物件的記憶體佔用大小,這樣才能準確計算出 ChannelOutboundBuffer 中待傳送資料的記憶體佔用總量。

ChannelOutboundBuffer 中所有的 Entry 範例中的 pendingSize 之和就是待傳送資料總的記憶體佔用量。

public final class ChannelOutboundBuffer {
  //ChannelOutboundBuffer中的待傳送資料的記憶體佔用總量
  private volatile long totalPendingSize;

}

3.3.3 高低水位線

上小節提到 Netty 為了防止 ChannelOutboundBuffer 中的待傳送資料記憶體佔用無限制的增長從而導致 OOM ,所以引入了高低水位線,作為待傳送資料記憶體佔用的上限和下限。

那麼高低水位線具體設定多大呢 ? 我們來看一下 DefaultChannelConfig 中的設定。

public class DefaultChannelConfig implements ChannelConfig {

    //ChannelOutboundBuffer中的高低水位線
    private volatile WriteBufferWaterMark writeBufferWaterMark = WriteBufferWaterMark.DEFAULT;

}
public final class WriteBufferWaterMark {

    private static final int DEFAULT_LOW_WATER_MARK = 32 * 1024;
    private static final int DEFAULT_HIGH_WATER_MARK = 64 * 1024;

    public static final WriteBufferWaterMark DEFAULT =
            new WriteBufferWaterMark(DEFAULT_LOW_WATER_MARK, DEFAULT_HIGH_WATER_MARK, false);

    WriteBufferWaterMark(int low, int high, boolean validate) {

        ..........省略校驗邏輯.........

        this.low = low;
        this.high = high;
    }
}

我們看到 ChannelOutboundBuffer 中的高水位線設定的大小為 64 KB,低水位線設定的是 32 KB。

這也就意味著每個 Channel 中的待傳送資料如果超過 64 KB。Channel 的狀態就會變為不可寫狀態。當記憶體佔用量低於 32 KB時,Channel 的狀態會再次變為可寫狀態。

3.3.4 Entry範例物件在JVM中佔用記憶體大小

前邊提到 pendingSize 的作用主要是記錄當前待傳送資料的記憶體佔用總量從而可以預警 OOM 的發生。

待傳送資料的記憶體佔用分為:待傳送資料 msg 的記憶體佔用大小以及 Entry 物件本身在JVM中的記憶體佔用。

那麼 Entry 物件本身的記憶體佔用我們該如何計算呢?

要想搞清楚這個問題,大家需要先了解一下 Java 物件記憶體佈局的相關知識。關於這部分背景知識,筆者已經在 《一文聊透物件在JVM中的記憶體佈局,以及記憶體對齊和壓縮指標的原理及應用》這篇文章中給出了詳盡的闡述,想深入瞭解這塊的同學可以看下這篇文章。

這裡筆者只從這篇文章中提煉一些關於計算 Java 物件佔用記憶體大小相關的內容。

在關於 Java 物件記憶體佈局這篇文章中我們提到,對於Java普通物件來說記憶體中的佈局由:物件頭 + 範例資料區 + Padding,這三部分組成。

其中物件頭由儲存物件執行時資訊的 MarkWord 以及指向物件型別元資訊的型別指標組成。

MarkWord 用來存放:hashcode,GC 分代年齡,鎖狀態標誌,執行緒持有的鎖,偏向執行緒 Id,偏向時間戳等。在 32 位元運算系統和 64 位元運算系統中 MarkWord 分別佔用 4B 和 8B 大小的記憶體。

Java 物件頭中的型別指標還有範例資料區的物件參照,在64 位系統中開啟壓縮指標的情況下(-XX:+UseCompressedOops)佔用 4B 大小。在關閉壓縮指標的情況下(-XX:-UseCompressedOops)佔用 8B 大小。

範例資料區用於儲存 Java 類中定義的範例欄位,包括所有父類別中的範例欄位以及物件參照。

在範例資料區中物件欄位之間的排列以及記憶體對齊需要遵循三個欄位重排列規則:

  • 規則1:如果一個欄位佔用X個位元組,那麼這個欄位的偏移量OFFSET需要對齊至NX

  • 規則2:在開啟了壓縮指標的 64 位 JVM 中,Java 類中的第一個欄位的 OFFSET 需要對齊至 4N,在關閉壓縮指標的情況下類中第一個欄位的OFFSET需要對齊至 8N

  • 規則3:JVM 預設分配欄位的順序為:long / double,int / float,short / char,byte / boolean,oops(Ordianry Object Point 參照型別指標),並且父類別中定義的範例變數會出現在子類範例變數之前。當設定JVM引數 -XX +CompactFields 時(預設),佔用記憶體小於 long / double 的欄位會允許被插入到物件中第一個 long / double 欄位之前的間隙中,以避免不必要的記憶體填充。

還有一個重要規則就是 Java 虛擬機器器堆中物件的起始地址需要對齊至 8 的倍數(可由JVM引數 -XX:ObjectAlignmentInBytes 控制,預設為 8 )。

在瞭解上述欄位排列以及物件之間的記憶體對齊規則後,我們分別以開啟壓縮指標和關閉壓縮指標兩種情況,來對 Entry 物件的記憶體佈局進行分析並計算物件佔用記憶體大小。

   static final class Entry {
        .............省略static欄位RECYCLER.........

        //DefaultHandle用於回收物件
        private final Handle<Entry> handle;
        //ChannelOutboundBuffer下一個節點
        Entry next;
        //待傳送資料
        Object msg;
        //msg 轉換為 jdk nio 中的byteBuffer
        ByteBuffer[] bufs;
        ByteBuffer buf;
        //非同步write操作的future
        ChannelPromise promise;
        //已傳送了多少
        long progress;
        //總共需要傳送多少,不包含entry物件大小。
        long total;
        //pendingSize表示entry物件在堆中需要的記憶體總量 待傳送資料大小 + entry物件本身在堆中佔用記憶體大小(96)
        int pendingSize;
        //msg中包含了幾個jdk nio bytebuffer
        int count = -1;
        //write操作是否被取消
        boolean cancelled;
}

我們看到 Entry 物件中一共有 11 個範例欄位,其中 2 個 long 型欄位,2 個 int 型欄位,1 個 boolean 型欄位,6 個物件參照。

預設情況下JVM引數 -XX +CompactFields 是開啟的。

開啟指標壓縮 -XX:+UseCompressedOops

Entry 物件的記憶體佈局中開頭先是 8 個位元組的 MarkWord,然後是 4 個位元組的型別指標(開啟壓縮指標)。

在範例資料區中物件的排列規則需要符合規則3,也就是欄位之間的排列順序需要遵循 long > int > boolean > oop(物件參照)

根據規則 3 Entry物件範例資料區第一個欄位應該是 long progress,但根據規則1 long 型欄位的 OFFSET 需要對齊至 8 的倍數,並且根據 規則2 在開啟壓縮指標的情況下,物件的第一個欄位 OFFSET 需要對齊至 4 的倍數。所以欄位long progress 的 OFFET = 16,這就必然導致了在物件頭與欄位 long progress 之間需要由 4 位元組的位元組填充(OFFET = 12處發生位元組填充)。

但是 JVM 預設開啟了 -XX +CompactFields,根據 規則3 佔用記憶體小於 long / double 的欄位會允許被插入到物件中第一個 long / double 欄位之前的間隙中,以避免不必要的記憶體填充。

所以位於後邊的欄位 int pendingSize 插入到了 OFFET = 12 位置處,避免了不必要的位元組填充。

在 Entry 物件的範例資料區中緊接著基礎型別欄位後面跟著的就是 6 個物件參照欄位(開啟壓縮指標占用 4 個位元組)。

大家一定注意到 OFFSET = 37 處本應該存放的是欄位 private final Handle<Entry> handle 但是卻被填充了 3 個位元組。這是為什麼呢?

根據欄位重排列規則1:參照欄位 private final Handle<Entry> handle 佔用 4 個位元組(開啟壓縮指標的情況),所以需要對齊至4的倍數。所以需要填充3個位元組,使得參照欄位 private final Handle<Entry> handle 位於 OFFSET = 40 處。

根據以上這些規則最終計算出來在開啟壓縮指標的情況下Entry物件在堆中佔用記憶體大小為64位元組

關閉指標壓縮 -XX:-UseCompressedOops

在分析完 Entry 物件在開啟壓縮指標情況下的記憶體佈局情況後,我想大家現在對前邊介紹的欄位重排列的三個規則理解更加清晰了,那麼我們基於這個基礎來分析下在關閉壓縮指標的情況下 Entry 物件的記憶體佈局。

首先 Entry 物件在記憶體佈局中的開頭依然是由 8 個位元組的 MarkWord 還有 8 個位元組的型別指標(關閉壓縮指標)組成的物件頭。

我們看到在 OFFSET = 41 處發生了位元組填充,原因是在關閉壓縮指標的情況下,物件參照佔用記憶體大小變為 8 個位元組,根據規則1: 參照欄位 private final Handle<Entry> handle 的 OFFET 需要對齊至 8 的倍數,所以需要在該參照欄位之前填充 7 個位元組,使得參照欄位 private final Handle<Entry> handle 的OFFET = 48 。

綜合欄位重排列的三個規則最終計算出來在關閉壓縮指標的情況下Entry物件在堆中佔用記憶體大小為96位元組

3.3.5 向ChannelOutboundBuffer中快取待傳送資料

在介紹完 ChannelOutboundBuffer 的基本結構之後,下面就來到了 Netty 處理 write 事件的最後一步,我們來看下使用者的待傳送資料是如何被新增進 ChannelOutboundBuffer 中的。

    public void addMessage(Object msg, int size, ChannelPromise promise) {
        Entry entry = Entry.newInstance(msg, size, total(msg), promise);
        if (tailEntry == null) {
            flushedEntry = null;
        } else {
            Entry tail = tailEntry;
            tail.next = entry;
        }
        tailEntry = entry;
        if (unflushedEntry == null) {
            unflushedEntry = entry;
        }

        incrementPendingOutboundBytes(entry.pendingSize, false);
    }

3.3.5.1 建立Entry物件來封裝待傳送資料資訊

通過前邊的介紹我們瞭解到當用戶呼叫 ctx.write(msg) 之後,write 事件開始在pipeline中從當前 ChannelHandler開始一直向前進行傳播,最終在 HeadContext 中將待傳送資料寫入到 channel 對應的寫緩衝區 ChannelOutboundBuffer 中。

而 ChannelOutboundBuffer 是由 Entry 結構組成的一個單連結串列,Entry 結構封裝了使用者待傳送資料的各種資訊。

這裡首先我們需要為待傳送資料建立 Entry 物件,而在《詳解Recycler物件池的精妙設計與實現》一文中我們介紹物件池時,提到 Netty 作為一個高效能高吞吐的網路框架要面對海量的 IO 處理操作,這種場景下會頻繁的建立大量的 Entry 物件,而物件的建立及其回收時需要效能開銷的,尤其是在面對大量頻繁的建立物件場景下,這種開銷會進一步被放大,所以 Netty 引入了物件池來管理 Entry 物件範例從而避免 Entry 物件頻繁建立以及 GC 帶來的效能開銷。

既然 Entry 物件已經被物件池接管,那麼它在物件池外面是不能被直接建立的,其建構函式是私有型別,並提供一個靜態方法 newInstance 供外部執行緒從物件池中獲取 Entry 物件。這在《詳解Recycler物件池的精妙設計與實現》一文中介紹池化物件的設計時也有提到過。

   static final class Entry {
        //靜態變數參照型別地址 這個是在Klass Point(型別指標)中定義 8位元組(開啟指標壓縮 為4位元組)
        private static final ObjectPool<Entry> RECYCLER = ObjectPool.newPool(new ObjectCreator<Entry>() {
            @Override
            public Entry newObject(Handle<Entry> handle) {
                return new Entry(handle);
            }
        });

        //Entry物件只能通過物件池獲取,不可外部自行建立
        private Entry(Handle<Entry> handle) {
            this.handle = handle;
        }

        //不考慮指標壓縮的大小 entry物件在堆中佔用的記憶體大小為96
        //如果開啟指標壓縮,entry物件在堆中佔用的記憶體大小 會是64  
        static final int CHANNEL_OUTBOUND_BUFFER_ENTRY_OVERHEAD =
            SystemPropertyUtil.getInt("io.netty.transport.outboundBufferEntrySizeOverhead", 96);

        static Entry newInstance(Object msg, int size, long total, ChannelPromise promise) {
            Entry entry = RECYCLER.get();
            entry.msg = msg;
            //待發資料資料大小 + entry物件大小
            entry.pendingSize = size + CHANNEL_OUTBOUND_BUFFER_ENTRY_OVERHEAD;
            entry.total = total;
            entry.promise = promise;
            return entry;
        }

        .......................省略................

    }
  1. 通過池化物件 Entry 中持有的物件池 RECYCLER ,從物件池中獲取 Entry 物件範例。

  2. 將使用者待傳送資料 msg(DirectByteBuffer),待傳送資料大小:total ,本次傳送資料的 channelFuture,以及該 Entry 物件的 pendingSize 統統封裝在
    Entry 物件範例的相應欄位中。

這裡需要特殊說明一點的是關於 pendingSize 的計算方式,之前我們提到 pendingSize 中所計算的記憶體佔用一共包含兩部分:

  • 待傳送網路資料大小

  • Entry 物件本身在記憶體中的佔用量

而在《3.3.4 Entry範例物件在JVM中佔用記憶體大小》小節中我們介紹到,Entry 物件在記憶體中的佔用大小在開啟壓縮指標的情況下(-XX:+UseCompressedOops)佔用 64 位元組,在關閉壓縮指標的情況下(-XX:-UseCompressedOops)佔用 96 位元組。

欄位 CHANNEL_OUTBOUND_BUFFER_ENTRY_OVERHEAD 表示的就是 Entry 物件在記憶體中的佔用大小,Netty這裡預設是 96 位元組,當然如果我們的應用程式開啟了指標壓縮,我們可以通過 JVM 啟動引數 -D io.netty.transport.outboundBufferEntrySizeOverhead 指定為 64 位元組。

3.3.5.2 將Entry物件新增進ChannelOutboundBuffer中

       if (tailEntry == null) {
            flushedEntry = null;
        } else {
            Entry tail = tailEntry;
            tail.next = entry;
        }
        tailEntry = entry;
        if (unflushedEntry == null) {
            unflushedEntry = entry;
        }

在《3.3 ChannelOutboundBuffer》小節一開始,我們介紹了 ChannelOutboundBuffer 中最重要的三個指標,這裡涉及到的兩個指標分別是:

  • unflushedEntry :指向 ChannelOutboundBuffer 中第一個未被 flush 進 Socket 的待傳送資料。用來指示 ChannelOutboundBuffer 的第一個節點。

  • tailEntry :指向 ChannelOutboundBuffer 中最後一個節點。

通過 unflushedEntry 和 tailEntry 可以定位出待傳送資料的範圍。Channel 中的每一次 write 事件,最終都會將待傳送資料插入到 ChannelOutboundBuffer 的尾結點處。

3.3.5.3 incrementPendingOutboundBytes

在將 Entry 物件新增進 ChannelOutboundBuffer 之後,就需要更新用於記錄當前 ChannelOutboundBuffer 中關於待傳送資料所佔記憶體總量的水位線指示。

如果更新後的水位線超過了 Netty 指定的高水位線 DEFAULT_HIGH_WATER_MARK = 64 * 1024,則需要將當前 Channel 的狀態設定為不可寫,並在 pipeline 中傳播 ChannelWritabilityChanged 事件,注意該事件是一個 inbound 事件。

public final class ChannelOutboundBuffer {

   //ChannelOutboundBuffer中的待傳送資料的記憶體佔用總量 : 所有Entry物件本身所佔用記憶體大小 + 所有待傳送資料的大小
    private volatile long totalPendingSize;

    //水位線指標
    private static final AtomicLongFieldUpdater<ChannelOutboundBuffer> TOTAL_PENDING_SIZE_UPDATER =
            AtomicLongFieldUpdater.newUpdater(ChannelOutboundBuffer.class, "totalPendingSize");

    private void incrementPendingOutboundBytes(long size, boolean invokeLater) {
        if (size == 0) {
            return;
        }
        //更新總共待寫入資料的大小
        long newWriteBufferSize = TOTAL_PENDING_SIZE_UPDATER.addAndGet(this, size);
        //如果待寫入的資料 大於 高水位線 64 * 1024  則設定當前channel為不可寫 由使用者自己決定是否繼續寫入
        if (newWriteBufferSize > channel.config().getWriteBufferHighWaterMark()) {
            //設定當前channel狀態為不可寫,並觸發fireChannelWritabilityChanged事件
            setUnwritable(invokeLater);
        }
    }

}

volatile 關鍵字在 Java 記憶體模型中只能保證變數的可見性,以及禁止指令重排序。但無法保證多執行緒更新的原子性,這裡我們可以通過AtomicLongFieldUpdater 來幫助 totalPendingSize 欄位實現原子性的更新。

    // 0表示channel可寫,1表示channel不可寫
    private volatile int unwritable;

    private static final AtomicIntegerFieldUpdater<ChannelOutboundBuffer> UNWRITABLE_UPDATER =
            AtomicIntegerFieldUpdater.newUpdater(ChannelOutboundBuffer.class, "unwritable");

    private void setUnwritable(boolean invokeLater) {
        for (;;) {
            final int oldValue = unwritable;
            final int newValue = oldValue | 1;
            if (UNWRITABLE_UPDATER.compareAndSet(this, oldValue, newValue)) {
                if (oldValue == 0) {
                    //觸發fireChannelWritabilityChanged事件 表示當前channel變為不可寫
                    fireChannelWritabilityChanged(invokeLater);
                }
                break;
            }
        }
    }

當 ChannelOutboundBuffer 中的記憶體佔用水位線 totalPendingSize 已經超過高水位線時,呼叫該方法將當前 Channel 的狀態設定為不可寫狀態。

unwritable == 0 表示當前channel可寫,unwritable == 1 表示當前channel不可寫。

channel 可以通過呼叫 isWritable 方法來判斷自身當前狀態是否可寫。

    public boolean isWritable() {
        return unwritable == 0;
    }

當 Channel 的狀態是首次從可寫狀態變為不可寫狀態時,就會在 channel 對應的 pipeline 中傳播 ChannelWritabilityChanged 事件。

    private void fireChannelWritabilityChanged(boolean invokeLater) {
        final ChannelPipeline pipeline = channel.pipeline();
        if (invokeLater) {
            Runnable task = fireChannelWritabilityChangedTask;
            if (task == null) {
                fireChannelWritabilityChangedTask = task = new Runnable() {
                    @Override
                    public void run() {
                        pipeline.fireChannelWritabilityChanged();
                    }
                };
            }
            channel.eventLoop().execute(task);
        } else {
            pipeline.fireChannelWritabilityChanged();
        }
    }

使用者可以在自定義的 ChannelHandler 中實現 channelWritabilityChanged 事件回撥方法,來針對 Channel 的可寫狀態變化做出不同的處理。

public class EchoServerHandler extends ChannelInboundHandlerAdapter {

    @Override
    public void channelWritabilityChanged(ChannelHandlerContext ctx) throws Exception {

        if (ctx.channel().isWritable()) {
            ...........當前channel可寫.........
        } else {
            ...........當前channel不可寫.........
        }
    }

}

到這裡 write 事件在 pipeline 中的傳播,筆者就為大家介紹完了,下面我們來看下另一個重要的 flush 事件的處理過程。

4. flush

從前面 Netty 對 write 事件的處理過程中,我們可以看到當用戶呼叫 ctx.write(msg) 方法之後,Netty 只是將使用者要傳送的資料臨時寫到 channel 對應的待傳送緩衝佇列 ChannelOutboundBuffer 中,然而並不會將資料寫入 Socket 中。

而當一次 read 事件完成之後,我們會呼叫 ctx.flush() 方法將 ChannelOutboundBuffer 中的待傳送資料寫入 Socket 中的傳送緩衝區中,從而將資料傳送出去。

public class EchoServerHandler extends ChannelInboundHandlerAdapter {

    @Override
    public void channelReadComplete(ChannelHandlerContext ctx) {
        //本次OP_READ事件處理完畢
        ctx.flush();
    }

}

4.1 flush事件的傳播

flush 事件和 write 事件一樣都是 oubound 事件,所以它們的傳播方向都是從後往前在 pipeline 中傳播。

觸發 flush 事件傳播的同樣也有兩個方法:

  • channelHandlerContext.flush():flush事件會從當前 channelHandler 開始在 pipeline 中向前傳播直到 headContext。

  • channelHandlerContext.channel().flush():flush 事件會從 pipeline 的尾結點 tailContext 處開始向前傳播直到 headContext。

abstract class AbstractChannelHandlerContext implements ChannelHandlerContext, ResourceLeakHint {

    @Override
    public ChannelHandlerContext flush() {
        //向前查詢覆蓋flush方法的Outbound型別的ChannelHandler
        final AbstractChannelHandlerContext next = findContextOutbound(MASK_FLUSH);
        //獲取執行ChannelHandler的executor,在初始化pipeline的時候設定,預設為Reactor執行緒
        EventExecutor executor = next.executor();
        if (executor.inEventLoop()) {
            next.invokeFlush();
        } else {
            Tasks tasks = next.invokeTasks;
            if (tasks == null) {
                next.invokeTasks = tasks = new Tasks(next);
            }
            safeExecute(executor, tasks.invokeFlushTask, channel().voidPromise(), null, false);
        }

        return this;
    }

}

這裡的邏輯和 write 事件傳播的邏輯基本一樣,也是首先通過findContextOutbound(MASK_FLUSH) 方法從當前 ChannelHandler 開始從 pipeline 中向前查詢出第一個 ChannelOutboundHandler 型別的並且實現 flush 事件回撥方法的 ChannelHandler 。注意這裡傳入的執行資格掩碼為 MASK_FLUSH。

執行ChannelHandler中事件回撥方法的執行緒必須是通過pipeline#addLast(EventExecutorGroup group, ChannelHandler... handlers)為 ChannelHandler 指定的 executor。如果不指定,預設的 executor 為 channel 繫結的 reactor 執行緒。

如果當前執行緒不是 ChannelHandler 指定的 executor,則需要將 invokeFlush() 方法的呼叫封裝成 Task 交給指定的 executor 執行。

4.1.1 觸發nextChannelHandler的flush方法回撥

    private void invokeFlush() {
        if (invokeHandler()) {
            invokeFlush0();
        } else {
            //如果該ChannelHandler並沒有加入到pipeline中則繼續向前傳遞flush事件
            flush();
        }
    }

這裡和 write 事件的相關處理一樣,首先也是需要呼叫 invokeHandler() 方法來判斷這個 nextChannelHandler 是否在 pipeline 中被正確的初始化。

如果 nextChannelHandler 中的 handlerAdded 方法並沒有被回撥過,那麼這裡就只能跳過 nextChannelHandler,並呼叫 ChannelHandlerContext#flush 方法繼續向前傳播flush事件。

如果 nextChannelHandler 中的 handlerAdded 方法已經被回撥過,說明 nextChannelHandler 在 pipeline 中已經被正確的初始化好,則直接呼叫nextChannelHandler 的 flush 事件回撥方法。

    private void invokeFlush0() {
        try {
            ((ChannelOutboundHandler) handler()).flush(this);
        } catch (Throwable t) {
            invokeExceptionCaught(t);
        }
    }

這裡有一點和 write 事件處理不同的是,當呼叫 nextChannelHandler 的 flush 回撥出現異常的時候,會觸發 nextChannelHandler 的 exceptionCaught 回撥。

    private void invokeExceptionCaught(final Throwable cause) {
        if (invokeHandler()) {
            try {
                handler().exceptionCaught(this, cause);
            } catch (Throwable error) {
                if (logger.isDebugEnabled()) {
                    logger.debug(....相關紀錄檔列印......);
                } else if (logger.isWarnEnabled()) {
                    logger.warn(...相關紀錄檔列印......));
                }
            }
        } else {
            fireExceptionCaught(cause);
        }
    }

而其他 outbound 類事件比如 write 事件在傳播的過程中發生異常,只是回撥通知相關的 ChannelFuture。並不會觸發 exceptionCaught 事件的傳播。

4.2 flush事件的處理

最終flush事件會在pipeline中一直向前傳播至HeadContext中,並在 HeadContext 裡呼叫 channel 的 unsafe 類完成 flush 事件的最終處理邏輯。

final class HeadContext extends AbstractChannelHandlerContext {

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

}

下面就真正到了 Netty 處理 flush 事件的地方。

protected abstract class AbstractUnsafe implements Unsafe {

       @Override
        public final void flush() {
            assertEventLoop();

            ChannelOutboundBuffer outboundBuffer = this.outboundBuffer;
            //channel以關閉
            if (outboundBuffer == null) {
                return;
            }
            //將flushedEntry指標指向ChannelOutboundBuffer頭結點,此時變為即將要flush進Socket的資料佇列
            outboundBuffer.addFlush();
            //將待寫資料寫進Socket
            flush0();
        }

}

4.2.1 ChannelOutboundBuffer#addFlush

這裡就到了真正要傳送資料的時候了,在 addFlush 方法中會將 flushedEntry 指標指向 unflushedEntry 指標表示的第一個未被 flush 的 Entry 節點。並將 unflushedEntry 指標置為空,準備開始 flush 傳送資料流程。

此時 ChannelOutboundBuffer 由待傳送資料的緩衝佇列變為了即將要 flush 進 Socket 的資料佇列

這樣在 flushedEntry 與 tailEntry 之間的 Entry 節點即為本次 flush 操作需要傳送的資料範圍。

   public void addFlush() {
        Entry entry = unflushedEntry;
        if (entry != null) {
            if (flushedEntry == null) {
                flushedEntry = entry;
            }
            do {
                flushed ++;
                //如果當前entry對應的write操作被使用者取消,則釋放msg,並降低channelOutboundBuffer水位線
                if (!entry.promise.setUncancellable()) {
                    int pending = entry.cancel();
                    decrementPendingOutboundBytes(pending, false, true);
                }
                entry = entry.next;
            } while (entry != null);

            // All flushed so reset unflushedEntry
            unflushedEntry = null;
        }
    }

在 flush 傳送資料流程開始時,資料的傳送流程就不能被取消了,在這之前我們都是可以通過 ChannelPromise 取消資料傳送流程的。

所以這裡需要對 ChannelOutboundBuffer 中所有 Entry 節點包裹的 ChannelPromise 設定為不可取消狀態。

public interface Promise<V> extends Future<V> {

   /**
     * 設定當前future為不可取消狀態
     * 
     * 返回true的情況:
     * 1:成功的將future設定為uncancellable
     * 2:當future已經成功完成
     * 
     * 返回false的情況:
     * 1:future已經被取消,則不能在設定 uncancellable 狀態
     *
     */
    boolean setUncancellable();

}

如果這裡的 setUncancellable() 方法返回 false 則說明在這之前使用者已經將 ChannelPromise 取消掉了,接下來就需要呼叫 entry.cancel() 方法來釋放為待傳送資料 msg 分配的堆外記憶體。

static final class Entry {
        //write操作是否被取消
        boolean cancelled;

        int cancel() {
            if (!cancelled) {
                cancelled = true;
                int pSize = pendingSize;

                // release message and replace with an empty buffer
                ReferenceCountUtil.safeRelease(msg);
                msg = Unpooled.EMPTY_BUFFER;

                pendingSize = 0;
                total = 0;
                progress = 0;
                bufs = null;
                buf = null;
                return pSize;
            }
            return 0;
        }

}

當 Entry 物件被取消後,就需要減少 ChannelOutboundBuffer 的記憶體佔用總量的水位線 totalPendingSize。

    private static final AtomicLongFieldUpdater<ChannelOutboundBuffer> TOTAL_PENDING_SIZE_UPDATER =
            AtomicLongFieldUpdater.newUpdater(ChannelOutboundBuffer.class, "totalPendingSize");

    //水位線指標.ChannelOutboundBuffer中的待傳送資料的記憶體佔用總量 : 所有Entry物件本身所佔用記憶體大小 + 所有待傳送資料的大小
    private volatile long totalPendingSize;

    private void decrementPendingOutboundBytes(long size, boolean invokeLater, boolean notifyWritability) {
        if (size == 0) {
            return;
        }

        long newWriteBufferSize = TOTAL_PENDING_SIZE_UPDATER.addAndGet(this, -size);
        if (notifyWritability && newWriteBufferSize < channel.config().getWriteBufferLowWaterMark()) {
            setWritable(invokeLater);
        }
    }

當更新後的水位線低於低水位線 DEFAULT_LOW_WATER_MARK = 32 * 1024 時,就將當前 channel 設定為可寫狀態。

    private void setWritable(boolean invokeLater) {
        for (;;) {
            final int oldValue = unwritable;
            final int newValue = oldValue & ~1;
            if (UNWRITABLE_UPDATER.compareAndSet(this, oldValue, newValue)) {
                if (oldValue != 0 && newValue == 0) {
                    fireChannelWritabilityChanged(invokeLater);
                }
                break;
            }
        }
    }

當 Channel 的狀態是第一次從不可寫狀態變為可寫狀態時,Netty 會在 pipeline 中再次觸發 ChannelWritabilityChanged 事件的傳播。

4.2.2 傳送資料前的最後檢查---flush0

flush0 方法這裡主要做的事情就是檢查當 channel 的狀態是否正常,如果 channel 狀態一切正常,則呼叫 doWrite 方法傳送資料。

protected abstract class AbstractUnsafe implements Unsafe {

        //是否正在進行flush操作
        private boolean inFlush0; 

        protected void flush0() {
            if (inFlush0) {
                // Avoid re-entrance
                return;
            }

            final ChannelOutboundBuffer outboundBuffer = this.outboundBuffer;
            //channel已經關閉或者outboundBuffer為空
            if (outboundBuffer == null || outboundBuffer.isEmpty()) {
                return;
            }

            inFlush0 = true;

            if (!isActive()) {
                try {
                    if (!outboundBuffer.isEmpty()) {
                        if (isOpen()) {
                            //當前channel處於disConnected狀態  通知promise 寫入失敗 並觸發channelWritabilityChanged事件
                            outboundBuffer.failFlushed(new NotYetConnectedException(), true);
                        } else {
                           //當前channel處於關閉狀態 通知promise 寫入失敗 但不觸發channelWritabilityChanged事件
                           outboundBuffer.failFlushed(newClosedChannelException(initialCloseCause, "flush0()"), false);
                        }
                    }
                } finally {
                    inFlush0 = false;
                }
                return;
            }

            try {
                //寫入Socket
                doWrite(outboundBuffer);
            } catch (Throwable t) {
                handleWriteError(t);
            } finally {
                inFlush0 = false;
            }
        }

}
  • outboundBuffer == null || outboundBuffer.isEmpty() :如果 channel 已經關閉了或者對應寫緩衝區中沒有任何資料,那麼就停止傳送流程,直接 return。

  • !isActive() :如果當前channel處於非活躍狀態,則需要呼叫 outboundBuffer#failFlushed 通知 ChannelOutboundBuffer 中所有待傳送操作對應的 channelPromise 向用戶執行緒報告傳送失敗。並將待傳送資料 Entry 物件從 ChannelOutboundBuffer 中刪除,並釋放待傳送資料空間,回收 Entry 物件範例。

還記得我們在《Netty如何高效接收網路連線》一文中提到過的 NioSocketChannel 的 active 狀態有哪些條件嗎??

    @Override
    public boolean isActive() {
        SocketChannel ch = javaChannel();
        return ch.isOpen() && ch.isConnected();
    }

NioSocketChannel 處於 active 狀態的條件必須是當前 NioSocketChannel 是 open 的同時處於 connected 狀態。

  • !isActive() && isOpen():說明當前 channel 處於 disConnected 狀態,這時通知給使用者 channelPromise 的異常型別為 NotYetConnectedException ,並釋放所有待傳送資料佔用的堆外記憶體,如果此時記憶體佔用量低於低水位線,則設定 channel 為可寫狀態,並觸發 channelWritabilityChanged 事件。

當 channel 處於 disConnected 狀態時,使用者可以進行 write 操作但不能進行 flush 操作。

  • !isActive() && !isOpen() :說明當前 channel 處於關閉狀態,這時通知給使用者 channelPromise 的異常型別為 newClosedChannelException ,因為 channel 已經關閉,所以這裡並不會觸發 channelWritabilityChanged 事件。

  • 當 channel 的這些異常狀態校驗通過之後,則呼叫 doWrite 方法將 ChannelOutboundBuffer 中的待傳送資料寫進底層 Socket 中。

4.2.2.1 ChannelOutboundBuffer#failFlushed

public final class ChannelOutboundBuffer {

    private boolean inFail;

    void failFlushed(Throwable cause, boolean notify) {
        if (inFail) {
            return;
        }

        try {
            inFail = true;
            for (;;) {
                if (!remove0(cause, notify)) {
                    break;
                }
            }
        } finally {
            inFail = false;
        }
    }
}

該方法用於在 Netty 在傳送資料的時候,如果發現當前 channel 處於非活躍狀態,則將 ChannelOutboundBuffer 中 flushedEntry 與tailEntry 之間的 Entry 物件節點全部刪除,並釋放傳送資料佔用的記憶體空間,同時回收 Entry 物件範例。

4.2.2.2 ChannelOutboundBuffer#remove0

    private boolean remove0(Throwable cause, boolean notifyWritability) {
        Entry e = flushedEntry;
        if (e == null) {
            //清空當前reactor執行緒快取的所有待傳送資料
            clearNioBuffers();
            return false;
        }
        Object msg = e.msg;

        ChannelPromise promise = e.promise;
        int size = e.pendingSize;
        //從channelOutboundBuffer中刪除該Entry節點
        removeEntry(e);

        if (!e.cancelled) {
            // only release message, fail and decrement if it was not canceled before.
            //釋放msg所佔用的記憶體空間
            ReferenceCountUtil.safeRelease(msg);
            //編輯promise傳送失敗,並通知相應的Lisener
            safeFail(promise, cause);
            //由於msg得到釋放,所以需要降低channelOutboundBuffer中的記憶體佔用水位線,並根據notifyWritability決定是否觸發ChannelWritabilityChanged事件
            decrementPendingOutboundBytes(size, false, notifyWritability);
        }

        // recycle the entry
        //回收Entry範例物件
        e.recycle();

        return true;
    }

當一個 Entry 節點需要從 ChannelOutboundBuffer 中清除時,Netty 需要釋放該 Entry 節點中包裹的傳送資料 msg 所佔用的記憶體空間。並標記對應的 promise 為失敗同時通知對應的 listener ,由於 msg 得到釋放,所以需要降低 channelOutboundBuffer 中的記憶體佔用水位線,並根據 boolean notifyWritability 決定是否觸發 ChannelWritabilityChanged 事件。最後需要將該 Entry 範例回收至 Recycler 物件池中。

5. 終於開始真正地傳送資料了!

來到這裡我們就真正進入到了 Netty 傳送資料的核心處理邏輯,在《Netty如何高效接收網路資料》一文中,筆者詳細介紹了 Netty 讀取資料的核心流程,Netty 會在一個 read loop 中不斷迴圈讀取 Socket 中的資料直到資料讀取完畢或者讀取次數已滿 16 次,當迴圈讀取了 16 次還沒有讀取完畢時,Netty 就不能在繼續讀了,因為 Netty 要保證 Reactor 執行緒可以均勻的處理註冊在它上邊的所有 Channel 中的 IO 事件。剩下未讀取的資料等到下一次 read loop 在開始讀取。

除此之外,在每次 read loop 開始之前,Netty 都會分配一個初始化大小為 2048 的 DirectByteBuffer 來裝載從 Socket 中讀取到的資料,當整個 read loop 結束時,會根據本次讀取資料的總量來判斷是否為該 DirectByteBuffer 進行擴容或者縮容,目的是在下一次 read loop 的時候可以為其分配一個容量大小合適的 DirectByteBuffer 。

其實 Netty 對傳送資料的處理和對讀取資料的處理核心邏輯都是一樣的,這裡大家可以將這兩篇文章結合對比著看。

但傳送資料的細節會多一些,也會更復雜一些,由於這塊邏輯整體稍微比較複雜,所以我們接下來還是分模組進行解析:

5.1 傳送資料前的準備工作

    @Override
    protected void doWrite(ChannelOutboundBuffer in) throws Exception {
        //獲取NioSocketChannel中封裝的jdk nio底層socketChannel
        SocketChannel ch = javaChannel();
        //最大寫入次數 預設為16 目的是為了保證SubReactor可以平均的處理註冊其上的所有Channel
        int writeSpinCount = config().getWriteSpinCount();
        do {
            if (in.isEmpty()) {
                // 如果全部資料已經寫完 則移除OP_WRITE事件並直接退出writeLoop
                clearOpWrite();             
                return;
            }

            //  SO_SNDBUF設定的傳送緩衝區大小 * 2 作為 最大寫入位元組數  293976 = 146988 << 1
            int maxBytesPerGatheringWrite = ((NioSocketChannelConfig) config).getMaxBytesPerGatheringWrite();
            // 將ChannelOutboundBuffer中快取的DirectBuffer轉換成JDK NIO 的 ByteBuffer
            ByteBuffer[] nioBuffers = in.nioBuffers(1024, maxBytesPerGatheringWrite);
            // ChannelOutboundBuffer中總共的DirectBuffer數
            int nioBufferCnt = in.nioBufferCount();

            switch (nioBufferCnt) {
                .........向底層jdk nio socketChannel傳送資料.........
            }
        } while (writeSpinCount > 0);
        
        ............處理本輪write loop未寫完的情況.......
    }

這部分內容為 Netty 開始傳送資料之前的準備工作:

5.1.1 獲取write loop最大傳送回圈次數

從當前 NioSocketChannel 的設定類 NioSocketChannelConfig 中獲取 write loop 最大回圈寫入次數,預設為 16。但也可以通過下面的方式進行自定義設定。

            ServerBootstrap b = new ServerBootstrap();
            b.group(bossGroup, workerGroup)
                   .......
             .childOption(ChannelOption.WRITE_SPIN_COUNT,自定義數值)

5.1.2 處理在一輪write loop中就傳送完資料的情況

進入 write loop 之後首先需要判斷當前 ChannelOutboundBuffer 中的資料是否已經寫完了 in.isEmpty()) ,如果全部寫完就需要清除當前 Channel 在 Reactor 上註冊的 OP_WRITE 事件。

這裡大家可能會有疑問,目前我們還沒有註冊 OP_WRITE 事件到 Reactor 上,為啥要清除呢? 彆著急,筆者會在後面為大家揭曉答案。

5.1.3 獲取本次write loop 最大允許傳送位元組數

從 ChannelConfig 中獲取本次 write loop 最大允許傳送的位元組數
maxBytesPerGatheringWrite 。初始值為 SO_SNDBUF大小 * 2 = 293976 = 146988 << 1,最小值為 2048。

    private final class NioSocketChannelConfig extends DefaultSocketChannelConfig {
        //293976 = 146988 << 1
        //SO_SNDBUF設定的傳送緩衝區大小 * 2 作為 最大寫入位元組數
        //最小值為2048 
        private volatile int maxBytesPerGatheringWrite = Integer.MAX_VALUE;
        private NioSocketChannelConfig(NioSocketChannel channel, Socket javaSocket) {
            super(channel, javaSocket);
            calculateMaxBytesPerGatheringWrite();
        }

        private void calculateMaxBytesPerGatheringWrite() {
            // 293976 = 146988 << 1
            // SO_SNDBUF設定的傳送緩衝區大小 * 2 作為 最大寫入位元組數
            int newSendBufferSize = getSendBufferSize() << 1;
            if (newSendBufferSize > 0) {
                setMaxBytesPerGatheringWrite(newSendBufferSize);
            }
        }
   }

我們可以通過如下的方式自定義設定 Socket 傳送緩衝區大小。

            ServerBootstrap b = new ServerBootstrap();
            b.group(bossGroup, workerGroup)
                   .......
             .childOption(ChannelOption.SO_SNDBUF,自定義數值)

5.1.4 將待傳送資料轉換成 JDK NIO ByteBuffer

由於最終 Netty 會呼叫 JDK NIO 的 SocketChannel 傳送資料,所以這裡需要首先將當前 Channel 中的寫緩衝佇列 ChannelOutboundBuffer 裡儲存的 DirectByteBuffer( Netty 中的 ByteBuffer 實現)轉換成 JDK NIO 的 ByteBuffer 型別。最終將轉換後的待傳送資料儲存在 ByteBuffer[] nioBuffers 陣列中。這裡通過呼叫 ChannelOutboundBuffer#nioBuffers 方法完成以上 ByteBuffer 型別的轉換。

  • maxBytesPerGatheringWrite:表示本次 write loop 中最多從 ChannelOutboundBuffer 中轉換 maxBytesPerGatheringWrite 個位元組出來。也就是本次 write loop 最多能傳送多少位元組。

  • 1024 : 本次 write loop 最多轉換 1024 個 ByteBuffer( JDK NIO 實現)。也就是說本次 write loop 最多批次傳送多少個 ByteBuffer 。

通過 ChannelOutboundBuffer#nioBufferCount() 獲取本次 write loop 總共需要傳送的 ByteBuffer 數量 nioBufferCnt 。注意這裡已經變成了 JDK NIO 實現的 ByteBuffer 了。

詳細的 ByteBuffer 型別轉換過程,筆者會在專門講解 Buffer 設計的時候為大家全面細緻地講解,這裡我們還是主要聚焦於傳送資料流程的主線。

當做完這些傳送前的準備工作之後,接下來 Netty 就開始向 JDK NIO SocketChannel 傳送這些已經轉換好的 JDK NIO ByteBuffer 了。

5.2 向JDK NIO SocketChannel傳送資料

    @Override
    protected void doWrite(ChannelOutboundBuffer in) throws Exception {      
        SocketChannel ch = javaChannel();
        int writeSpinCount = config().getWriteSpinCount();
        do {
  
            .........將待傳送資料轉換到JDK NIO ByteBuffer中.........

            //本次write loop中需要傳送的 JDK ByteBuffer個數
            int nioBufferCnt = in.nioBufferCount();

            switch (nioBufferCnt) {
                case 0:
                    //這裡主要是針對 網路傳輸檔案資料 的處理 FileRegion                 
                    writeSpinCount -= doWrite0(in);
                    break;
                case 1: {
                    .........處理單個NioByteBuffer傳送的情況......
                    break;
                }
                default: {
                    .........批次處理多個NioByteBuffers傳送的情況......
                    break;
                }            
            }
        } while (writeSpinCount > 0);
        
        ............處理本輪write loop未寫完的情況.......
    }

這裡大家可能對 nioBufferCnt == 0 的情況比較有疑惑,明明之前已經校驗過ChannelOutboundBuffer 不為空了,為什麼這裡從 ChannelOutboundBuffer 中獲取到的 nioBuffer 個數依然為 0 呢

在前邊我們介紹 Netty 對 write 事件的處理過程時提過, ChannelOutboundBuffer 中只支援 ByteBuf 型別和 FileRegion 型別,其中 ByteBuf 型別用於裝載普通的傳送資料,而 FileRegion 型別用於通過零拷貝的方式網路傳輸檔案。

而這裡 ChannelOutboundBuffer 雖然不為空,但是裝載的 NioByteBuffer 個數卻為 0 說明 ChannelOutboundBuffer 中裝載的是 FileRegion 型別,當前正在進行網路檔案的傳輸。

case 0 的分支主要就是用於處理網路檔案傳輸的情況。

5.2.1 零拷貝傳送網路檔案

    protected final int doWrite0(ChannelOutboundBuffer in) throws Exception {
        Object msg = in.current();
        if (msg == null) {
            return 0;
        }
        return doWriteInternal(in, in.current());
    }

這裡需要特別注意的是用於檔案傳輸的方法 doWriteInternal 中的返回值,理解這些返回值的具體情況有助於我們理解後面 write loop 的邏輯走向。

    private int doWriteInternal(ChannelOutboundBuffer in, Object msg) throws Exception {

        if (msg instanceof ByteBuf) {

             ..............忽略............

        } else if (msg instanceof FileRegion) {
            FileRegion region = (FileRegion) msg;
            //檔案已經傳輸完畢
            if (region.transferred() >= region.count()) {
                in.remove();
                return 0;
            }

            //零拷貝的方式傳輸檔案
            long localFlushedAmount = doWriteFileRegion(region);
            if (localFlushedAmount > 0) {
                in.progress(localFlushedAmount);
                if (region.transferred() >= region.count()) {
                    in.remove();
                }
                return 1;
            }
        } else {
            // Should not reach here.
            throw new Error();
        }
        //走到這裡表示 此時Socket已經寫不進去了 退出writeLoop,註冊OP_WRITE事件
        return WRITE_STATUS_SNDBUF_FULL;
    }

最終會在 doWriteFileRegion 方法中通過 FileChannel#transferTo 方法底層用到的系統呼叫為 sendFile 實現零拷貝網路檔案的傳輸。


public class NioSocketChannel extends AbstractNioByteChannel implements io.netty.channel.socket.SocketChannel {

   @Override
    protected long doWriteFileRegion(FileRegion region) throws Exception {
        final long position = region.transferred();
        return region.transferTo(javaChannel(), position);
    }

}

關於 Netty 中涉及到的零拷貝,筆者會有一篇專門的文章為大家講解,本文的主題我們還是先聚焦於把傳送流程的主線打通。

我們繼續回到傳送資料流程主線上來~~

                case 0:
                    //這裡主要是針對 網路傳輸檔案資料 的處理 FileRegion                 
                    writeSpinCount -= doWrite0(in);
                    break;
  • region.transferred() >= region.count() :表示當前 FileRegion 中的檔案資料已經傳輸完畢。那麼在這種情況下本次 write loop 沒有寫入任何資料到 Socket ,所以返回 0 ,writeSpinCount - 0 意思就是本次 write loop 不算,繼續迴圈。

  • localFlushedAmount > 0 :表示本 write loop 中寫入了一些資料到 Socket 中,會有返回 1,writeSpinCount - 1 減少一次 write loop 次數。

  • localFlushedAmount <= 0 :表示當前 Socket 傳送緩衝區已滿,無法寫入資料,那麼就返回 WRITE_STATUS_SNDBUF_FULL = Integer.MAX_VALUE
    writeSpinCount - Integer.MAX_VALUE 必然是負數,直接退出迴圈,向 Reactor 註冊 OP_WRITE 事件並退出 flush 流程。等 Socket 傳送緩衝區可寫了,Reactor 會通知 channel 繼續傳送檔案資料。記住這裡,我們後面還會提到

5.2.2 傳送普通資料

剩下兩個 case 1 和 default 分支主要就是處理 ByteBuffer 裝載的普通資料傳送邏輯。

其中 case 1 表示當前 Channel 的 ChannelOutboundBuffer 中只包含了一個 NioByteBuffer 的情況。

default 表示當前 Channel 的 ChannelOutboundBuffer 中包含了多個 NioByteBuffers 的情況。

    @Override
    protected void doWrite(ChannelOutboundBuffer in) throws Exception {      
        SocketChannel ch = javaChannel();
        int writeSpinCount = config().getWriteSpinCount();
        do {
  
            .........將待傳送資料轉換到JDK NIO ByteBuffer中.........

            //本次write loop中需要傳送的 JDK ByteBuffer個數
            int nioBufferCnt = in.nioBufferCount();

            switch (nioBufferCnt) {
                case 0:
                      ..........處理網路檔案傳輸.........
                case 1: {
                    ByteBuffer buffer = nioBuffers[0];
                    int attemptedBytes = buffer.remaining();
                    final int localWrittenBytes = ch.write(buffer);
                    if (localWrittenBytes <= 0) {
                        //如果當前Socket傳送緩衝區滿了寫不進去了,則註冊OP_WRITE事件,等待Socket傳送緩衝區可寫時 在寫
                        // SubReactor在處理OP_WRITE事件時,直接呼叫flush方法
                        incompleteWrite(true);
                        return;
                    }
                    //根據當前實際寫入情況調整 maxBytesPerGatheringWrite數值
                    adjustMaxBytesPerGatheringWrite(attemptedBytes, localWrittenBytes, maxBytesPerGatheringWrite);
                    //如果ChannelOutboundBuffer中的某個Entry被全部寫入 則刪除該Entry
                    // 如果Entry被寫入了一部分 還有一部分未寫入  則更新Entry中的readIndex 等待下次writeLoop繼續寫入
                    in.removeBytes(localWrittenBytes);
                    --writeSpinCount;
                    break;
                }
                default: {
                    // ChannelOutboundBuffer中總共待寫入資料的位元組數
                    long attemptedBytes = in.nioBufferSize();
                    //批次寫入
                    final long localWrittenBytes = ch.write(nioBuffers, 0, nioBufferCnt);
                    if (localWrittenBytes <= 0) {
                        incompleteWrite(true);
                        return;
                    }
                    //根據實際寫入情況調整一次寫入資料大小的最大值
                    // maxBytesPerGatheringWrite決定每次可以從channelOutboundBuffer中獲取多少傳送資料
                    adjustMaxBytesPerGatheringWrite((int) attemptedBytes, (int) localWrittenBytes,
                            maxBytesPerGatheringWrite);
                    //移除全部寫完的BUffer,如果只寫了部分資料則更新buffer的readerIndex,下一個writeLoop寫入
                    in.removeBytes(localWrittenBytes);
                    --writeSpinCount;
                    break;
                }            
            }
        } while (writeSpinCount > 0);
        
        ............處理本輪write loop未寫完的情況.......
    }

case 1 和 default 這兩個分支在處理傳送資料時的邏輯是一樣的,唯一的區別就是 case 1 是處理單個 NioByteBuffer 的傳送,而 default 分支是批次處理多個 NioByteBuffers 的傳送。

下面筆者就以經常被觸發到的 default 分支為例來為大家講述 Netty 在處理資料傳送時的邏輯細節:

  1. 首先從當前 NioSocketChannel 中的 ChannelOutboundBuffer 中獲取本次 write loop 需要傳送的位元組總量 attemptedBytes 。這個 nioBufferSize 是在前邊介紹 ChannelOutboundBuffer#nioBuffers 方法轉換 JDK NIO ByteBuffer 型別時被計算出來的。

  2. 呼叫 JDK NIO 原生 SocketChannel 批次傳送 nioBuffers 中的資料。並獲取到本次 write loop 一共批次傳送了多少位元組 localWrittenBytes 。

    /**
     * @throws  NotYetConnectedException
     *          If this channel is not yet connected
     */
    public abstract long write(ByteBuffer[] srcs, int offset, int length)
        throws IOException;
  1. localWrittenBytes <= 0 表示當前 Socket 的寫快取區 SEND_BUF 已滿,寫不進資料了。那麼就需要向當前 NioSocketChannel 對應的 Reactor 註冊 OP_WRITE 事件,並停止當前 flush 流程。當 Socket 的寫緩衝區有容量可寫時,epoll 會通知 reactor 執行緒繼續寫入。
    protected final void incompleteWrite(boolean setOpWrite) {
        // Did not write completely.
        if (setOpWrite) {
            //這裡處理還沒寫滿16次 但是socket緩衝區已滿寫不進去的情況 註冊write事件
            //什麼時候socket可寫了, epoll會通知reactor執行緒繼續寫
            setOpWrite();
        } else {
              ...........目前還不需要關注這裡.......
        }
    }

向 Reactor 註冊 OP_WRITE 事件:

    protected final void setOpWrite() {
        final SelectionKey key = selectionKey();
        if (!key.isValid()) {
            return;
        }
        final int interestOps = key.interestOps();
        if ((interestOps & SelectionKey.OP_WRITE) == 0) {
            key.interestOps(interestOps | SelectionKey.OP_WRITE);
        }
    }

關於通過位運算來向 IO 事件集合 interestOps 新增監聽 IO 事件的用法,在前邊的文章中,筆者已經多次介紹過了,這裡不再重複。

  1. 根據本次 write loop 向 Socket 寫緩衝區寫入資料的情況,來調整下次 write loop 最大寫入位元組數。maxBytesPerGatheringWrite 決定每次 write loop 可以從 channelOutboundBuffer 中最多獲取多少傳送資料。初始值為 SO_SNDBUF大小 * 2 = 293976 = 146988 << 1,最小值為 2048。
    public static final int MAX_BYTES_PER_GATHERING_WRITE_ATTEMPTED_LOW_THRESHOLD = 4096;

    private void adjustMaxBytesPerGatheringWrite(int attempted, int written, int oldMaxBytesPerGatheringWrite) {
        if (attempted == written) {
            if (attempted << 1 > oldMaxBytesPerGatheringWrite) {
                ((NioSocketChannelConfig) config).setMaxBytesPerGatheringWrite(attempted << 1);
            }
        } else if (attempted > MAX_BYTES_PER_GATHERING_WRITE_ATTEMPTED_LOW_THRESHOLD && written < attempted >>> 1) {
            ((NioSocketChannelConfig) config).setMaxBytesPerGatheringWrite(attempted >>> 1);
        }
    }

由於作業系統會動態調整 SO_SNDBUF 的大小,所以這裡 netty 也需要根據作業系統的動態調整做出相應的調整,目的是儘量多的去寫入資料。

attempted == written 表示本次 write loop 嘗試寫入的資料能全部寫入到 Socket 的寫緩衝區中,那麼下次 write loop 就應該嘗試去寫入更多的資料。

那麼這裡的更多具體是多少呢?

Netty 會將本次寫入的資料量 written 擴大兩倍,如果擴大兩倍後的寫入量大於本次 write loop 的最大限制寫入量 maxBytesPerGatheringWrite,說明使用者的寫入需求很猛烈,Netty當然要滿足這樣的猛烈需求,那麼就將當前 NioSocketChannelConfig 中的 maxBytesPerGatheringWrite 更新為本次 write loop 兩倍的寫入量大小。

在下次 write loop 寫入資料的時候,就會嘗試從 ChannelOutboundBuffer 中載入最多 written * 2 大小的位元組數。

如果擴大兩倍後的寫入量依然小於等於本次 write loop 的最大限制寫入量 maxBytesPerGatheringWrite,說明使用者的寫入需求還不是很猛烈,Netty 繼續維持本次 maxBytesPerGatheringWrite 數值不變。

如果本次寫入的資料還不及嘗試寫入資料的 1 / 2written < attempted >>> 1。說明當前 Socket 寫緩衝區的可寫容量不是很多了,下一次 write loop 就不要寫這麼多了嘗試減少下次寫入的量將下次 write loop 要寫入的資料減小為 attempted 的1 / 2。當然也不能無限制的減小,最小值不能低於 2048。

這裡可以結合筆者前邊的文章《一文聊透ByteBuffer動態自適應擴縮容機制》中介紹到的 read loop 場景中的擴縮容一起對比著看。

read loop 中的擴縮容觸發時機是在一個完整的 read loop 結束時候觸發。而 write loop 中擴縮容的觸發時機是在每次 write loop 傳送完資料後,立即觸發擴縮容判斷。

  1. 當本次 write loop 批次傳送完 ChannelOutboundBuffer 中的資料之後,最後呼叫in.removeBytes(localWrittenBytes) 從 ChannelOutboundBuffer 中移除全部寫完的 Entry ,如果只傳送了 Entry 的部分資料則更新 Entry 物件中封裝的 DirectByteBuffer 的 readerIndex,等待下一次 write loop 寫入。

到這裡,write loop 中的傳送資料的邏輯就介紹完了,接下來 Netty 會在 write loop 中迴圈地傳送資料直到寫滿 16 次或者資料傳送完畢。

還有一種退出 write loop 的情況就是當 Socket 中的寫緩衝區滿了,無法在寫入時。Netty 會退出 write loop 並向 reactor 註冊 OP_WRITE 事件。

但這其中還隱藏著一種情況就是如果 write loop 已經寫滿 16 次但還沒寫完資料並且此時 Socket 寫緩衝區還沒有滿,還可以繼續在寫。那 Netty 會如何處理這種情況呢?

6. 處理Socket可寫但已經寫滿16次還沒寫完的情況

    @Override
    protected void doWrite(ChannelOutboundBuffer in) throws Exception {      
        SocketChannel ch = javaChannel();
        int writeSpinCount = config().getWriteSpinCount();
        do {
  
            .........將待傳送資料轉換到JDK NIO ByteBuffer中.........

            int nioBufferCnt = in.nioBufferCount();

            switch (nioBufferCnt) {
                case 0:
                    //這裡主要是針對 網路傳輸檔案資料 的處理 FileRegion                 
                    writeSpinCount -= doWrite0(in);
                    break;
                case 1: {
                      .....傳送單個nioBuffer....
                }
                default: {
                      .....批次傳送多個nioBuffers......
                }            
            }
        } while (writeSpinCount > 0);
        
        //處理write loop結束 但資料還沒寫完的情況
        incompleteWrite(writeSpinCount < 0);
    }

當 write loop 結束後,這時 writeSpinCount 的值會有兩種情況:

  • writeSpinCount < 0:這種情況有點不好理解,我們在介紹 Netty 通過零拷貝的方式傳輸網路檔案也就是這裡的 case 0 分支邏輯時,詳細介紹了 doWrite0 方法的幾種返回值,當 Netty 在傳輸檔案的過程中發現 Socket 緩衝區已滿無法在繼續寫入資料時,會返回 WRITE_STATUS_SNDBUF_FULL = Integer.MAX_VALUE,這就使得 writeSpinCount的值 < 0。隨後 break 掉 write loop 來到 incompleteWrite(writeSpinCount < 0) 方法中,最後會在 incompleteWrite 方法中向 reactor 註冊 OP_WRITE 事件。當 Socket 緩衝區變得可寫時,epoll 會通知 reactor 執行緒繼續傳送檔案。
    protected final void incompleteWrite(boolean setOpWrite) {
        // Did not write completely.
        if (setOpWrite) {
            //這裡處理還沒寫滿16次 但是socket緩衝區已滿寫不進去的情況 註冊write事件
            // 什麼時候socket可寫了, epoll會通知reactor執行緒繼續寫
            setOpWrite();
        } else {
            ..............
        }
    }
  • writeSpinCount == 0: 這種情況很好理解,就是已經寫滿了 16 次,但是還沒寫完,同時 Socket 的寫緩衝區未滿,還可以繼續寫入。這種情況下即使 Socket 還可以繼續寫入,Netty 也不會再去寫了,因為執行 flush 操作的是 reactor 執行緒,而 reactor 執行緒負責執行註冊在它上邊的所有 channel 的 IO 操作,Netty 不會允許 reactor 執行緒一直在一個 channel 上執行 IO 操作,reactor 執行緒的執行時間需要均勻的分配到每個 channel 上。所以這裡 Netty 會停下,轉而去處理其他 channel 上的 IO 事件。

那麼還沒寫完的資料,Netty會如何處理呢

    protected final void incompleteWrite(boolean setOpWrite) {
        // Did not write completely.
        if (setOpWrite) {
            //這裡處理還沒寫滿16次 但是socket緩衝區已滿寫不進去的情況 註冊write事件
            // 什麼時候socket可寫了, epoll會通知reactor執行緒繼續寫
            setOpWrite();
        } else {
            //這裡處理的是socket緩衝區依然可寫,但是寫了16次還沒寫完,這時就不能在寫了,reactor執行緒需要處理其他channel上的io事件

            //因為此時socket是可寫的,必須清除op_write事件,否則會一直不停地被通知
            clearOpWrite();
            //如果本次writeLoop還沒寫完,則提交flushTask到reactor           
            eventLoop().execute(flushTask);

        }

這個方法的 if 分支邏輯,我們在介紹do {.....}while()迴圈體 write loop 中傳送邏輯時已經提過,在 write loop 迴圈傳送資料的過程中,如果發現 Socket 緩衝區已滿,無法寫入資料時( localWrittenBytes <= 0),則需要向 reactor 註冊 OP_WRITE 事件,等到 Socket 緩衝區變為可寫狀態時,epoll 會通知 reactor 執行緒繼續寫入剩下的資料。

       do {
            .........將待傳送資料轉換到JDK NIO ByteBuffer中.........

            int nioBufferCnt = in.nioBufferCount();

            switch (nioBufferCnt) {
                case 0:
                    writeSpinCount -= doWrite0(in);
                    break;
                case 1: {
                    .....傳送單個nioBuffer....
                    final int localWrittenBytes = ch.write(buffer);
                    if (localWrittenBytes <= 0) {
                        incompleteWrite(true);
                        return;
                    }
                    .................省略..............
                    break;
                }
                default: {
                    .....批次傳送多個nioBuffers......
                    final long localWrittenBytes = ch.write(nioBuffers, 0, nioBufferCnt);
                    if (localWrittenBytes <= 0) {
                        incompleteWrite(true);
                        return;
                    }
                    .................省略..............
                    break;
                }
            }
        } while (writeSpinCount > 0);

注意 if 分支處理的情況是還沒寫滿 16 次,但是 Socket 緩衝區已滿,無法寫入的情況。

而 else 分支正是處理我們這裡正在討論的情況即 Socket 緩衝區是可寫的,但是已經寫滿 16 次,在本輪 write loop 中不能再繼續寫入的情況。

這時 Netty 會將 channel 中剩下的待寫資料的 flush 操作封裝程 flushTask,丟進 reactor 的普通任務佇列中,等待 reactor 執行完其他 channel 上的 io 操作後在回過頭來執行未寫完的 flush 任務。

忘記 Reactor 整體執行邏輯的同學,可以在回看下筆者的這篇文章《一文聊透Netty核心引擎Reactor的運轉架構》

    private final Runnable flushTask = new Runnable() {
        @Override
        public void run() {
            ((AbstractNioUnsafe) unsafe()).flush0();
        }
    };

這裡我們看到 flushTask 中的任務是直接再次呼叫 flush0 繼續回到傳送資料的邏輯流程中。

細心的同學可能會有疑問,為什麼這裡不在繼續註冊 OP_WRITE 事件而是通過向 reactor 提交一個 flushTask 來完成 channel 中剩下資料的寫入呢?

原因是這裡我們講的 else 分支是用來處理 Socket 緩衝區未滿還是可寫的,但是由於使用者本次要傳送的資料太多,導致寫了 16 次還沒寫完的情形。

既然當前 Socket 緩衝區是可寫的,我們就不能註冊 OP_WRITE 事件,否則這裡一直會不停地收到 epoll 的通知。因為 JDK NIO Selector 預設的是 epoll 的水平觸發。

忘記水平觸發和邊緣觸發這兩種 epoll 工作模式的同學,可以在回看下筆者的這篇文章《聊聊Netty那些事兒之從核心角度看IO模型》

所以這裡只能向 reactor 提交 flushTask 來繼續完成剩下資料的寫入,而不能註冊 OP_WRITE 事件。

注意:只有當 Socket 緩衝區已滿導致無法寫入時,Netty 才會去註冊 OP_WRITE 事件。這和我們之前介紹的 OP_ACCEPT 事件和 OP_READ 事件的註冊時機是不同的。

這裡大家可能還會有另一個疑問,就是為什麼在向 reactor 提交 flushTask 之前需要清理 OP_WRITE 事件呢? 我們並沒有註冊 OP_WRITE 事件呀~~

    protected final void incompleteWrite(boolean setOpWrite) {
        if (setOpWrite) {
            ......省略......
        } else {
            clearOpWrite();  
            eventLoop().execute(flushTask);
        }

在為大家解答這個疑問之前,筆者先為大家介紹下 Netty 是如何處理 OP_WRITE 事件的,當大家明白了 OP_WRITE 事件的處理邏輯後,這個疑問就自然解開了。

7. OP_WRITE事件的處理

《一文聊透Netty核心引擎Reactor的運轉架構》一文中,我們介紹過,當 Reactor 監聽到 channel 上有 IO 事件發生後,最終會在 processSelectedKey 方法中處理 channel 上的 IO 事件,其中 OP_ACCEPT 事件和 OP_READ 事件的處理過程,筆者已經在之前的系列文章中介紹過了,這裡我們聚焦於 OP_WRITE 事件的處理。

public final class NioEventLoop extends SingleThreadEventLoop {

   private void processSelectedKey(SelectionKey k, AbstractNioChannel ch) {
        final AbstractNioChannel.NioUnsafe unsafe = ch.unsafe();

        .............省略.......

        try {
            int readyOps = k.readyOps();

            if ((readyOps & SelectionKey.OP_CONNECT) != 0) {
                  ......處理connect事件......
            }

            if ((readyOps & SelectionKey.OP_WRITE) != 0) {
                ch.unsafe().forceFlush();
            }
 
            if ((readyOps & (SelectionKey.OP_READ | SelectionKey.OP_ACCEPT)) != 0 || readyOps == 0) {
               ........處理accept和read事件.........
            }
        } catch (CancelledKeyException ignored) {
            unsafe.close(unsafe.voidPromise());
        }
    }

}

這裡我們看到當 OP_WRITE 事件發生後,Netty 直接呼叫 channel 的 forceFlush 方法。

       @Override
        public final void forceFlush() {
            // directly call super.flush0() to force a flush now
            super.flush0();
        }

其實 forceFlush 方法中並沒有什麼特殊的邏輯,直接呼叫 flush0 方法再次發起 flush 操作繼續 channel 中剩下資料的寫入。

    @Override
    protected void doWrite(ChannelOutboundBuffer in) throws Exception {      
        SocketChannel ch = javaChannel();
        int writeSpinCount = config().getWriteSpinCount();
        do {
            if (in.isEmpty()) {
                clearOpWrite();
                return;
            }
            .........將待傳送資料轉換到JDK NIO ByteBuffer中.........

            int nioBufferCnt = in.nioBufferCount();

            switch (nioBufferCnt) {
                case 0:
                      ......傳輸網路檔案........
                case 1: {
                      .....傳送單個nioBuffer....
                }
                default: {
                      .....批次傳送多個nioBuffers......
                }            
            }
        } while (writeSpinCount > 0);
        
        //處理write loop結束 但資料還沒寫完的情況
        incompleteWrite(writeSpinCount < 0);
    }

注意這裡的 clearOpWrite() 方法,由於 channel 上的 OP_WRITE 事件就緒,表明此時 Socket 緩衝區變為可寫狀態,從而 Reactor 執行緒再次來到了 flush 流程中。

當 ChannelOutboundBuffer 中的資料全部寫完後 in.isEmpty() ,就需要清理 OP_WRITE 事件,因為此時 Socket 緩衝區是可寫的,這種情況下當資料全部寫完後,就需要取消對 OP_WRITE 事件的監聽,否則 epoll 會不斷的通知 Reactor。

同理在 incompleteWrite 方法的 else 分支也需要執行 clearOpWrite() 方法取消對 OP_WRITE 事件的監聽。

    protected final void incompleteWrite(boolean setOpWrite) {

        if (setOpWrite) {
            // 這裡處理還沒寫滿16次 但是socket緩衝區已滿寫不進去的情況 註冊write事件
            // 什麼時候socket可寫了, epoll會通知reactor執行緒繼續寫
            setOpWrite();
        } else {
            // 必須清除OP_WRITE事件,此時Socket對應的緩衝區依然是可寫的,只不過當前channel寫夠了16次,被SubReactor限制了。
            // 這樣SubReactor可以騰出手來處理其他channel上的IO事件。這裡如果不清除OP_WRITE事件,則會一直被通知。
            clearOpWrite();

            //如果本次writeLoop還沒寫完,則提交flushTask到SubReactor
            //釋放SubReactor讓其可以繼續處理其他Channel上的IO事件
            eventLoop().execute(flushTask);
        }
    }

8. writeAndFlush

在我們講完了 write 事件和 flush 事件的處理過程之後,writeAndFlush 就變得很簡單了,它就是把 write 和 flush 流程結合起來,先觸發 write 事件然後在觸發 flush 事件。

下面我們來看下 writeAndFlush 的具體邏輯處理:

public class EchoServerHandler extends ChannelInboundHandlerAdapter {

    @Override
    public void channelRead(final ChannelHandlerContext ctx, final Object msg) {
        //此處的msg就是Netty在read loop中從NioSocketChannel中讀取到ByteBuffer
        ctx.writeAndFlush(msg);
    }
}
abstract class AbstractChannelHandlerContext implements ChannelHandlerContext, ResourceLeakHint {

    @Override
    public ChannelFuture writeAndFlush(Object msg) {
        return writeAndFlush(msg, newPromise());
    }

    @Override
    public ChannelFuture writeAndFlush(Object msg, ChannelPromise promise) {
        write(msg, true, promise);
        return promise;
    }

}

這裡可以看到 writeAndFlush 方法的處理入口和 write 事件的處理入口是一樣的。唯一不同的是入口處理常式 write 方法的 boolean flush 入參不同,在 writeAndFlush 的處理中 flush = true。

    private void write(Object msg, boolean flush, ChannelPromise promise) {
        ObjectUtil.checkNotNull(msg, "msg");

        ................省略檢查promise的有效性...............

        //flush = true 表示channelHandler中呼叫的是writeAndFlush方法,這裡需要找到pipeline中覆蓋write或者flush方法的channelHandler
        //flush = false 表示呼叫的是write方法,只需要找到pipeline中覆蓋write方法的channelHandler
        final AbstractChannelHandlerContext next = findContextOutbound(flush ?
                (MASK_WRITE | MASK_FLUSH) : MASK_WRITE);
        //用於檢查記憶體洩露
        final Object m = pipeline.touch(msg, next);
        //獲取下一個要被執行的channelHandler的executor
        EventExecutor executor = next.executor();
        //確保OutBound事件由ChannelHandler指定的executor執行
        if (executor.inEventLoop()) {
            //如果當前執行緒正是channelHandler指定的executor則直接執行
            if (flush) {
                next.invokeWriteAndFlush(m, promise);
            } else {
                next.invokeWrite(m, promise);
            }
        } else {
            //如果當前執行緒不是ChannelHandler指定的executor,則封裝成非同步任務提交給指定executor執行,注意這裡的executor不一定是reactor執行緒。
            final WriteTask task = WriteTask.newInstance(next, m, promise, flush);
            if (!safeExecute(executor, task, promise, m, !flush)) {
                task.cancel();
            }
        }
    }

由於在 writeAndFlush 流程的處理中,flush 標誌被設定為 true,所以這裡有兩個地方會和 write 事件的處理有所不同。

  • findContextOutbound( MASK_WRITE | MASK_FLUSH ):這裡在 pipeline 中向前查詢的 ChanneOutboundHandler 需要實現 write 方法或者 flush 方法。這裡需要注意的是 write 方法和 flush 方法只需要實現其中一個即可滿足查詢條件。因為一般我們自定義 ChannelOutboundHandler 時,都會繼承 ChannelOutboundHandlerAdapter 類,而在 ChannelInboundHandlerAdapter 類中對於這些 outbound 事件都會有預設的實現。
public class ChannelOutboundHandlerAdapter extends ChannelHandlerAdapter implements ChannelOutboundHandler {

    @Skip
    @Override
    public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
        ctx.write(msg, promise);
    }


    @Skip
    @Override
    public void flush(ChannelHandlerContext ctx) throws Exception {
        ctx.flush();
    }

}

這樣在後面傳播 write 事件或者 flush 事件的時候,我們通過上面邏輯找出的 ChannelOutboundHandler 中可能只實現了一個 flush 方法或者 write 方法。不過這樣沒關係,如果這裡在傳播 outbound 事件的過程中,發現找出的 ChannelOutboundHandler 中並沒有實現對應的 outbound 事件回撥函數,那麼就直接呼叫在 ChannelOutboundHandlerAdapter 中的預設實現。

  • 在向前傳播 writeAndFlush 事件的時候會通過呼叫 ChannelHandlerContext 的 invokeWriteAndFlush 方法,先傳播 write 事件 然後在傳播 flush 事件。
    void invokeWriteAndFlush(Object msg, ChannelPromise promise) {
        if (invokeHandler()) {
            //向前傳遞write事件
            invokeWrite0(msg, promise);
            //向前傳遞flush事件
            invokeFlush0();
        } else {
            writeAndFlush(msg, promise);
        }
    }

    private void invokeWrite0(Object msg, ChannelPromise promise) {
        try {
            //呼叫當前ChannelHandler中的write方法
            ((ChannelOutboundHandler) handler()).write(this, msg, promise);
        } catch (Throwable t) {
            notifyOutboundHandlerException(t, promise);
        }
    }

    private void invokeFlush0() {
        try {
            ((ChannelOutboundHandler) handler()).flush(this);
        } catch (Throwable t) {
            invokeExceptionCaught(t);
        }
    }

這裡我們看到了 writeAndFlush 的核心處理邏輯,首先向前傳播 write 事件,經過 write 事件的流程處理後,最後向前傳播 flush 事件。

根據前邊的介紹,這裡在向前傳播 write 事件的時候,可能查詢出的 ChannelOutboundHandler 只是實現了 flush 方法,不過沒關係,這裡會直接呼叫 write 方法在 ChannelOutboundHandlerAdapter 父類別中的預設實現。同理 flush 也是一樣。


總結

到這裡,Netty 處理資料傳送的整個完整流程,筆者就為大家詳細地介紹完了,可以看到 Netty 在處理讀取資料和處理傳送資料的過程中,雖然核心邏輯都差不多,但是傳送資料的過程明顯細節比較多,而且更加複雜一些。

這裡筆者將讀取資料和傳送資料的不同之處總結如下幾點供大家回憶對比:

  • 在每次 read loop 之前,會分配一個大小固定的 diretByteBuffer 用來裝載讀取資料。每輪 read loop 完全結束之後,才會決定是否對下一輪的讀取過程分配的 directByteBuffer 進行擴容或者縮容。

  • 在每次 write loop 之前,都會獲取本次 write loop 最大能夠寫入的位元組數,根據這個最大寫入位元組數從 ChannelOutboundBuffer 中轉換 JDK NIO ByteBuffer 。每次寫入 Socket 之後都需要重新評估是否對這個最大寫入位元組數進行擴容或者縮容。

  • read loop 和 write loop 都被預設限定最多執行 16 次。

  • 在一個完整的 read loop 中,如果還讀取不完資料,直接退出。等到 reactor 執行緒執行完其他 channel 上的 IO 事件再來讀取未讀完的資料。

  • 而在一個完整的 write loop 中,資料傳送不完,則分兩種情況。

    • Socket 緩衝區滿無法在繼續寫入。這時就需要向 reactor 註冊 OP_WRITE 事件。等 Socket 緩衝區變的可寫時,epoll 通知 reactor 執行緒繼續傳送。
    • Socket 緩衝區可寫,但是由於傳送資料太多,導致雖然寫滿 16 次但依然沒有寫完。這時就直接向 reactor 丟一個 flushTask 進去,等到 reactor 執行緒執行完其他 channel 上的 IO 事件,在回過頭來執行 flushTask。
  • OP_READ 事件的註冊是在 NioSocketChannel 被註冊到對應的 Reactor 中時就會註冊。而 OP_WRITE 事件只會在 Socket 緩衝區滿的時候才會被註冊。當 Socket 緩衝區再次變得可寫時,要記得取消 OP_WRITE 事件的監聽。否則的話就會一直被通知

好了,本文的全部內容就到這裡了,我們下篇文章見~~~~

閱讀原文

歡迎關注公眾號:bin的技術小屋