Netty耗時的業務邏輯應該寫在哪兒,有什么注意事項?


 

 

 

 

 

更多技術分享可關注我

前言 

Netty以高性能著稱,但是在實際使用中,不可避免會遇到耗時的業務邏輯,那么這些耗時操作應該寫在哪兒呢,有什么注意的坑嗎?本篇文章將一一總結。原文:​Netty耗時的業務邏輯應該寫在哪兒,有什么注意事項?

Netty線程調度模型回顧

這部分內容前面都有總結,很簡單,只要心中有一個圖像就能hold住——對於Netty來說,它的每個NIO線程都對應一個轉動起來的“輪盤”,即I/O事件監聽+I/O事件分類處理+異步任務處理,三件事組成一個“輪盤”循環往復的轉動,直到被優雅停機或者異常中斷。。。大概結構如下:

具體細節和源碼的分析參考:

Netty的線程調度模型分析(5)

Netty的線程調度模型分析(6)

Netty的線程調度模型分析(7)

Netty的線程調度模型分析(8)

Netty的線程調度模型分析(9)

本文不再贅述。

 

Netty的NIO線程常見的阻塞場景

知道一個大前提:Netty的ChannelHandler是業務代碼和Netty框架交匯的地方(關於pipeline機制的細節后續專題分析,先知道即可),ChannelHandler里的業務邏輯,正常來說是由NioEventLoop(NIO)線程串行執行,以Netty服務端舉例,在服務端接收到新消息后,第一步要做的往往是用解碼的handler解碼消息的字節序列,字節序列解碼后就變為了消息對象,第二步將消息對象丟給后續的業務handler處理,此時如果某個業務handler的流程非常耗時,比如需要查詢數據庫,那么為了避免I/O線程(也就是Netty的NIO線程)被長時間占用,需要使用額外的非I/O線程池來執行這些耗時的業務邏輯,這也是基本操作。

下面看下NIO線程常見的阻塞情況,一共兩大類:

  • 無意識:在ChannelHandler中編寫了可能導致NIO線程阻塞的代碼,但是用戶沒有意識到,包括但不限於查詢各種數據存儲器的操作、第三方服務的遠程調用、中間件服務的調用、等待鎖等

  • 有意識:用戶知道有耗時邏輯需要額外處理,但是在處理過程中翻車了,比如主動切換耗時邏輯到業務線程池或者業務的消息隊列做處理時發生阻塞,最典型的有對方是阻塞隊列,鎖競爭激烈導致耗時,或者投遞異步任務給消息隊列時異機房的網絡耗時,或者任務隊列滿了導致等待,等等

JDK的線程池還是Netty的非I/O線程池?

如上一節的分析,不論是哪類原因,都需要使用非I/O線程池處理耗時的業務邏輯,這個操作有兩個注意的點,第一個點是需要確定使用什么樣的業務線程池,第二個點是這個線程池應該用在哪兒?

比如下面這個Netty線程池使用的架構圖,熟悉Netty線程調度模型的人一看就懂,但是具體到非業務線程池的使用細節可能一部分人就不知道了:

如上圖,既然知道了應該將耗時的業務邏輯封裝在額外的業務線程池中執行,那么是使用JDK的原生線程池,還是用其它的自定義線程池,比如Netty的線程池呢?

 

可以通過看Netty的ChannelPipeline源碼來找到答案,如下ChannelPipeline接口的注釋寫的很明白:

即Netty建議使用它自身提供的業務線程池來驅動非I/O的耗時業務邏輯,如果業務邏輯執行時間很短或者是完全異步的,那么不需要使用額外的非I/O線程池。而且具體用法是Netty在添加handler時,在ChannelPipeline接口提供了一個重載的addLast方法,專用於為對應handler添加Netty業務線程池,如下:

其最終的內部實現如下:

提交的業務線程池——group對象,會被包裹進Netty的pipeline的新節點中,最終會賦值給該handler節點的父類的線程池executor對象,這樣后續該handler被執行時,會將執行的任務提交到指定的業務線程池——group執行。如下是pipeline的新節點的數據結構——AbstractChannelHandloerContext:

關於Netty的handler添加機制以及pipeline機制后續分析,暫時看不懂沒關系,先簡單了解。

 

直接看demo,重點是兩個紅框的代碼:

I/O線程池是Nio開頭的group,非I/O線程池使用DefaultEventExecutorGroup這個Netty默認實現的線程池。在看第二個紅框處,ChannelInitializer內部類里BusinessHandler這個入站處理器使用DefaultEventExecutorGroup執行,該處理器在channelRead事件方法里模擬一個耗時邏輯,如下休眠3s模擬查詢大量數據:

回到demo:

EchoServerHandler也是入站處理器,且被添加在pipeline的最后,那么進入服務器的字節序列會先進入BusinessHandler,再進入EchoServerHandler,下面是測試結果:

看紅框發現BusinessHandler被非I/O線程驅動,EchoServerHandler被NIO線程驅動,它的執行不受耗時業務的影響。且BusinessHandler的channelRead方法內,會將該channelRead事件繼續傳播出去,因為調用了父類的channelRead方法,如下:

這樣會執行到下一個入站handler——EchoServerHandler的channelRead方法。

異步的執行結果怎么回到Netty的NIO線程?

我看不少初學者會搞不明白這里,即將異步任務丟給了服務端外部的非I/O線程池執行,那外一客戶端需要異步任務的計算結果,這個計算的結果怎么回到Netty的NIO線程呢,即他們懷疑或者說不理解這個異步結果是怎么被Netty發出去給客戶端的呢?

可以繼續看第3節的demo的執行結果:

發現BusinessHandler確實是被非I/O線程驅動的,即日志打印的線程名是default開頭的,而EchoServerHandler又確實是被NIO線程驅動的,即日志打印的線程是nio開頭的,它的執行不受耗時業務的影響。這底層流轉到底是怎么回事呢,且看分解。如下是demo里,BusinessHandler的channelRead方法:

該方法是一個回調的用戶事件,當Channel里有數據可讀時,Netty會主動調用它,這種機制后續專題總結,這里知道結論即可。注意紅線處前面也提到了——會將該channelRead事件繼續傳播給下一個handler節點,即執行到下一個入站處理器——EchoServerHandler的channelRead方法。而在pipeline上傳播這個事件時,Netty會對其驅動的傳播過程做一個判斷。看如下的invokeChannelRead方法源碼:其中參數next是入站節點EchoServerHandler,其executor是NIO線程,核心代碼如下紅框處——會做一個判斷:

如果當前執行的線程是Netty的NIO線程(就是該Channel綁定的那個NIO線程,即executor,暫時不理解也沒關系,知道結論,后續專題分析),那么就直接驅動,如果不是NIO線程,那么會將該流程封裝成一個新的task扔到NIO線程的MPSCQ,排隊等待被NIO線程處理,這里關於MPSCQ可以參考:Netty的線程調度模型分析(9)。因此將耗時的業務邏輯放到非NIO線程池處理,也不會影響Netty的I/O調度,仍然能通過NIO線程向客戶端返回結果。

JDK的線程池拒絕策略的坑可能導致阻塞

使用過線程池的都知道,如果業務邏輯處理慢,那么會導致線程池的阻塞隊列積壓任務,當積壓任務達到容量上限,JDK有對應的處理策略,一般有如下幾個已經提供的拒絕策略:

ThreadPoolExecutor.AbortPolicy:丟棄任務並拋出RejectedExecutionException異常。
ThreadPoolExecutor.DiscardPolicy:也是丟棄任務,但是不拋出異常。
ThreadPoolExecutor.DiscardOldestPolicy:丟棄隊列最前面的任務,然后重新嘗試執行任務(重復此過程)
ThreadPoolExecutor.CallerRunsPolicy:由調用線程處理該任務

JDK線程池默認是AbortPolicy,即主動拋出RejectedExecutionException

回到Netty,如果使用其非I/O線程池不當,可能造成NIO線程阻塞,比如業務上有人會設置線程池滿的拒絕策略為CallerRunsPolicy 策略,這導致會由調用方的線程——NioEventLoop線程執行業務邏輯,最終導致NioEventLoop線程可能被長時間阻塞,在服務端就是無法及時的讀取新的請求消息。

實際使用Netty時,一定注意這個坑。即當提交的任務的阻塞隊列滿時,再向隊列加入新的任務,千萬不能阻塞NIO線程,要么丟棄當前任務,或者使用流控並向業務方和運維人員報警的方式規避這個問題,比如及時的動態擴容,或者提高算法能力,提升機器性能等。

使用Netty非I/O線程池的正確姿勢

前面其實分析過Netty的EventExecutorGroup線程池,可以參考:Netty的線程調度模型分析(10)——《Netty有幾類線程池,它們的區別,以及和JDK線程池區別?》,它也是類似NIO的線程池機制,只不過它沒有綁定I/O多路復用器,它和Channel的綁定關系和NIO線程池一樣,也是來一個新連接,就用線程選擇器選擇一個線程與之綁定,后續該連接上的所有非I/O任務,都在這一個線程中串行執行,此時並不能發揮EventExecutorGroup的作用,即使初始值設置100個線程也無濟於事。

兩句話:

1、如果所有客戶端的並發連接數小於業務線程數,那么建議將請求消息封裝成任務投遞到后端普通業務線程池執行即可,ChannelHandler不需要處理復雜業務邏輯,也不需要再綁定EventExecutorGroup

2、如果所有客戶端的並發連接數大於等於業務需要配置的線程數,那么可以為業務ChannelHandler綁定EventExecutorGroup——使用addLast的方法

后記

dashuai的博客是終身學習踐行者,大廠程序員,且專注於工作經驗、學習筆記的分享和日常吐槽,包括但不限於互聯網行業,附帶分享一些PDF電子書,資料,幫忙內推,歡迎拍磚!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM