做者 李林鋒 發佈於 2015年2月7日 | 注意:GTLC全球技術領導力峯會,500+CTO技聚從新定義技術領導力!18 討論後端
根據對Netty社區部分用戶的調查,結合Netty在其它開源項目中的使用狀況,咱們能夠看出目前Netty商用的主流版本集中在3.X和4.X上,其中以Netty 3.X系列版本使用最爲普遍。promise
Netty社區很是活躍,3.X系列版本從2011年2月7日發佈的netty-3.2.4 Final版本到2014年12月17日發佈的netty-3.10.0 Final版本,版本跨度達3年多,期間共推出了61個Final版本。安全
相比於其它開源項目,Netty用戶的版本升級之路更加艱辛,最根本的緣由就是Netty 4對Netty 3沒有作到很好的前向兼容。服務器
相關廠商內容微信
相關贊助商網絡
QCon全球軟件開發大會上海站,2016年10月20日-22日,上海寶華萬豪酒店,精彩內容搶先看!多線程
因爲版本不兼容,大多數老版本使用者的想法就是既然升級這麼麻煩,我暫時又不須要使用到Netty 4的新特性,當前版本還挺穩定,就暫時先不升級,之後看看再說。架構
堅守老版本還有不少其它的理由,例如考慮到線上系統的穩定性、對新版本的熟悉程度等。不管如何升級Netty都是一件大事,特別是對Netty有直接強依賴的產品。併發
從上面的分析能夠看出,堅守老版本彷佛是個不錯的選擇;可是,「理想是美好的,現實倒是殘酷的」,堅守老版本並不是老是那麼容易,下面咱們就看下被迫升級的案例。
除了爲了使用新特性而主動進行的版本升級,大多數升級都是「被迫的」。下面咱們對這些升級緣由進行分析。
表面上看,類庫包路徑的修改、API的重構等彷佛是升級的重頭戲,你們每每把注意力放到這些「明槍」上,但真正隱藏和致命的倒是「暗箭」。若是對Netty底層的事件調度機制和線程模型不熟悉,每每就會「中槍」。
本文以幾個比較典型的真實案例爲例,經過問題描述、問題定位和問題總結,讓這些隱藏的「暗箭」再也不傷人。
因爲Netty 4線程模型改變致使的升級事故還有不少,限於篇幅,本文不一一枚舉,這些問題萬變不離其宗,只要抓住線程模型這個關鍵點,所謂的疑難雜症都將迎刃而解。
隨着JVM虛擬機和JIT即時編譯技術的發展,對象的分配和回收是個很是輕量級的工做。可是對於緩衝區Buffer,狀況卻稍有不一樣,特別是對於堆外直接內存的分配和回收,是一件耗時的操做。爲了儘可能重用緩衝區,Netty4.X提供了基於內存池的緩衝區重用機制。性能測試代表,採用內存池的ByteBuf相比於朝生夕滅的ByteBuf,性能高23倍左右(性能數據與使用場景強相關)。
業務應用的特色是高併發、短流程,大多數對象都是朝生夕滅的短生命週期對象。爲了減小內存的拷貝,用戶指望在序列化的時候直接將對象編碼到PooledByteBuf裏,這樣就不須要爲每一個業務消息都從新申請和釋放內存。
業務的相關代碼示例以下:
//在業務線程中初始化內存池分配器,分配非堆內存 ByteBufAllocator allocator = new PooledByteBufAllocator(true); ByteBuf buffer = allocator.ioBuffer(1024); //構造訂購請求消息並賦值,業務邏輯省略 SubInfoReq infoReq = new SubInfoReq (); infoReq.setXXX(......); //將對象編碼到ByteBuf中 codec.encode(buffer, info); //調用ChannelHandlerContext進行消息發送 ctx.writeAndFlush(buffer);
業務代碼升級Netty版本並重構以後,運行一段時間,Java進程就會宕機,查看系統運行日誌發現系統發生了內存泄露(示例堆棧):
圖2-1 OOM內存溢出堆棧
對內存進行監控(切換使用堆內存池,方便對內存進行監控),發現堆內存一直飆升,以下所示(示例堆內存監控):
圖2-2 堆內存監控
使用jmap -dump:format=b,file=netty.bin PID 將堆內存dump出來,經過IBM的HeapAnalyzer工具進行分析,發現ByteBuf發生了泄露。
由於使用了內存池,因此首先懷疑是否是申請的ByteBuf沒有被釋放致使?查看代碼,發現消息發送完成以後,Netty底層已經調用ReferenceCountUtil.release(message)對內存進行了釋放。這是怎麼回事呢?難道Netty 4.X的內存池有Bug,調用release操做釋放內存失敗?
考慮到Netty 內存池自身Bug的可能性不大,首先從業務的使用方式入手分析:
初次排查並無發現致使內存泄露的根因,束手無策之際開始查看Netty的內存池分配器PooledByteBufAllocator的Doc和源碼實現,發現內存池實際是基於線程上下文實現的,相關代碼以下:
final ThreadLocal<PoolThreadCache> threadCache = new ThreadLocal<PoolThreadCache>() { private final AtomicInteger index = new AtomicInteger(); @Override protected PoolThreadCache initialValue() { final int idx = index.getAndIncrement(); final PoolArena<byte[]> heapArena; final PoolArena<ByteBuffer> directArena; if (heapArenas != null) { heapArena = heapArenas[Math.abs(idx % heapArenas.length)]; } else { heapArena = null; } if (directArenas != null) { directArena = directArenas[Math.abs(idx % directArenas.length)]; } else { directArena = null; } return new PoolThreadCache(heapArena, directArena); }
也就是說內存的申請和釋放必須在同一線程上下文中,不能跨線程。跨線程以後實際操做的就不是同一塊內存區域,這會致使不少嚴重的問題,內存泄露即是其中之一。內存在A線程申請,切換到B線程釋放,實際是沒法正確回收的。
經過對Netty內存池的源碼分析,問題基本鎖定。保險起見進行簡單驗證,經過對單條業務消息進行Debug,發現執行釋放的果真不是業務線程,而是Netty的NioEventLoop線程:當某個消息被徹底發送成功以後,會經過ReferenceCountUtil.release(message)方法釋放已經發送成功的ByteBuf。
問題定位出來以後,繼續溯源,發現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線程負責處理。
下面咱們分別經過對比Netty 3和Netty 4的消息接收和發送流程,來理解兩個版本線程模型的差別:
Netty 3的I/O事件處理流程:
圖2-3 Netty 3 I/O事件處理線程模型
Netty 4的I/O消息處理流程:
圖2-4 Netty 4 I/O事件處理線程模型
Netty 4.X版本新增的內存池確實很是高效,可是若是使用不當則會致使各類嚴重的問題。諸如內存泄露這類問題,功能測試並無異常,若是相關接口沒有進行壓測或者穩定性測試而直接上線,則會致使嚴重的線上問題。
內存池PooledByteBuf的使用建議:
某業務產品,Netty3.X升級到4.X以後,系統運行過程當中,偶現服務端發送給客戶端的應答數據被莫名「篡改」。
業務服務端的處理流程以下:
業務相關代碼示例以下:
//構造訂購應答消息 SubInfoResp infoResp = new SubInfoResp(); //根據業務邏輯,對應答消息賦值 infoResp.setResultCode(0); infoResp.setXXX(); 後續賦值操做省略...... //調用ChannelHandlerContext進行消息發送 ctx.writeAndFlush(infoResp); //消息發送完成以後,後續根據業務流程進行分支處理,修改infoResp對象 infoResp.setXXX(); 後續代碼省略......
首先對應答消息被非法「篡改」的緣由進行分析,通過定位發現當發生問題時,被「篡改」的內容是調用writeAndFlush接口以後,由後續業務分支代碼修改應答消息致使的。因爲修改操做發生在writeAndFlush操做以後,按照Netty 3.X的線程模型不該該出現該問題。
在Netty3中,downstream是在業務線程裏執行的,也就是說對SubInfoResp的編碼操做是在業務線程中執行的,當編碼後的ByteBuf對象被投遞到消息發送隊列以後,業務線程纔會返回並繼續執行後續的業務邏輯,此時修改應答消息是不會改變已完成編碼的ByteBuf對象的,因此確定不會出現應答消息被篡改的問題。
初步分析應該是因爲線程模型發生變動致使的問題,隨後查驗了Netty 4的線程模型,果真發生了變化:當調用outbound向外發送消息的時候,Netty會將發送事件封裝成Task,投遞到NioEventLoop的任務隊列中異步執行,相關代碼以下:
@Override public void invokeWrite(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) { if (msg == null) { throw new NullPointerException("msg"); } validatePromise(ctx, promise, true); if (executor.inEventLoop()) { invokeWriteNow(ctx, msg, promise); } else { AbstractChannel channel = (AbstractChannel) ctx.channel(); int size = channel.estimatorHandle().size(msg); if (size > 0) { ChannelOutboundBuffer buffer = channel.unsafe().outboundBuffer(); // Check for null as it may be set to null if the channel is closed already if (buffer != null) { buffer.incrementPendingOutboundBytes(size); } } safeExecuteOutbound(WriteTask.newInstance(ctx, msg, size, promise), promise, msg); } }
經過上述代碼能夠看出,Netty首先對當前的操做的線程進行判斷,若是操做自己就是由NioEventLoop線程執行,則調用寫操做;不然,執行線程安全的寫操做,即將寫事件封裝成Task,放入到任務隊列中由Netty的I/O線程執行,業務調用返回,流程繼續執行。
經過源碼分析,問題根源已經很清楚:系統升級到Netty 4以後,線程模型發生變化,響應消息的編碼由NioEventLoop線程異步執行,業務線程返回。這時存在兩種可能:
因爲線程的執行前後順序沒法預測,所以該問題隱藏的至關深。若是對Netty 4和Netty3的線程模型不瞭解,就會掉入陷阱。
Netty 3版本業務邏輯沒有問題,流程以下:
圖3-1 升級以前的業務流程線程模型
升級到Netty 4版本以後,業務流程因爲Netty線程模型的變動而發生改變,致使業務邏輯發生問題:
圖3-2 升級以後的業務處理流程發生改變
不少讀者在進行Netty 版本升級的時候,只關注到了包路徑、類和API的變動,並無注意到隱藏在背後的「暗箭」- 線程模型變動。
升級到Netty 4的用戶須要根據新的線程模型對已有的系統進行評估,重點須要關注outbound的ChannelHandler,若是它的正確性依賴於Netty 3的線程模型,則極可能在新的線程模型中出問題,多是功能問題或者其它問題。
相信不少Netty用戶都看過以下相關報告:
在Twitter,Netty 4 GC開銷降爲五分之一:Netty 3使用Java對象表示I/O事件,這樣簡單,但會產生大量的垃圾,尤爲是在咱們這樣的規模下。Netty 4在新版本中對此作出了更改,取代生存週期短的事件對象,而以定義在生存週期長的通道對象上的方法處理I/O事件。它還有一個使用池的專用緩衝區分配器。
每當收到新信息或者用戶發送信息到遠程端,Netty 3均會建立一個新的堆緩衝區。這意味着,對應每個新的緩衝區,都會有一個‘new byte[capacity]’。這些緩衝區會致使GC壓力,並消耗內存帶寬:爲了安全起見,新的字節數組分配時會用零填充,這會消耗內存帶寬。然而,用零填充的數組極可能會再次用實際的數據填充,這又會消耗一樣的內存帶寬。若是Java虛擬機(JVM)提供了建立新字節數組而又無需用零填充的方式,那麼咱們原本就能夠將內存帶寬消耗減小50%,可是目前沒有那樣一種方式。
在Netty 4中,代碼定義了粒度更細的API,用來處理不一樣的事件類型,而不是建立事件對象。它還實現了一個新緩衝池,那是一個純Java版本的 jemalloc (Facebook也在用)。如今,Netty不會再由於用零填充緩衝區而浪費內存帶寬了。
咱們比較了兩個分別創建在Netty 3和4基礎上echo協議服務器。(Echo很是簡單,這樣,任何垃圾的產生都是Netty的緣由,而不是協議的緣由)。我使它們服務於相同的分佈式echo協議客戶端,來自這些客戶端的16384個併發鏈接重複發送256字節的隨機負載,幾乎使千兆以太網飽和。
根據測試結果,Netty 4:
正是看到了相關的Netty 4性能提高報告,不少用戶選擇了升級。過後一些用戶反饋Netty 4並無跟產品帶來預期的性能提高,有些甚至還發生了很是嚴重的性能降低,下面咱們就以某業務產品的失敗升級經歷爲案例,詳細分析下致使性能降低的緣由。
首先經過JMC等性能分析工具對性能熱點進行分析,示例以下(信息安全等緣由,只給出分析過程示例截圖):
圖4-1 JMC性能監控分析
經過對熱點方法的分析,發如今消息發送過程當中,有兩處熱點:
對使用Netty 3版本的業務產品進行性能對比測試,發現上述兩個Handler也是熱點方法。既然都是熱點,爲啥切換到Netty4以後性能降低這麼厲害呢?
經過方法的調用樹分析發現了兩個版本的差別:在Netty 3中,上述兩個熱點方法都是由業務線程負責執行;而在Netty 4中,則是由NioEventLoop(I/O)線程執行。對於某個鏈路,業務是擁有多個線程的線程池,而NioEventLoop只有一個,因此執行效率更低,返回給客戶端的應答時延就大。時延增大以後,天然致使系統併發量下降,性能降低。
找出問題根因以後,針對Netty 4的線程模型對業務進行專項優化,性能達到預期,遠超過了Netty 3老版本的性能。
Netty 3的業務線程調度模型圖以下所示:充分利用了業務多線程並行編碼和Handler處理的優點,週期T內能夠處理N條業務消息。
圖4-2 Netty 3業務調度性能模型
切換到Netty 4以後,業務耗時Handler被I/O線程串行執行,所以性能發生比較大的降低:
圖4-3 Netty 4業務調度性能模型
該問題的根因仍是因爲Netty 4的線程模型變動引發,線程模型變動以後,不只影響業務的功能,甚至對性能也會形成很大的影響。
對Netty的升級須要從功能、兼容性和性能等多個角度進行綜合考慮,切不可只盯着API變動這個芝麻,而丟掉了性能這個西瓜。API的變動會致使編譯錯誤,可是性能降低卻隱藏於無形之中,稍不留意就會中招。
對於講究快速交付、敏捷開發和灰度發佈的互聯網應用,升級的時候更應該要小心。
爲了提高業務的二次定製能力,下降對接口的侵入性,業務使用線程變量進行消息上下文的傳遞。例如消息發送源地址信息、消息Id、會話Id等。
業務同時使用到了一些第三方開源容器,也提供了線程級變量上下文的能力。業務經過容器上下文獲取第三方容器的系統變量信息。
升級到Netty 4以後,業務繼承自Netty的ChannelHandler發生了空指針異常,不管是業務自定義的線程上下文、仍是第三方容器的線程上下文,都獲取不到傳遞的變量值。
首先檢查代碼,看業務是否傳遞了相關變量,確認業務傳遞以後懷疑跟Netty 版本升級相關,調試發現,業務ChannelHandler獲取的線程上下文對象和以前業務傳遞的上下文不是同一個。這就說明執行ChannelHandler的線程跟處理業務的線程不是同一個線程!
查看Netty 4線程模型的相關Doc發現,Netty修改了outbound的線程模型,正好影響了業務消息發送時的線程上下文傳遞,最終致使線程變量丟失。
一般業務的線程模型有以下幾種:
在實踐中咱們發現不少業務使用了第三方框架,可是隻熟悉API和功能,對線程模型並不清楚。某個類庫由哪一個線程調用,糊里糊塗。爲了方便變量傳遞,又隨意的使用線程變量,實際對背後第三方類庫的線程模型產生了強依賴。當容器或者第三方類庫升級以後,若是線程模型發生了變動,則原有功能就會發生問題。
鑑於此,在實際工做中,儘可能不要強依賴第三方類庫的線程模型,若是確實沒法避免,則必須對它的線程模型有深刻和清晰的瞭解。當第三方類庫升級以後,須要檢查線程模型是否發生變動,若是發生變化,相關的代碼也須要考慮同步升級。
經過對三個具備典型性的升級失敗案例進行分析和總結,咱們發現有個共性:都是線程模型改變惹的禍!
下面小節咱們就詳細得對Netty3和Netty4版本的I/O線程模型進行對比,以方便你們掌握二者的差別,在升級和使用中儘可能少踩雷。
Netty 3.X的I/O操做線程模型比較複雜,它的處理模型包括兩部分:
咱們首先分析下Inbound操做的線程模型:
圖6-1 Netty 3 Inbound操做線程模型
從上圖能夠看出,Inbound操做的主要處理流程以下:
經過對Netty 3的Inbound操做進行分析咱們能夠看出,Inbound的Handler都是由Netty的I/O Work線程負責執行。
下面咱們繼續分析Outbound操做的線程模型:
圖6-2 Netty 3 Outbound操做線程模型
從上圖能夠看出,Outbound操做的主要處理流程以下:
業務線程發起Channel Write操做,發送消息;
相比於Netty 3.X系列版本,Netty 4.X的I/O操做線程模型比較簡答,它的原理圖以下所示:
圖6-3 Netty 4 Inbound和Outbound操做線程模型
從上圖能夠看出,Outbound操做的主要處理流程以下:
經過流程分析,咱們發現Netty 4修改了線程模型,不管是Inbound仍是Outbound操做,統一由I/O線程NioEventLoop調度執行。
在進行新老版本線程模型PK以前,首先仍是要熟悉下串行化設計的理念:
咱們知道當系統在運行過程當中,若是頻繁的進行線程上下文切換,會帶來額外的性能損耗。多線程併發執行某個業務流程,業務開發者還須要時刻對線程安全保持警戒,哪些數據可能會被併發修改,如何保護?這不只下降了開發效率,也會帶來額外的性能損耗。
爲了解決上述問題,Netty 4採用了串行化設計理念,從消息的讀取、編碼以及後續Handler的執行,始終都由I/O線程NioEventLoop負責,這就意外着整個流程不會進行線程上下文的切換,數據也不會面臨被併發修改的風險,對於用戶而言,甚至不須要了解Netty的線程細節,這確實是個很是好的設計理念,它的工做原理圖以下:
圖6-4 Netty 4的串行化設計理念
一個NioEventLoop聚合了一個多路複用器Selector,所以能夠處理成百上千的客戶端鏈接,Netty的處理策略是每當有一個新的客戶端接入,則從NioEventLoop線程組中順序獲取一個可用的NioEventLoop,當到達數組上限以後,從新返回到0,經過這種方式,能夠基本保證各個NioEventLoop的負載均衡。一個客戶端鏈接只註冊到一個NioEventLoop上,這樣就避免了多個I/O線程去併發操做它。
Netty經過串行化設計理念下降了用戶的開發難度,提高了處理性能。利用線程組實現了多個串行化線程水平並行執行,線程之間並無交集,這樣既能夠充分利用多核提高並行處理能力,同時避免了線程上下文的切換和併發保護帶來的額外性能損耗。
瞭解完了Netty 4的串行化設計理念以後,咱們繼續看Netty 3線程模型存在的問題,總結起來,它的主要問題以下:
講了這麼多,彷佛Netty 4 完勝 Netty 3的線程模型,其實並不盡然。在特定的場景下,Netty 3的性能可能更高,就如本文第4章節所講,若是編碼和其它Outbound操做很是耗時,由多個業務線程併發執行,性能確定高於單個NioEventLoop線程。
可是,這種性能優點不是不可逆轉的,若是咱們修改業務代碼,將耗時的Handler操做前置,Outbound操做不作複雜業務邏輯處理,性能一樣不輸於Netty 3,可是考慮內存池優化、不會反覆建立Event、不須要對Handler加鎖等Netty 4的優化,總體性能Netty 4版本確定會更高。
總而言之,若是用戶真正熟悉並掌握了Netty 4的線程模型和功能類庫,相信不只僅開發會更加簡單,性能也會更優!
就Netty 而言,掌握線程模型的重要性不亞於熟悉它的API和功能。不少時候我遇到的功能、性能等問題,都是因爲缺少對它線程模型和原理的理解致使的,結果咱們就以訛傳訛,認爲Netty 4版本不如3好用等。
不能說全部開源軟件的版本升級必定都賽過老版本,就Netty而言,我認爲Netty 4版本相比於老的Netty 3,確實是歷史的一大進步。
李林鋒,2007年畢業於東北大學,2008年進入華爲公司從事高性能通訊軟件的設計和開發工做,有7年NIO設計和開發經驗,精通Netty、Mina等NIO框架和平臺中間件,現任華爲軟件平臺架構部架構師,《Netty權威指南》做者