該文章為轉載,原文章請點擊
1. 背景
1.1. Netty 3.X系列版本現狀
根據對Netty社區部分用戶的調查,結合Netty在其它開源項目中的使用情況,我們可以看出目前Netty商用的主流版本集中在3.X和4.X上,其中以Netty 3.X系列版本使用最為廣泛。
Netty社區非常活躍,3.X系列版本從2011年2月7日發布的netty-3.2.4 Final版本到2014年12月17日發布的netty-3.10.0 Final版本,版本跨度達3年多,期間共推出了61個Final版本。
1.2. 升級還是堅守老版本
相比于其它開源項目,Netty用戶的版本升級之路更加艱辛,最根本的原因就是Netty 4對Netty 3沒有做到很好的前向兼容。
由于版本不兼容,大多數老版本使用者的想法就是既然升級這么麻煩,我暫時又不需要使用到Netty 4的新特性,當前版本還挺穩定,就暫時先不升級,以后看看再說。
堅守老版本還有很多其它的理由,例如考慮到線上系統的穩定性、對新版本的熟悉程度等。無論如何升級Netty都是一件大事,特別是對Netty有直接強依賴的產品。
從上面的分析可以看出,堅守老版本似乎是個不錯的選擇;但是,“理想是美好的,現實卻是殘酷的”,堅守老版本并非總是那么容易,下面我們就看下被迫升級的案例。
1.3. “被迫”升級到Netty 4.X
除了為了使用新特性而主動進行的版本升級,大多數升級都是“被迫的”。下面我們對這些升級原因進行分析。
- 公司的開源軟件管理策略:對于那些大廠,不同部門和產品線依賴的開源軟件版本經常不同,為了對開源依賴進行統一管理,降低安全、維護和管理成本,往往會指定優選的軟件版本。由于Netty 4.X 系列版本已經非常成熟,因為,很多公司都優選Netty 4.X版本。
- 維護成本:無論是依賴Netty 3.X,還是Netty4.X,往往需要在原框架之上做定制。例如,客戶端的短連重連、心跳檢測、流控等。分別對Netty 4.X和3.X版本實現兩套定制框架,開發和維護成本都非常高。根據開源軟件的使用策略,當存在版本沖突的時候,往往會選擇升級到更高的版本。對于Netty,依然遵循這個規則。
- 新特性:Netty 4.X相比于Netty 3.X,提供了很多新的特性,例如優化的內存管理池、對MQTT協議的支持等。如果用戶需要使用這些新特性,最簡便的做法就是升級Netty到4.X系列版本。
- 更優異的性能:Netty 4.X版本相比于3.X老版本,優化了內存池,減少了GC的頻率、降低了內存消耗;通過優化Rector線程池模型,用戶的開發更加簡單,線程調度也更加高效。
1.4. 升級不當付出的代價
表面上看,類庫包路徑的修改、API的重構等似乎是升級的重頭戲,大家往往把注意力放到這些“明槍”上,但真正隱藏和致命的卻是“暗箭”。如果對Netty底層的事件調度機制和線程模型不熟悉,往往就會“中槍”。
本文以幾個比較典型的真實案例為例,通過問題描述、問題定位和問題總結,讓這些隱藏的“暗箭”不再傷人。
由于Netty 4線程模型改變導致的升級事故還有很多,限于篇幅,本文不一一枚舉,這些問題萬變不離其宗,只要抓住線程模型這個關鍵點,所謂的疑難雜癥都將迎刃而解。
2. Netty升級之后遭遇內存泄露
2.1. 問題描述
隨著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進程就會宕機,查看系統運行日志發現系統發生了內存泄露(示例堆棧):
OOM內存溢出堆棧
對內存進行監控(切換使用堆內存池,方便對內存進行監控),發現堆內存一直飆升,如下所示(示例堆內存監控):
圖2-2 堆內存監控
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的可能性不大,首先從業務的使用方式入手分析:
- 內存的分配是在業務代碼中進行,由于使用到了業務線程池做I/O操作和業務操作的隔離,實際上內存是在業務線程中分配的;
- 內存的釋放操作是在outbound中進行,按照Netty 3的線程模型,downstream(對應Netty 4的outbound,Netty 4取消了upstream和downstream)的handler也是由業務調用者線程執行的,也就是說釋放跟分配在同一個業務線程中進行。
初次排查并沒有發現導致內存泄露的根因,一籌莫展之際開始查看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事件處理線程模型
2.3. 問題總結
Netty 4.X版本新增的內存池確實非常高效,但是如果使用不當則會導致各種嚴重的問題。諸如內存泄露這類問題,功能測試并沒有異常,如果相關接口沒有進行壓測或者穩定性測試而直接上線,則會導致嚴重的線上問題。
內存池PooledByteBuf的使用建議:
- 申請之后一定要記得釋放,Netty自身Socket讀取和發送的ByteBuf系統會自動釋放,用戶不需要做二次釋放;如果用戶使用Netty的內存池在應用中做ByteBuf的對象池使用,則需要自己主動釋放;
- 避免錯誤的釋放:跨線程釋放、重復釋放等都是非法操作,要避免。特別是跨線程申請和釋放,往往具有隱蔽性,問題定位難度較大;
- 防止隱式的申請和分配:之前曾經發生過一個案例,為了解決內存池跨線程申請和釋放問題,有用戶對內存池做了二次包裝,以實現多線程操作時,內存始終由包裝的管理線程申請和釋放,這樣可以屏蔽用戶業務線程模型和訪問方式的差異。誰知運行一段時間之后再次發生了內存泄露,最后發現原來調用ByteBuf的write操作時,如果內存容量不足,會自動進行容量擴展。擴展操作由業務線程執行,這就繞過了內存池管理線程,發生了“引用逃逸”。該Bug只有在ByteBuf容量動態擴展的時候才發生,因此,上線很長一段時間沒有發生,直到某一天......因此,大家在使用Netty 4.X的內存池時要格外當心,特別是做二次封裝時,一定要對內存池的實現細節有深刻的理解。
3. Netty升級之后遭遇數據被篡改
3.1. 問題描述
某業務產品,Netty3.X升級到4.X之后,系統運行過程中,偶現服務端發送給客戶端的應答數據被莫名“篡改”。
業務服務端的處理流程如下:
- 將解碼后的業務消息封裝成Task,投遞到后端的業務線程池中執行;
- 業務線程處理業務邏輯,完成之后構造應答消息發送給客戶端;
- 業務應答消息的編碼通過繼承Netty的CodeC框架實現,即Encoder ChannelHandler;
- 調用Netty的消息發送接口之后,流程繼續,根據業務場景,可能會繼續操作原發送的業務對象。
業務相關代碼示例如下:
//構造訂購應答消息
SubInfoResp infoResp = new SubInfoResp();
//根據業務邏輯,對應答消息賦值
infoResp.setResultCode(0);
infoResp.setXXX();
后續賦值操作省略......
//調用ChannelHandlerContext進行消息發送
ctx.writeAndFlush(infoResp);
//消息發送完成之后,后續根據業務流程進行分支處理,修改infoResp對象
infoResp.setXXX();
后續代碼省略......
3.2. 問題定位
首先對應答消息被非法“篡改”的原因進行分析,經過定位發現當發生問題時,被“篡改”的內容是調用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 升級之后的業務處理流程發生改變
3.3. 問題總結
很多讀者在進行Netty 版本升級的時候,只關注到了包路徑、類和API的變更,并沒有注意到隱藏在背后的“暗箭”- 線程模型變更。
升級到Netty 4的用戶需要根據新的線程模型對已有的系統進行評估,重點需要關注outbound的ChannelHandler,如果它的正確性依賴于Netty 3的線程模型,則很可能在新的線程模型中出問題,可能是功能問題或者其它問題。
4. Netty升級之后性能嚴重下降
4.1. 問題描述
相信很多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:
- GC中斷頻率是原來的1/5: 45.5 vs. 9.2次/分鐘
- 垃圾生成速度是原來的1/5: 207.11 vs 41.81 MiB/秒
正是看到了相關的Netty 4性能提升報告,很多用戶選擇了升級。事后一些用戶反饋Netty 4并沒有跟產品帶來預期的性能提升,有些甚至還發生了非常嚴重的性能下降,下面我們就以某業務產品的失敗升級經歷為案例,詳細分析下導致性能下降的原因。
4.2. 問題定位
首先通過JMC等性能分析工具對性能熱點進行分析,示例如下(信息安全等原因,只給出分析過程示例截圖):
圖4-1 JMC性能監控分析
通過對熱點方法的分析,發現在消息發送過程中,有兩處熱點:
- 消息發送性能統計相關Handler;
- 編碼Handler。
對使用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業務調度性能模型
4.3. 問題總結
該問題的根因還是由于Netty 4的線程模型變更引起,線程模型變更之后,不僅影響業務的功能,甚至對性能也會造成很大的影響。
對Netty的升級需要從功能、兼容性和性能等多個角度進行綜合考慮,切不可只盯著API變更這個芝麻,而丟掉了性能這個西瓜。API的變更會導致編譯錯誤,但是性能下降卻隱藏于無形之中,稍不留意就會中招。
對于講究快速交付、敏捷開發和灰度發布的互聯網應用,升級的時候更應該要當心。
5. Netty升級之后上下文丟失
5.1. 問題描述
為了提升業務的二次定制能力,降低對接口的侵入性,業務使用線程變量進行消息上下文的傳遞。例如消息發送源地址信息、消息Id、會話Id等。
業務同時使用到了一些第三方開源容器,也提供了線程級變量上下文的能力。業務通過容器上下文獲取第三方容器的系統變量信息。
升級到Netty 4之后,業務繼承自Netty的ChannelHandler發生了空指針異常,無論是業務自定義的線程上下文、還是第三方容器的線程上下文,都獲取不到傳遞的變量值。
5.2. 問題定位
首先檢查代碼,看業務是否傳遞了相關變量,確認業務傳遞之后懷疑跟Netty 版本升級相關,調試發現,業務ChannelHandler獲取的線程上下文對象和之前業務傳遞的上下文不是同一個。這就說明執行ChannelHandler的線程跟處理業務的線程不是同一個線程!
查看Netty 4線程模型的相關Doc發現,Netty修改了outbound的線程模型,正好影響了業務消息發送時的線程上下文傳遞,最終導致線程變量丟失。
5.3. 問題總結
通常業務的線程模型有如下幾種:
- 業務自定義線程池/線程組處理業務,例如使用JDK 1.5提供的ExecutorService;
- 使用J2EE Web容器自帶的線程模型,常見的如JBoss和Tomcat的HTTP接入線程等;
- 隱式的使用其它第三方框架的線程模型,例如使用NIO框架進行協議處理,業務代碼隱式使用的就是NIO框架的線程模型,除非業務明確的實現自定義線程模型。
在實踐中我們發現很多業務使用了第三方框架,但是只熟悉API和功能,對線程模型并不清楚。某個類庫由哪個線程調用,糊里糊涂。為了方便變量傳遞,又隨意的使用線程變量,實際對背后第三方類庫的線程模型產生了強依賴。當容器或者第三方類庫升級之后,如果線程模型發生了變更,則原有功能就會發生問題。
鑒于此,在實際工作中,盡量不要強依賴第三方類庫的線程模型,如果確實無法避免,則必須對它的線程模型有深入和清晰的了解。當第三方類庫升級之后,需要檢查線程模型是否發生變更,如果發生變化,相關的代碼也需要考慮同步升級。
6. Netty3.X VS Netty4.X 之線程模型
通過對三個具有典型性的升級失敗案例進行分析和總結,我們發現有個共性:都是線程模型改變惹的禍!
下面小節我們就詳細得對Netty3和Netty4版本的I/O線程模型進行對比,以方便大家掌握兩者的差異,在升級和使用中盡量少踩雷。
6.1 Netty 3.X 版本線程模型
Netty 3.X的I/O操作線程模型比較復雜,它的處理模型包括兩部分:
- Inbound:主要包括鏈路建立事件、鏈路激活事件、讀事件、I/O異常事件、鏈路關閉事件等;
- Outbound:主要包括寫事件、連接事件、監聽綁定事件、刷新事件等。
我們首先分析下Inbound操作的線程模型:
圖6-1 Netty 3 Inbound操作線程模型
從上圖可以看出,Inbound操作的主要處理流程如下:
- I/O線程(Work線程)將消息從TCP緩沖區讀取到SocketChannel的接收緩沖區中;
- 由I/O線程負責生成相應的事件,觸發事件向上執行,調度到ChannelPipeline中;
- I/O線程調度執行ChannelPipeline中Handler鏈的對應方法,直到業務實現的Last Handler;
- Last Handler將消息封裝成Runnable,放入到業務線程池中執行,I/O線程返回,繼續讀/寫等I/O操作;
- 業務線程池從任務隊列中彈出消息,并發執行業務邏輯。
通過對Netty 3的Inbound操作進行分析我們可以看出,Inbound的Handler都是由Netty的I/O Work線程負責執行。
下面我們繼續分析Outbound操作的線程模型:
圖6-2 Netty 3 Outbound操作線程模型
從上圖可以看出,Outbound操作的主要處理流程如下:
業務線程發起Channel Write操作,發送消息;
- Netty將寫操作封裝成寫事件,觸發事件向下傳播;
- 寫事件被調度到ChannelPipeline中,由業務線程按照Handler Chain串行調用支持Downstream事件的Channel Handler;
- 執行到系統最后一個ChannelHandler,將編碼后的消息Push到發送隊列中,業務線程返回;
- Netty的I/O線程從發送消息隊列中取出消息,調用SocketChannel的write方法進行消息發送。
6.2 Netty 4.X 版本線程模型
相比于Netty 3.X系列版本,Netty 4.X的I/O操作線程模型比較簡答,它的原理圖如下所示:
圖6-3 Netty 4 Inbound和Outbound操作線程模型
從上圖可以看出,Outbound操作的主要處理流程如下:
- I/O線程NioEventLoop從SocketChannel中讀取數據報,將ByteBuf投遞到ChannelPipeline,觸發ChannelRead事件;
- I/O線程NioEventLoop調用ChannelHandler鏈,直到將消息投遞到業務線程,然后I/O線程返回,繼續后續的讀寫操作;
- 業務線程調用ChannelHandlerContext.write(Object msg)方法進行消息發送;
- 如果是由業務線程發起的寫操作,ChannelHandlerInvoker將發送消息封裝成Task,放入到I/O線程NioEventLoop的任務隊列中,由NioEventLoop在循環中統一調度和執行。放入任務隊列之后,業務線程返回;
- I/O線程NioEventLoop調用ChannelHandler鏈,進行消息發送,處理Outbound事件,直到將消息放入發送隊列,然后喚醒Selector,進而執行寫操作。
通過流程分析,我們發現Netty 4修改了線程模型,無論是Inbound還是Outbound操作,統一由I/O線程NioEventLoop調度執行。
6.3. 線程模型對比
在進行新老版本線程模型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線程模型存在的問題,總結起來,它的主要問題如下:
- Inbound和Outbound實質都是I/O相關的操作,它們的線程模型竟然不統一,這給用戶帶來了更多的學習和使用成本;
- Outbound操作由業務線程執行,通常業務會使用線程池并行處理業務消息,這就意味著在某一個時刻會有多個業務線程同時操作ChannelHandler,我們需要對ChannelHandler進行并發保護,通常需要加鎖。如果同步塊的范圍不當,可能會導致嚴重的性能瓶頸,這對開發者的技能要求非常高,降低了開發效率;
- Outbound操作過程中,例如消息編碼異常,會產生Exception,它會被轉換成Inbound的Exception并通知到ChannelPipeline,這就意味著業務線程發起了Inbound操作!它打破了Inbound操作由I/O線程操作的模型,如果開發者按照Inbound操作只會由一個I/O線程執行的約束進行設計,則會發生線程并發訪問安全問題。由于該場景只在特定異常時發生,因此錯誤非常隱蔽!一旦在生產環境中發生此類線程并發問題,定位難度和成本都非常大。
講了這么多,似乎Netty 4 完勝 Netty 3的線程模型,其實并不盡然。在特定的場景下,Netty 3的性能可能更高,就如本文第4章節所講,如果編碼和其它Outbound操作非常耗時,由多個業務線程并發執行,性能肯定高于單個NioEventLoop線程。
但是,這種性能優勢不是不可逆轉的,如果我們修改業務代碼,將耗時的Handler操作前置,Outbound操作不做復雜業務邏輯處理,性能同樣不輸于Netty 3,但是考慮內存池優化、不會反復創建Event、不需要對Handler加鎖等Netty 4的優化,整體性能Netty 4版本肯定會更高。
總而言之,如果用戶真正熟悉并掌握了Netty 4的線程模型和功能類庫,相信不僅僅開發會更加簡單,性能也會更優!
6.4. 思考
就Netty 而言,掌握線程模型的重要性不亞于熟悉它的API和功能。很多時候我遇到的功能、性能等問題,都是由于缺乏對它線程模型和原理的理解導致的,結果我們就以訛傳訛,認為Netty 4版本不如3好用等。
不能說所有開源軟件的版本升級一定都勝過老版本,就Netty而言,我認為Netty 4版本相比于老的Netty 3,確實是歷史的一大進步。