Seata 高效能 RPC 通訊的實現基石-Netty篇

張哥說技術發表於2023-03-24

來源:架構染色

一、Netty 簡述

Netty 是一個非同步的、基於事件驅動的網路應用框架,用以快速開發高效能、高可靠性的網路 IO 程式。從下方所列舉的特性中不難發現 Netty 優點很多。

Seata 高效能 RPC 通訊的實現基石-Netty篇

學習 Netty 需要從瞭解與 Netty 相關的幾個關鍵類開始,如BootstrapServerBootstrapChannelSelectorChannelFutureEventLoopEventLoopGroupChannelHandlerPipeline 等。這些類是 Netty 對網路程式設計抽象的代表,也是 Netty 的精髓。

二、Bootstrap 和 ServerBootstrap

BootstrapServerBootstrap 作為 Netty 的引導類,提供配置 Netty 元件的介面,開發者透過這些介面來定製搭配 Netty 的各個元件,組裝出一個健壯、高效能的網路通訊模組。

BootstrapNetty 的客戶端引導類,引導客戶端程式連線到另一個執行在某個指定主機的指定埠上的服務端程式後進行網路通訊。

Seata 高效能 RPC 通訊的實現基石-Netty篇

ServerBootstrapNetty 的服務端引導類,引導一個服務端程式繫結到某個指定的埠,接收來自客戶端的網路連線後進行網路通訊。

Seata 高效能 RPC 通訊的實現基石-Netty篇

三、Channel

Channel 是 Java NIO 的一個基本構造,從網路程式設計視角看可把Channel 理解成是對 Socket 操作的封裝,所提供的如埠繫結、建立連線、資料讀寫等 API 降低了直接使用 Socket 的複雜度;Channel具備以下特性:

  • 可獲得當前網路連線的通道狀態
  • 可獲得網路連線的配置引數(緩衝區大小等)
  • 提供非同步的⽹絡 I/O 操作,⽐如建⽴連線、繫結端⼝、資料讀寫等
  • 獲得 ChannelFuture 例項,並在其上註冊監聽器⽤於監聽 I/O 操作成功、失敗、取消時的事件回撥。
  • 不同協議、不同 I/O 型別的連線都有不同的 Channel 型別與之對應

四、Selector

java.nio.channels.Selector 是 Java 非阻塞 I/O 實現的關鍵。Selector 管理一組非阻塞 socket,當這些 socket 中有已就緒可進行 I/O 相關操作的時候,會進行事件通知。使用非阻塞 I/O 比用阻塞 I/O 來處理大量事件相比,處理更快速、更經濟。

Selector 被稱作多路復⽤器,正是因為藉助它可以實現用一個執行緒監視多個檔案控制程式碼,在網路場景中即是一個執行緒監視多個 socket 控制程式碼。

Seata 高效能 RPC 通訊的實現基石-Netty篇

Netty 中即是一個 Selector 可以監視多個 Channel ,監聽 I/O 事件,如 OP_ACCEPT(接收連線事件)、OP_CONNECT(連線事件)、OP_READ(讀事件)、OP_WRITE(寫事件),還可以不斷的查詢已註冊 Channel 是否處於就緒狀態,透過一個執行緒中管理一個Selector,一個Selector監視多個 Channel,繼而達到用少量的執行緒管理大量的 Channel

五、ChannelFuture

Netty 中所有的 I/O 操作都是非同步的。非同步操作會立即返回,但操作結果可能不會立即返回,獲取結果有同步和非同步兩種方式:

  • 非同步方式,即需要一種在操作執行之後的某個時間點通知使用者其結果的方法。ChannelFuture 可透過 addListener()方法註冊了一個或多個 ChannelFutureListener,當操作完成時(無論是否成功)監聽器的operationComplete(ChannelFuture channelFuture)方法會被回撥執行,若是異常可透過channelFuture.cause()來獲得對應的Throwable物件。

  • 同步方式,需藉助ChannelFuture#sync() ⽅法達到同步執⾏的效果。

六、EventLoop 和 EventLoopGroup

Netty具有用少量的執行緒管理大量的 Channel的能力的基礎是一個執行緒可管理一個可監聽多個 Channel中 I/O 事件 的 Selector,那從開發者視角出發,如何提供執行緒,如何關注事件並提供對應的處理邏輯,並儘量少的關注執行緒安全問題?Netty 是瞭解開發者的,提供的這個元件就是EventLoop

EventLoop 內建立一個執行緒並管理一個 Selector,每個 Channel 被建立後就會被分配給一個 SelectorSelector 會監聽註冊在其上的多個 Channel 的 I/O 事件,EventLoop 會在這個內部執行緒中透過Selector檢測到多個 Channel 裡發生的 I/O 事件,並將 I/O 事件派發給對應ChannelChannelHandler。所以一個 Channel 的所有 I/O 事件都在EventLoop 內的這個執行緒中被處理。EventLoop並不獨立存在,在 Netty 中是被池化管理的,這個管理者就是 EventLoopGroup,因為每個EventLoop內都有一個執行緒,所以通常也把EventLoopGroup類比為執行緒池,參考下圖:

Seata 高效能 RPC 通訊的實現基石-Netty篇

透過上邊的介紹不難看出 EventLoop 的能力封裝將 Selector透明化了,因此通常 Netty 的資料常常僅介紹 ChannelEventLoopEventLoopGroup 之間的關係:

  • 一個 EventLoopGroup 包含 n 個 EventLoop
  • EventLoopGroup 負責為每個新建立的 Channel 分配一個 EventLoop,在當前實現中,使用 round-robin(順序迴圈)的方式進行分配以獲取一個均衡的分佈
  • 一個 EventLoop 在它的生命週期內只和一個執行緒繫結;且執行緒是按需建立
  • 所有由 EventLoop 處理的 I/O 事件都將在它專有的執行緒上被處理;
  • 一個 Channel 在它的生命週期內只註冊於一個 EventLoop
  • 多個 Channel 會被分配給同一個 EventLoop
Seata 高效能 RPC 通訊的實現基石-Netty篇

從執行機制來說EventLoop是一種事件等待和處理的程式模型,如 Node.js 就是採用 EventLoop 的執行機制,這種機制可以解決多執行緒資源消耗高的問題。每當事件發生時,應用程式都會將產生的事件放入事件佇列當中,然後會輪詢從佇列中取出事件執行或者將事件分發給相應的事件監聽者執行。事件執行的方式通常分為立即執行、延後執行、定期執行幾種,Netty 中的事件執行方式也是這樣,只是事件名稱上稍有差異。

Netty 中 EventLoop 的實現大概是這樣,當EventLoop首次收到任務後,在其內部例項化一個執行緒,這個執行緒run()方法的主體邏輯是 for 迴圈來處理事件(Selector上監聽到的 I/O 任務)和 非同步任務(⾮ I/O 任務,每個 EventLoop 都擁有它自已的非同步任務佇列):

  • 事件(I/O 任務):如 OP_ACCEPT(接收連線事件)、OP_CONNECT(連線事件)、OP_READ(讀事件)、OP_WRITE(寫事件)等,由 processSelectedKeys() ⽅法觸發。
  • 非同步任務(⾮ I/O 任務):如 register0、bind0 等任務,以及其他顯式提交的排程任務最終將會被新增到 taskQueue 任務佇列中,由 runAllTasks ⽅法觸發。

事件和非同步任務是以先進先出(FIFO)的順序執行的。這樣可以透過保證位元組內容總是按正確的順序被處理。一個非同步任務提交的小細節,當呼叫 execute() 或者submit()方法提交非同步任務的執行緒剛好是EventLoop中的執行緒,則任務會被立即執行,而無需再投入到taskQueue中。

Seata 高效能 RPC 通訊的實現基石-Netty篇

EventLoop中除了可以提交這種普通非同步任務,還可以提交定時任務(也算一種特殊的非同步任務),定時任務是排程一個任務在稍後(延遲)執行或者週期性地執行。例如,定時傳送心跳訊息到服務端,以檢查連線是否仍然還活著。如果沒有響應,你便知道可以關閉該 Channel 了。定時任務和普通非同步任務在EventLoop中的執行時機基本類似,其他區別之處在於:

  1. 提交⽅法 :
  • 定時非同步任務使⽤ scheduleAtFixedRate() 或者 scheduleWithFixedDelay() ⽅法提交任務
  • 普通非同步任務使⽤ execute() 或者submit()方法提交任務
  1. 任務佇列 :
  • 定時非同步任務提交到 ScheduleTaskQueue 任務佇列中
  • 普通非同步任務提交到 TaskQueue 任務佇列中

EventLoop 中 I/O 事件的處理優先順序是高於taskQueue中的非同步任務,優先順序的管控可透過ioRatio微調(請讀者老師自省查閱)。優先順序的控制方法是限制runAllTasks(xxx)這個方法中非同步任務的處理時長。在runAllTasks(xxx),會提取定時任務佇列ScheduleTaskQueue中到時間點需要被執行的任務,轉移到taskQueue排隊,之後從taskQueue裡面逐個取出任務並執行,當本次runAllTasks()中處理耗時超過限定時間後終止,轉去繼續處理 I/O 事件,如此形成迴圈。

Seata 高效能 RPC 通訊的實現基石-Netty篇

NioEventLoop#run()的核心邏輯是處理完輪詢到的 key 之後, 首先記錄下耗時, 然後透過 runAllTasks(ioTime \* (100 - ioRatio) / ioRatio),限時執行taskQueue中的任務

protected void run() {
    for (;;) {
        try {
            switch (selectStrategy.calculateStrategy(selectNowSupplier, hasTasks())) {
                case SelectStrategy.CONTINUE:
                    continue;
                case SelectStrategy.SELECT:
                    //輪詢io事件(1)
                    select(wakenUp.getAndSet(false));
                    if (wakenUp.get()) {
                        selector.wakeup();
                    }
                default:
            }
            cancelledKeys = 0;
            needsToSelectAgain = false;
            //預設是50
            final int ioRatio = this.ioRatio;
            if (ioRatio == 100) {
                try {
                    processSelectedKeys();
                } finally {
                    runAllTasks();
                }
            } else {
                //記錄下開始時間
                final long ioStartTime = System.nanoTime();
                try {
                    //處理輪詢到的key(2)
                    processSelectedKeys();
                } finally {
                    //計算耗時
                    final long ioTime = System.nanoTime() - ioStartTime;
                    //執行task(3)
                    runAllTasks(ioTime * (100 - ioRatio) / ioRatio);
                }
            }
        } catch (Throwable t) {
            handleLoopException(t);
        }
        //程式碼省略
    }
}

runAllTasks(xxx)非同步任務的限時處理環節,會提取定時任務佇列ScheduleTaskQueue中到時間點需要被執行的任務,轉移到taskQueue排隊,之後從taskQueue裡面逐個取出任務並執行,處理耗時超過限定時間後終止任務處理,退出方法。

protected boolean runAllTasks(long timeoutNanos) {
    //定時任務佇列中提到點取需執行任務
    fetchFromScheduledTaskQueue();
    //從普通taskQ裡面拿一個任務
    Runnable task = pollTask();
    //task為空, 則直接返回
    if (task == null) {
        //跑完所有的任務執行收尾的操作
        afterRunningAllTasks();
        return false;
    }
    //如果佇列不為空
    //首先算一個截止時間(+50毫秒, 因為執行任務, 不要超過這個時間)
    final long deadline = ScheduledFutureTask.nanoTime() + timeoutNanos;
    long runTasks = 0;
    long lastExecutionTime;
    //執行每一個任務
    for (;;) {
        safeExecute(task);
        //標記當前跑完的任務
        runTasks ++;
        //當跑完64個任務的時候, 會計算一下當前時間
        if ((runTasks & 0x3F) == 0) {
            //定時任務初始化到當前的時間
            lastExecutionTime = ScheduledFutureTask.nanoTime();
            //如果超過截止時間則不執行(nanoTime()是耗時的)
            if (lastExecutionTime >= deadline) {
                break;
            }
        }
        //如果沒有超過這個時間, 則繼續從普通任務佇列拿任務
        task = pollTask();
        //直到沒有任務執行
        if (task == null) {
            //記錄下最後執行時間
            lastExecutionTime = ScheduledFutureTask.nanoTime();
            break;
        }
    }
    //收尾工作
    afterRunningAllTasks();
    this.lastExecutionTime = lastExecutionTime;
    return true;
}

fetchFromScheduledTaskQueue()這個方法將定時任務中提取到點需執行的定時任務新增到 taskQueue

private boolean fetchFromScheduledTaskQueue() {
    long nanoTime = AbstractScheduledEventExecutor.nanoTime();
    //從定時任務佇列中抓取第一個定時任務
    //尋找截止時間為nanoTime的任務
    Runnable scheduledTask  = pollScheduledTask(nanoTime);
    //如果該定時任務佇列不為空, 則塞到普通任務佇列裡面
    while (scheduledTask != null) {
        //如果新增到普通任務佇列過程中失敗
        if (!taskQueue.offer(scheduledTask)) {
            //則重新新增到定時任務佇列中
            scheduledTaskQueue().add((ScheduledFutureTask<?>) scheduledTask);
            return false;
        }
        //繼續從定時任務佇列中拉取任務
        //方法執行完成之後, 所有符合執行條件的定時任務佇列, 都新增到了普通任務佇列中
        scheduledTask = pollScheduledTask(nanoTime);
    }
    return true;
}

EventLoop 將負責在同一個執行緒中處理一個或多個 Channel 的整個生命週期內的所有事件。這個情況也有弊端:

  1. 若事件處理耗時很長,將導致 I/O 流量下降。所以需考慮任務處理對系統效能的影響,選擇合適的 Netty 執行緒模型,配置合理的執行緒數

  2. EventLoop 被多個 Channel 複用,那麼這些 ChannelThreadLocal 都將是一樣的。

七、ChannelPipelineChannelHandler

上文提到Eventloop 中將Channel中的 I/O 事件派發給 ChannelHandler 處理,開發人員在ChannelHandler中新增對應事件的處理邏輯,從 NettyChannelHandler 的組織管理來說,開發者的視角是用ChannelPipelineChannelHandler以連結串列的方式串聯起來。如果一個完整的 I/O 處理流程是由 解碼構建訊息->接收處理訊息->回執傳送訊息->訊息編碼傳送 這四個步驟組成,那就用 4 個ChannelHandler分別實現這 4 步驟的邏輯,這種鏈式處理層次分明、程式碼清晰。

但從原始碼實現的角度是這樣的,Channel中有個ChannelPipeline屬性,建立Channel時,同時例項化這個ChannelPipeline屬性。ChannelPipeline串起的其實是ChannelHandlerContext,為什麼資料常說ChannelPipelineChannelHandler以連結串列的方式串聯起來呢?原因是被串起的 ChannelHandlerContext 中有個屬性是 ChannelHandlerChannelHandlerContext使得ChannelHandler能夠和它的ChannelPipeline以及其他的ChannelHandler 互動。

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70024923/viewspace-2941449/,如需轉載,請註明出處,否則將追究法律責任。

相關文章