Netty和RPC框架線程模型分析

《Netty 進階之路》、《分佈式服務框架原理與實踐》作者李林鋒深入剖析Netty和RPC框架線程模型。李林鋒已在 InfoQ 上開設 Netty 專題持續出稿,感興趣的同學可以持續關注。

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的執行策略,示例如下:

圖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操作的線程模型:

圖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操作的線程模型:

圖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操作線程模型比較簡答,它的原理圖如下所示:

圖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的線程細節,這確實是個非常好的設計理念,它的工作原理圖如下:

圖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服務端線程模型和交互圖如下所示:

圖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的執行,代碼示例如下:

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

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

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

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

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

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

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

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

3.2 客戶端線程模型

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

  1. 業務調用線程。

  2. 客戶端連接和I/O讀寫線程。

  3. 請求消息業務處理和響應回調線程。

3.2.1 客戶端線程模型概述

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

圖8 客戶端調用線程模型

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

  • 應用線程,負責調用gRPC服務端並獲取響應,其中請求消息的序列化由該線程負責。

  • 客戶端負載均衡以及Netty Client創建,由grpc-default-executor線程池負責。

  • HTTP/2客戶端鏈路創建、網絡I/O數據的讀寫,由Netty NioEventLoop線程負責。

  • 響應消息的反序列化由SerializingExecutor負責,與服務端不同的是,客戶端使用的是ThreadlessExecutor,並非JDK線程池。

  • SerializingExecutor通過調用responseFuture的set(value),喚醒阻塞的應用線程,完成一次RPC調用。

3.2.2 客戶端調用線程模型

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

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

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

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

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

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

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

3.3 線程模型總結

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

圖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線程(協議棧)和微服務調用線程分離,線程調度模型如下所示:

圖11 ServiceComb內置線程池

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

  1. 線程池用於執行同步模式的業務邏輯。

  2. 網絡收發及reactive模式的業務邏輯在Eventloop中執行,與線程池無關。

  3. 默認所有同步方法都在一個全局內置線程池中執行。

  4. 如果業務有特殊的需求,可以指定使用自定義的全局線程池,並且可以根據schemaId或operationId指定各自使用獨立的線程池,實現隔離倉的效果。

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

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

4.2 異步模式

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

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

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

圖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線程執行。

5. 作者簡介

李林鋒,10年Java NIO、平臺中間件設計和開發經驗,精通Netty、Mina、分佈式服務框架、API Gateway、PaaS等,《Netty進階之路》、《分佈式服務框架原理與實踐》作者。目前在華爲終端應用市場負責業務微服務化、雲化、全球化等相關設計和開發工作。

聯繫方式:新浪微博 Nettying 微信:Nettying

Email:[email protected]

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