Netty 和 RPC 框架線程模型分析「轉」

1. 背景

1.1 線程模型的重要性

對於 RPC 框架而言,影響其性能指標的主要有三個要素:

  1. I/O 模型:採用的是同步 BIO、還是非阻塞的 NIO、以及全異步的事件驅動 I/O(AIO)。
  2. 協議和序列化方式:它主要影響消息的序列化、反序列化性能,以及消息的通信效率。
  3. 線程模型:主要影響消息的讀取和發送效率、以及調度的性能。

除了對性能有影響,在一些場景下,線程模型的變化也會影響到功能的正確性,例如 Netty 從 3.X 版本升級到 4.X 版本之後,重構和優化了線程模型。當業務沒有意識到線程模型發生變化時,就會踩到一些性能和功能方面的坑。

1.2 Netty 和 RPC 框架的線程模型關係

作為一個高性能的 NIO 通信框架,Netty 主要關注的是 I/O 通信相關的線程工作策略,以及提供的用戶擴展點 ChannelHandler 的執行策略,示例如下:

Netty 和 RPC 框架線程模型分析「轉」

圖 1 Netty 多線程模型

該線程模型的工作特點如下:

  1. 有專門一個(一組)NIO 線程 -Acceptor 線程用於監聽服務端,接收客戶端的 TCP 連接請求。
  2. 網絡 I/O 操作 - 讀、寫等由一個 NIO 線程池負責,線程池可以採用標準的 JDK 線程池實現,它包含一個任務隊列和 N 個可用的線程,由這些 NIO 線程負責消息的讀取、解碼、編碼和發送。
  3. 1 個 NIO 線程可以同時處理 N 條鏈路,但是 1 個鏈路只對應 1 個 NIO 線程,防止發生併發操作問題。

對於 RPC 框架,它的線程模型會更復雜一些,除了通信相關的 I/O 線程模型,還包括服務接口調用、服務訂閱 / 發佈等相關的業務側線程模型。對於基於 Netty 構建的 RPC 框架,例如 gRPC、Apache ServiceComb 等,它在重用 Netty 線程模型的基礎之上,也擴展實現了自己的線程模型。

2. Netty 線程模型

2.1 線程模型的變更

2.1.1 Netty 3.X 版本線程模型

Netty 3.X 的 I/O 操作線程模型比較複雜,它的處理模型包括兩部分:

  1. Inbound:主要包括鏈路建立事件、鏈路激活事件、讀事件、I/O 異常事件、鏈路關閉事件等。
  2. Outbound:主要包括寫事件、連接事件、監聽綁定事件、刷新事件等。

我們首先分析下 Inbound 操作的線程模型:

Netty 和 RPC 框架線程模型分析「轉」

圖 2 Netty 3 Inbound 操作線程模型

從上圖可以看出,Inbound 操作的主要處理流程如下:

  1. I/O 線程(Work 線程)將消息從 TCP 緩衝區讀取到 SocketChannel 的接收緩衝區中。
  2. 由 I/O 線程負責生成相應的事件,觸發事件向上執行,調度到 ChannelPipeline 中。
  3. I/O 線程調度執行 ChannelPipeline 中 Handler 鏈的對應方法,直到業務實現的 Last Handler。
  4. Last Handler 將消息封裝成 Runnable,放入到業務線程池中執行,I/O 線程返回,繼續讀 / 寫等 I/O 操作。
  5. 業務線程池從任務隊列中彈出消息,併發執行業務邏輯。

通過對 Netty 3 的 Inbound 操作進行分析我們可以看出,Inbound 的 Handler 都是由 Netty 的 I/O Work 線程負責執行。

下面我們繼續分析 Outbound 操作的線程模型:

Netty 和 RPC 框架線程模型分析「轉」

圖 3 Netty 3 Outbound 操作線程模型

從上圖可以看出,Outbound 操作的主要處理流程如下:

  1. 業務線程發起 Channel Write 操作,發送消息。
  2. Netty 將寫操作封裝成寫事件,觸發事件向下傳播。
  3. 寫事件被調度到 ChannelPipeline 中,由業務線程按照 Handler Chain 串行調用支持 Downstream 事件的 Channel Handler。
  4. 執行到系統最後一個 ChannelHandler,將編碼後的消息 Push 到發送隊列中,業務線程返回。
  5. Netty 的 I/O 線程從發送消息隊列中取出消息,調用 SocketChannel 的 write 方法進行消息發送。

2.1.2 Netty 4.X 版本線程模型

相比於 Netty 3.X 系列版本,Netty 4.X 的 I/O 操作線程模型比較簡答,它的原理圖如下所示:

Netty 和 RPC 框架線程模型分析「轉」

圖 4 Netty 4 Inbound 和 Outbound 操作線程模型

從上圖可以看出,Outbound 操作的主要處理流程如下:

  1. I/O 線程 NioEventLoop 從 SocketChannel 中讀取數據報,將 ByteBuf 投遞到 ChannelPipeline,觸發 ChannelRead 事件。
  2. I/O 線程 NioEventLoop 調用 ChannelHandler 鏈,直到將消息投遞到業務線程,然後 I/O 線程返回,繼續後續的讀寫操作。
  3. 業務線程調用 ChannelHandlerContext.write(Object msg) 方法進行消息發送。
  4. 如果是由業務線程發起的寫操作,ChannelHandlerInvoker 將發送消息封裝成 Task,放入到 I/O 線程 NioEventLoop 的任務隊列中,由 NioEventLoop 在循環中統一調度和執行。放入任務隊列之後,業務線程返回。
  5. I/O 線程 NioEventLoop 調用 ChannelHandler 鏈,進行消息發送,處理 Outbound 事件,直到將消息放入發送隊列,然後喚醒 Selector,進而執行寫操作。

通過流程分析,我們發現 Netty 4 修改了線程模型,無論是 Inbound 還是 Outbound 操作,統一由 I/O 線程 NioEventLoop 調度執行。

2.1.3 新老線程模型對比

在進行新老版本線程模型對比之前,首先還是要熟悉下串行化設計的理念:

我們知道當系統在運行過程中,如果頻繁的進行線程上下文切換,會帶來額外的性能損耗。多線程併發執行某個業務流程,業務開發者還需要時刻對線程安全保持警惕,哪些數據可能會被併發修改,如何保護?這不僅降低了開發效率,也會帶來額外的性能損耗。

為了解決上述問題,Netty 4 採用了串行化設計理念,從消息的讀取、編碼以及後續 Handler 的執行,始終都由 I/O 線程 NioEventLoop 負責,這就意外著整個流程不會進行線程上下文的切換,數據也不會面臨被併發修改的風險,對於用戶而言,甚至不需要了解 Netty 的線程細節,這確實是個非常好的設計理念,它的工作原理圖如下:

Netty 和 RPC 框架線程模型分析「轉」

圖 5 Netty 4 的串行化設計理念

一個 NioEventLoop 聚合了一個多路複用器 Selector,因此可以處理成百上千的客戶端連接,Netty 的處理策略是每當有一個新的客戶端接入,則從 NioEventLoop 線程組中順序獲取一個可用的 NioEventLoop,當到達數組上限之後,重新返回到 0,通過這種方式,可以基本保證各個 NioEventLoop 的負載均衡。一個客戶端連接只註冊到一個 NioEventLoop 上,這樣就避免了多個 I/O 線程去併發操作它。

Netty 通過串行化設計理念降低了用戶的開發難度,提升了處理性能。利用線程組實現了多個串行化線程水平並行執行,線程之間並沒有交集,這樣既可以充分利用多核提升並行處理能力,同時避免了線程上下文的切換和併發保護帶來的額外性能損耗。

瞭解完了 Netty 4 的串行化設計理念之後,我們繼續看 Netty 3 線程模型存在的問題,總結起來,它的主要問題如下:

  1. Inbound 和 Outbound 實質都是 I/O 相關的操作,它們的線程模型竟然不統一,這給用戶帶來了更多的學習和使用成本。
  2. Outbound 操作由業務線程執行,通常業務會使用線程池並行處理業務消息,這就意味著在某一個時刻會有多個業務線程同時操作 ChannelHandler,我們需要對 ChannelHandler 進行併發保護,通常需要加鎖。如果同步塊的範圍不當,可能會導致嚴重的性能瓶頸,這對開發者的技能要求非常高,降低了開發效率。
  3. Outbound 操作過程中,例如消息編碼異常,會產生 Exception,它會被轉換成 Inbound 的 Exception 並通知到 ChannelPipeline,這就意味著業務線程發起了 Inbound 操作!它打破了 Inbound 操作由 I/O 線程操作的模型,如果開發者按照 Inbound 操作只會由一個 I/O 線程執行的約束進行設計,則會發生線程併發訪問安全問題。由於該場景只在特定異常時發生,因此錯誤非常隱蔽!一旦在生產環境中發生此類線程併發問題,定位難度和成本都非常大。

講了這麼多,似乎 Netty 4 完勝 Netty 3 的線程模型,其實並不盡然。在特定的場景下,Netty 3 的性能可能更高,如果編碼和其它 Outbound 操作非常耗時,由多個業務線程併發執行,性能肯定高於單個 NioEventLoop 線程。

但是,這種性能優勢不是不可逆轉的,如果我們修改業務代碼,將耗時的 Handler 操作前置,Outbound 操作不做複雜業務邏輯處理,性能同樣不輸於 Netty 3, 但是考慮內存池優化、不會反覆創建 Event、不需要對 Handler 加鎖等 Netty 4 的優化,整體性能 Netty 4 版本肯定會更高。

2.2 Netty 4.X 版本線程模型實踐經驗

2.2.1 時間可控的簡單業務直接在 I/O 線程上處理

如果業務非常簡單,執行時間非常短,不需要與外部網元交互、訪問數據庫和磁盤,不需要等待其它資源,則建議直接在業務 ChannelHandler 中執行,不需要再啟業務的線程或者線程池。避免線程上下文切換,也不存在線程併發問題。

2.2.2 複雜和時間不可控業務建議投遞到後端業務線程池統一處理

對於此類業務,不建議直接在業務 ChannelHandler 中啟動線程或者線程池處理,建議將不同的業務統一封裝成 Task,統一投遞到後端的業務線程池中進行處理。

過多的業務 ChannelHandler 會帶來開發效率和可維護性問題,不要把 Netty 當作業務容器,對於大多數複雜的業務產品,仍然需要集成或者開發自己的業務容器,做好和 Netty 的架構分層。

2.2.3 業務線程避免直接操作 ChannelHandler

對於 ChannelHandler,I/O 線程和業務線程都可能會操作,因為業務通常是多線程模型,這樣就會存在多線程操作 ChannelHandler。為了儘量避免多線程併發問題,建議按照 Netty 自身的做法,通過將操作封裝成獨立的 Task 由 NioEventLoop 統一執行,而不是業務線程直接操作。

3. gRPC 線程模型

gRPC 的線程模型主要包括服務端線程模型和客戶端線程模型,其中服務端線程模型主要包括:

  • 服務端監聽和客戶端接入線程(HTTP /2 Acceptor)。
  • 網絡 I/O 讀寫線程。
  • 服務接口調用線程。

客戶端線程模型主要包括:

  • 客戶端連接線程(HTTP/2 Connector)。
  • 網絡 I/O 讀寫線程。
  • 接口調用線程。
  • 響應回調通知線程。

3.1 服務端線程模型

gRPC 服務端線程模型整體上可以分為兩大類:

  • 網絡通信相關的線程模型,基於 Netty4.1 的線程模型實現。
  • 服務接口調用線程模型,基於 JDK 線程池實現。

3.1.1 服務端線程模型概述

gRPC 服務端線程模型和交互圖如下所示:

Netty 和 RPC 框架線程模型分析「轉」

圖 6 gRPC 服務端線程模型

其中,HTTP/2 服務端創建、HTTP/2 請求消息的接入和響應發送都由 Netty 負責,gRPC 消息的序列化和反序列化、以及應用服務接口的調用由 gRPC 的 SerializingExecutor 線程池負責。

3.1.2 服務調度線程模型

gRPC 服務調度線程主要職責如下:

  • 請求消息的反序列化,主要包括:HTTP/2 Header 的反序列化,以及將 PB(Body) 反序列化為請求對象。
  • 服務接口的調用,method.invoke(非反射機制)。
  • 將響應消息封裝成 WriteQueue.QueuedCommand,寫入到 Netty Channel 中,同時,對響應 Header 和 Body 對象做序列化。

服務端調度的核心是 SerializingExecutor,它同時實現了 JDK 的 Executor 和 Runnable 接口,既是一個線程池,同時也是一個 Task。

SerializingExecutor 聚合了 JDK 的 Executor,由 Executor 負責 Runnable 的執行,代碼示例如下:

Netty 和 RPC 框架線程模型分析「轉」

其中,Executor 默認使用的是 JDK 的 CachedThreadPool,在構建 ServerImpl 的時候進行初始化,代碼如下:

Netty 和 RPC 框架線程模型分析「轉」

當服務端接收到客戶端 HTTP/2 請求消息時,由 Netty 的 NioEventLoop 線程切換到 gRPC 的 SerializingExecutor,進行消息的反序列化、以及服務接口的調用,代碼示例如下:

Netty 和 RPC 框架線程模型分析「轉」

相關的調用堆棧,示例如下:

Netty 和 RPC 框架線程模型分析「轉」

響應消息的發送,由 SerializingExecutor 發起,將響應消息頭和消息體序列化,然後分別封裝成 SendResponseHeadersCommand 和 SendGrpcFrameCommand,調用 Netty NioSocketChannle 的 write 方法,發送到 Netty 的 ChannelPipeline 中,由 gRPC 的 NettyServerHandler 攔截之後,真正寫入到 SocketChannel 中,代碼如下所示:

Netty 和 RPC 框架線程模型分析「轉」

響應消息體的發送堆棧如下所示:

Netty 和 RPC 框架線程模型分析「轉」

Netty I/O 線程和服務調度線程的運行分工界面以及切換點如下所示:

Netty 和 RPC 框架線程模型分析「轉」

圖 7 網絡 I/O 線程和服務調度線程交互圖

事實上,在實際服務接口調用過程中,NIO 線程和服務調用線程切換次數遠遠超過 4 次,頻繁的線程切換對 gRPC 的性能帶來了一定的損耗。

3.2 客戶端線程模型

gRPC 客戶端的線程主要分為三類:

  1. 業務調用線程。
  2. 客戶端連接和 I/O 讀寫線程。
  3. 請求消息業務處理和響應回調線程。

3.2.1 客戶端線程模型概述

gRPC 客戶端線程模型工作原理如下圖所示(同步阻塞調用為例):

Netty 和 RPC 框架線程模型分析「轉」

圖 8 客戶端調用線程模型

客戶端調用主要涉及的線程包括:

  • 應用線程,負責調用 gRPC 服務端並獲取響應,其中請求消息的序列化由該線程負責。
  • 客戶端負載均衡以及 Netty Client 創建,由 grpc-default-executor 線程池負責。
  • HTTP/2 客戶端鏈路創建、網絡 I/O 數據的讀寫,由 Netty NioEventLoop 線程負責。
  • 響應消息的反序列化由 SerializingExecutor 負責,與服務端不同的是,客戶端使用的是 ThreadlessExecutor,並非 JDK 線程池。
  • SerializingExecutor 通過調用 responseFuture 的 set(value),喚醒阻塞的應用線程,完成一次 RPC 調用。

3.2.2 客戶端調用線程模型

客戶端調用線程交互流程如下所示:

Netty 和 RPC 框架線程模型分析「轉」

圖 9 客戶端線程交互原理圖

請求消息的發送由用戶線程發起,相關代碼示例如下:

Netty 和 RPC 框架線程模型分析「轉」

HTTP/2 Header 的創建、以及請求參數反序列化為 Protobuf,均由用戶線程負責完成,相關代碼示例如下:

Netty 和 RPC 框架線程模型分析「轉」

用戶線程將請求消息封裝成 CreateStreamCommand 和 SendGrpcFrameCommand,發送到 Netty 的 ChannelPipeline 中,然後返回,完成線程切換。後續操作由 Netty NIO 線程負責,相關代碼示例如下:

Netty 和 RPC 框架線程模型分析「轉」

客戶端響應消息的接收,由 gRPC 的 NettyClientHandler 負責,相關代碼如下所示:

Netty 和 RPC 框架線程模型分析「轉」

接收到 HTTP/2 響應之後,Netty 將消息投遞到 SerializingExecutor,由 SerializingExecutor 的 ThreadlessExecutor 負責響應的反序列化,以及 responseFuture 的設值,相關代碼示例如下:

Netty 和 RPC 框架線程模型分析「轉」

3.3 線程模型總結

消息的序列化和反序列化均由 gRPC 線程負責,而沒有在 Netty 的 Handler 中做 CodeC,原因如下:Netty4 優化了線程模型,所有業務 Handler 都由 Netty 的 I/O 線程負責,通過串行化的方式消除鎖競爭,原理如下所示:

Netty 和 RPC 框架線程模型分析「轉」

圖 10 Netty4 串行執行 Handler

如果大量的 Handler 都在 Netty I/O 線程中執行,一旦某些 Handler 執行比較耗時,則可能會反向影響 I/O 操作的執行,像序列化和反序列化操作,都是 CPU 密集型操作,更適合在業務應用線程池中執行,提升併發處理能力。因此,gRPC 並沒有在 I/O 線程中做消息的序列化和反序列化。

4. Apache ServiceComb 微服務框架線程模型

Apache ServiceComb 底層通信框架基於 Vert.X(Netty) 構建,它重用了 Netty 的 EventLoop 線程模型,考慮到目前同步 RPC 調用仍然是主流模式,因此,針對同步 RPC 調用,在 Vert.X 線程模型基礎之上,提供了額外的線程模型封裝。

下面我們分別對同步和異步模式的線程模型進行分析。

4.1 同步模式

核心設計理念是 I/O 線程(協議棧)和微服務調用線程分離,線程調度模型如下所示:

Netty 和 RPC 框架線程模型分析「轉」

圖 11 ServiceComb 內置線程池

同步模式下 ServiceComb 的線程模型特點如下:

  1. 線程池用於執行同步模式的業務邏輯。
  2. 網絡收發及 reactive 模式的業務邏輯在 Eventloop 中執行,與線程池無關。
  3. 默認所有同步方法都在一個全局內置線程池中執行。
  4. 如果業務有特殊的需求,可以指定使用自定義的全局線程池,並且可以根據 schemaId 或 operationId 指定各自使用獨立的線程池,實現隔離倉的效果。

基於 ServiceComb 定製線程池策略實現的微服務隔離倉效果如下所示:

Netty 和 RPC 框架線程模型分析「轉」

圖 12 基於 ServiceComb 的微服務故障隔離倉

4.2 異步模式

ServiceComb 的異步模式即純 Reactive 機制,它的代碼示例如下:

<code>public interface Intf{
  CompletableFuture hello(String name);
}
@GetMapping(path = "/hello/{name}")public CompletableFuture hello(@PathVariable(name = "name") String name){
  CompletableFuture future = new CompletableFuture<>();
  intf.hello(name).whenComplete((result, exception) -> {
    if (exception == null) {
      future.complete("from remote: " + result);
      return;
    }
 
    future.completeExceptionally(exception);
  });
  return future;/<code>

與之對應的線程調度流程如下所示:

Netty 和 RPC 框架線程模型分析「轉」

圖 13 基於 ServiceComb 的 Reactive 線程模型

它的特點總結如下:

  1. 所有功能都在 eventloop 中執行,並不會進行線程切換。
  2. 橙色箭頭走完後,對本線程的佔用即完成了,不會阻塞等待應答,該線程可以處理其他任務。
  3. 當收到遠端應答後,由網絡數據驅動開始走紅色箭頭的應答流程。
  4. 只要有任務,線程就不會停止,會一直執行任務,可以充分利用 cpu 資源,也不會產生多餘的線程切換,去無謂地消耗 cpu。

4.3. 線程模型總結

ServiceComb 的同步和異步 RPC 調用對應的線程模型存在差異,對於純 Reactive 的異步,I/O 讀寫與微服務業務邏輯執行共用同一個 EventLoop 線程,在一次服務端 RPC 調用時不存在線程切換,性能最優。但是,這種模式也存在一些約束,例如要求微服務業務邏輯執行過程中不能有任何可能會導致同步阻塞的操作,包括但不限於數據庫操作、緩存讀寫、第三方 HTTP 服務調用、本地 I/O 讀寫等(本質就是要求全棧異步)。

對於無法做到全棧異步的業務,可以使用 ServiceComb 同步編程模型,同時根據不同微服務接口的重要性和優先級,利用定製線程池策略,實現接口級的線程隔離。

需要指出的是,ServiceComb 根據接口定義來決定採用哪種線程模型,如果返回值是 CompletableFuture,業務又沒有對接口指定額外的線程池,則默認採用 Reactive 模式,即業務微服務接口由 Vert.X 的 EventLoop 線程執行。

原文地址:https://www.infoq.cn/article/9Ib3hbKSgQaALj02-90y


分享到:


相關文章: