從入門到實戰,Netty多線程篇案例集錦


 

從入門到實戰,Netty多線程篇案例集錦

 

Netty案例集錦系列文章介紹

 

1Netty的特點

 

Netty入門比較簡單,主要原因有如下幾點:

 

  • Netty的API封裝比較簡單,將復雜的網絡通信通過BootStrap等工具類做了二次封裝,用戶使用起來比較簡單;

  • Netty源碼自帶的Demo比較多,通過Demo可以很快入門;

  • Netty社區資料、相關學習書籍也比較多,學習資料比較豐富。

 

但是很多入門之后的Netty學習者遇到了很多困惑,例如不知道在實際項目中如何使用Netty、遇到Netty問題之后無從定位等,這些問題嚴重制約了對Netty的深入掌握和實際項目應用。

 

Netty相關問題比較難定位的主要原因如下:

 

1) NIO編程自身的復雜性,涉及到大量NIO類庫、Netty自身封裝的類庫等,當你需要打開黑盒定位問題時,必須對這些類庫了如指掌;否則即便定位到問題所在,也不知所以然,更無法修復;

 

2) Netty復雜的多線程模型,用戶在實際使用Netty時,會涉及到Netty自己封裝的線程組、線程池、NIO線程,以及業務線程,通信鏈路的創建、I/O消息的讀寫會涉及到復雜的線程切換,這會讓初學者雲山霧繞,調試起來非常痛苦,甚至都不知道從哪里調試;

 

3) Netty版本的跨度大,從實際商用情況看,涉及到了Netty 3.X、4.X和5.X等多個版本,每個Major版本之間特性變化非常大,即便是Minor版本都存在一些差異,這些功能特性和類庫差異會給使用者帶來很多問題,版本升級之后稍有不慎就會掉入陷阱。

 

2|案例來源

 

Netty案例集錦的案例來源於作者在實際項目中遇到的問題總結、以及Netty社區網友的反饋,大多數案例都來源於實際項目,也有少部分是讀者在學習Netty中遭遇的比較典型的問題。

 

3|多線程篇

 

學習和掌握Netty多線程模型是個難點,在實際項目中如何使用好Netty多線程更加困難,很多網上問題和事故都來源於對Netty線程模型了解不透徹所致。鑒於此,Netty案例集錦系列就首先從多線程方面開始。

 

 

Netty 3 版本升級遭遇內存泄漏案例

 

1|問題描述

 

業務代碼升級Netty 3到Netty4之后,運行一段時間,Java進程就會宕機,查看系統運行日志發現系統發生了內存泄露(示例堆棧):

圖2-1 內存泄漏堆棧

 

對內存進行監控(切換使用堆內存池,方便對內存進行監控),發現堆內存一直飆升,如下所示(示例堆內存監控):

 

圖2-2 堆內存監控示例

2|問題定位

 

使用jmap -dump:format=b,file=netty.bin PID 將堆內存dump出來,通過IBM的HeapAnalyzer工具進行分析,發現ByteBuf發生了泄露。

 

因為使用了Netty 4的內存池,所以首先懷疑是不是申請的ByteBuf沒有被釋放導致?查看代碼,發現消息發送完成之后,Netty底層已經調用ReferenceCountUtil.release(message)對內存進行了釋放。這是怎么回事呢?難道Netty 4.X的內存池有Bug,調用release操作釋放內存失敗?

 

考慮到Netty 內存池自身Bug的可能性不大,首先從業務的使用方式入手分析:

 

1)內存的分配是在業務代碼中進行,由於使用到了業務線程池做I/O操作和業務操作的隔離,實際上內存是在業務線程中分配的;

 

2)內存的釋放操作是在outbound中進行,按照Netty 3的線程模型,downstream(對應Netty 4的outbound,Netty 4取消了upstream和downstream)的handler也是由業務調用者線程執行的,也就是說申請和釋放在同一個業務線程中進行。初次排查並沒有發現導致內存泄露的根因,繼續分析Netty內存池的實現原理。

 

Netty 內存池實現原理分析:查看Netty的內存池分配器PooledByteBufAllocator的源碼實現,發現內存池實際是基於線程上下文實現的,相關代碼如下:

 

圖2-3

也就是說內存的申請和釋放必須在同一線程上下文中,不能跨線程。跨線程之后實際操作的就不是同一塊兒內存區域,這會導致很多嚴重的問題,內存泄露便是其中之一。內存在A線程申請,切換到B線程釋放,實際是無法正確回收的。

 

3|問題根因

 

Netty 4修改了Netty 3的線程模型:在Netty 3的時候,upstream是在I/O線程里執行的,而downstream是在業務線程里執行。當Netty從網絡讀取一個數據報投遞給業務handler的時候,handler是在I/O線程里執行;而當我們在業務線程中調用write和writeAndFlush向網絡發送消息的時候,handler是在業務線程里執行,直到最后一個Header handler將消息寫入到發送隊列中,業務線程才返回。

 

Netty4修改了這一模型,在Netty 4里inbound(對應Netty 3的upstream)和outbound(對應Netty 3的downstream)都是在NioEventLoop(I/O線程)中執行。當我們在業務線程里通過ChannelHandlerContext.write發送消息的時候,Netty 4在將消息發送事件調度到ChannelPipeline的時候,首先將待發送的消息封裝成一個Task,然后放到NioEventLoop的任務隊列中,由NioEventLoop線程異步執行。后續所有handler的調度和執行,包括消息的發送、I/O事件的通知,都由NioEventLoop線程負責處理。

 

在本案例中,ByteBuf在業務線程中申請,在后續的ChannelHandler中釋放,ChannelHandler是由Netty的I/O線程(EventLoop)執行的,因此內存的申請和釋放不在同一個線程中,導致內存泄漏。

 

Netty 3的I/O事件處理流程:

圖2-4 Netty 3的I/O線程模型

 

Netty 4的I/O消息處理流程:

圖2-5 Netty 4 I/O線程模型

4|案例總結

 

Netty 4.X版本新增的內存池確實非常高效,但是如果使用不當則會導致各種嚴重的問題。諸如內存泄露這類問題,功能測試並沒有異常,如果相關接口沒有進行壓測或者穩定性測試而直接上線,則會導致嚴重的線上問題。

 

內存池PooledByteBuf的使用建議:

 

1)申請之后一定要記得釋放,Netty自身Socket讀取和發送的ByteBuf系統會自動釋放,用戶不需要做二次釋放;如果用戶使用Netty的內存池在應用中做ByteBuf的對象池使用,則需要自己主動釋放;

 

2)避免錯誤的釋放:跨線程釋放、重復釋放等都是非法操作,要避免。特別是跨線程申請和釋放,往往具有隱蔽性,問題定位難度較大;

 

3)防止隱式的申請和分配:之前曾經發生過一個案例,為了解決內存池跨線程申請和釋放問題,有用戶對內存池做了二次包裝,以實現多線程操作時,內存始終由包裝的管理線程申請和釋放,這樣可以屏蔽用戶業務線程模型和訪問方式的差異。誰知運行一段時間之后再次發生了內存泄露,最后發現原來調用ByteBuf的write操作時,如果內存容量不足,會自動進行容量擴展。擴展操作由業務線程執行,這就繞過了內存池管理線程,發生了“引用逃逸”;

 

4)避免跨線程申請和使用內存池,由於存在“引用逃逸”等隱式的內存創建,實際上跨線程申請和使用內存池是非常危險的行為。盡管從技術角度看可以實現一個跨線程協調的內存池機制,甚至重寫PooledByteBufAllocator,但是這無疑會增加很多復雜性,通常也使用不到。如果確實存在跨線程的ByteBuf傳遞,而且無法保證ByteBuf在另一個線程中會重新分配大小等操作,最簡單保險的方式就是在線程切換點做一次ByteBuf的拷貝,但這會造成性能下降。

 

比較好的一種方案就是如果存在跨線程的ByteBuf傳遞,對ByteBuf的寫操作要在分配線程完成,另一個線程只能做讀操作。操作完成之后發送一個事件通知分配線程,由分配線程執行內存釋放操作。

 

Netty 3 版本升級性能下降案例

 

1|問題描述

 

業務代碼升級Netty 3到Netty4之后,並沒有給產品帶來預期的性能提升,有些甚至還發生了非常嚴重的性能下降,這與Netty 官方給出的數據並不一致。

 

Netty 官方性能測試對比數據:我們比較了兩個分別建立在Netty 3和4基礎上echo協議服務器。(Echo非常簡單,這樣,任何垃圾的產生都是Netty的原因,而不是協議的原因)。我使它們服務於相同的分布式echo協議客戶端,來自這些客戶端的16384個並發連接重復發送256字節的隨機負載,幾乎使千兆以太網飽和。

 

根據測試結果,Netty 4:

 

  • GC中斷頻率是原來的1/5: 45.5 vs. 9.2次/分鍾

  • 垃圾生成速度是原來的1/5: 207.11 vs 41.81 MiB/秒

 

2|問題定位

 

首先通過JMC等性能分析工具對性能熱點進行分析,示例如下(信息安全等原因,只給出分析過程示例截圖):

圖3-1 性能熱點線程堆棧

 

通過對熱點方法的分析,發現在消息發送過程中,有兩處熱點:

 

1)消息發送性能統計相關Handler;

2)編碼Handler。

 

對使用Netty 3版本的業務產品進行性能對比測試,發現上述兩個Handler也是熱點方法。既然都是熱點,為啥切換到Netty4之后性能下降這么厲害呢?

 

通過方法的調用樹分析發現了兩個版本的差異:在Netty 3中,上述兩個熱點方法都是由業務線程負責執行;而在Netty 4中,則是由NioEventLoop(I/O)線程執行。對於某個鏈路,業務是擁有多個線程的線程池,而NioEventLoop只有一個,所以執行效率更低,返回給客戶端的應答時延就大。時延增大之后,自然導致系統並發量降低,性能下降。

 

找出問題根因之后,針對Netty 4的線程模型對業務進行專項優化,將耗時的編碼等操作遷移到業務線程中執行,為I/O線程減負,性能達到預期,遠超過了Netty 3老版本的性能。

 

Netty 3的業務線程調度模型圖如下所示:充分利用了業務多線程並行編碼和Handler處理的優勢,周期T內可以處理N條業務消息:

圖3-2 Netty 3 Handler執行線程模型

 

切換到Netty 4之后,業務耗時Handler被I/O線程串行執行,因此性能發生比較大的下降:

 

圖3-3 Netty 4 Handler執行線程模型

 

3|問題總結

 

該問題的根因還是由於Netty 4的線程模型變更引起,線程模型變更之后,不僅影響業務的功能,甚至對性能也會造成很大的影響。

 

對Netty的升級需要從功能、兼容性和性能等多個角度進行綜合考慮,切不可只盯着API變更這個芝麻,而丟掉了性能這個西瓜。API的變更會導致編譯錯誤,但是性能下降卻隱藏於無形之中,稍不留意就會中招。

 

對於講究快速交付、敏捷開發和灰度發布的互聯網應用,升級的時候更應該要當心。

 

 

Netty業務Handler接收不到消息案例

 

1|問題描述

 

我的服務碰到一個問題,經常有請求上來到MessageDecoder就結束了,沒有繼續往LogicServerHandler里面送,覺得很奇怪,是不是線程池滿了?我想請教:

 

1)netty 5如何打印executor線程的占用情況,如空閑線程數?

2)executor設置的大小一般如何進行計算的?

 

業務代碼示例如下:

 

 

2|問題定位

 

 

從服務端初始化代碼來看,並沒有什么問題,業務LogicServerHandler沒有接收到消息,有如下幾種可能:

 

1)客戶端並沒有將消息發送到服務端,可以在服務端LoggingHandler中打印日志查看;

 

2)服務端部分消息解碼發生異常,導致消息被丟棄/忽略,沒有走到LogicServerHandler中;

 

3)執行業務Handler的DefaultEventExecutor中的線程太繁忙,導致任務隊列積壓,長時間得不到處理。

 

通過抓包結合日志分析,可能導致問題的原因1和2排除,需要繼續對可能原因3進行排查。

 

Netty 5如何打印executor線程的占用情況,如空閑線程數?回答這些問題,首先要了解Netty的線程組和線程池機制。

 

Netty的EventExecutorGroup實際就是一組EventExecutor,它的定義如下:

 

 

通常通過它的next方法從線程組中獲取一個線程池,代碼如下:

 

Netty EventExecutor的典型實現有兩個:DefaultEventExecutor和SingleThreadEventLoop,在本案例中,因為使用的是DefaultEventExecutorGroup,所以實際執行業務Handler的線程池就是DefaultEventExecutor,它繼承自SingleThreadEventExecutor,從名稱就可以看出它是個單線程的線程池。它的工作原理如下:

 

1)DefaultEventExecutor聚合JDK的Executor和Thread, 首次執行Task的時候啟動線程,將線程池狀態修改為運行態;

 

2)Thread run方法循環從隊列中獲取Task執行,如果隊列為空,則同步阻塞,線程無限循環執行,直到接收到退出信號。

圖4-1 DefaultEventExecutor工作原理

用戶想通過Netty提供的DefaultEventExecutorGroup來並發執行業務Handler,但實際上卻是單線程SingleThreadEventExecutor在串行執行業務邏輯,當服務端消息接收速度超過業務邏輯執行速度時,就會導致業務消息積壓在SingleThreadEventExecutor的消息隊列中得不到及時處理,現象就是業務Handler好像得不到執行,部分業務消息丟失。

 

講解完Netty線程模型后,問題原因也定位出來了。其實我們發現,可以通過EventExecutor獲取EventExecutorGroup的信息,然后獲取整個EventExecutor線程組信息,最后打印線程負載信息,代碼如下:

 

執行結果如下:

 

 

3|問題總結

 

事實上,Netty為了防止多線程執行某個Handler(Channel)引起線程安全問題,實際只有一個線程會執行某個Handler,代碼如下:

 

 

需要指出的是,SingleThreadEventExecutor的pendingTasks可能是個耗時的操作,因此調用的時候需要注意:

 

 

實際就像JDK的線程池,不同的業務場景、硬件環境和性能標就會有不同的配置,無法給出標准的答案。需要進行實際測試、評估和調優來靈活調整。

 

最后再總結回顧下問題,對於案例中的代碼,實際上在使用單線程處理某個Handler的LogicServerHandler,作者可能想並發多線程執行這個Handler,提升業務處理性能,但實際並沒有達到設計效果。

 

如果業務性能存在問題,並不奇怪,因為業務實際是單線程串行處理的!當然,如果業務存在多個Channel,則每個/多個Channel會對應一個線程(池),也可以實現多線程處理,這取決於客戶端的接入數。

 

案例中代碼的線程處理模型如下所示(單個鏈路模型):

 

圖4-3 單線程執行業務邏輯線程模型圖

 

 

Netty 4 ChannelHandler線程安全疑問

 

1|問題咨詢

 

我有一個非線程安全的類ThreadUnsafeClass,這個類會在channelRead方法中被調用。我下面這樣的調用方法在多線程環境下安全嗎?謝謝!

 

代碼示例如下:

 

 

2|解答

 

Netty 4優化了Netty 3的線程模型,其中一個非常大的優化就是用戶不需要再擔心ChannelHandler會被並發調用,總結如下:

 

1)ChannelHandler's的方法不會被Netty並發調用;

2)用戶不再需要對ChannelHandler的各個方法做同步保護;

3)ChannelHandler實例不允許被多次添加到ChannelPiple中,否則線程安全將得不到保證。

 

根據上述分析,MyHandler的channelRead方法不會被並發調用,因此不存在線程安全問題。

 

3|一些特例

 

ChannelHandler的線程安全存在幾個特例,總結如下:

 

1)如果ChannelHandler被注解為 @Sharable,全局只有一個handler實例,它會被多個Channel的Pipeline共享,會被多線程並發調用,因此它不是線程安全的;

 

2)如果存在跨ChannelHandler的實例級變量共享,需要特別注意,它可能不是線程安全的。

 

非線程安全的跨ChannelHandler變量原理如下:

圖5-1 串行調用,線程安全

Netty支持在添加ChannelHandler的時候,指定執行該Handler的EventExecutorGroup,這就意味着在整個ChannelPipeline執行過程中,可能會發生線程切換。此時,如果同一個對象在多個ChannelHandler中被共享,可能會被多線程並發操作,原理如下:

圖5-2 並行調用,多Handler共享成員變量,非線程安全

 


免責聲明!

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



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