http://www.infoq.com/cn/articles/the-multithreading-of-netty-cases-part02#anch130405
1.1. 問題描述
最近在使用Netty構建推送服務的過程中,遇到了一個問題,想再次請教您:如何正確的處理業務邏輯?問題主要來源於閱讀您發表在InfoQ上的文章《Netty系列之Netty線程模型》,文中提到 “2.4Netty線程開發最佳實踐中 2.4.2復雜和時間不可控業務建議投遞到后端業務線程池統一處理。對於此類業務,不建議直接在業務ChannelHandler中啟動線程或者線程池處理,建議將不同的業務統一封裝成Task,統一投遞到后端的業務線程池中進行處理。”
我不太理解“統一投遞到后端的業務線程池中進行處理”具體如何操作?像下面這樣做是否可行:
private ExecutorService executorService = Executors.newFixedThreadPool(4); @Override public void channelRead (final ChannelHandlerContext ctx, final Object msg) throws Exception { executorService.execute(new Runnable() {@Override public void run() { doSomething();
其實我想了解的是真實生產環境中如何將業務邏輯與Netty網絡處理部分很好的作隔離,有沒有通用的做法?
1.2. 答疑解惑
Netty的ChannelHandler鏈由I/O線程執行,如果在I/O線程做復雜的業務邏輯操作,可能會導致I/O線程無法及時進行read()或者write()操作。所以,比較通用的做法如下:
- 在ChannelHanlder的Codec中進行編解碼,由I/O線程做CodeC;
- 將數據報反序列化成業務Object對象之后,將業務消息封裝到Task中,投遞到業務線程池中進行處理,I/O線程返回。
不建議的做法:
圖1-1 不推薦業務和I/O線程共用同一個線程
推薦做法:
圖1-2 建議業務線程和I/O線程隔離
1.3. 問題總結
事實上,並不是說業務ChannelHandler一定不能由NioEventLoop線程執行,如果業務ChannelHandler處理邏輯比較簡單,執行時間是受控的,業務I/O線程的負載也不重,在這種應用場景下,業務ChannelHandler可以和I/O操作共享同一個線程。使用這種線程模型會帶來兩個優勢:
- 開發簡單:開發業務ChannelHandler的不需要關注Netty的線程模型,只負責ChannelHandler的業務邏輯開發和編排即可,對開發人員的技能要求會低一些;
- 性能更高:因為減少了一次線程上下文切換,所以性能會更高。
在實際項目開發中,一些開發人員往往喜歡照葫蘆畫瓢,並不會分析自己的ChannelHandler更適合在哪種線程模型下處理。如果在ChannelHandler中進行數據庫等同步I/O操作,很有可能會導致通信模塊被阻塞。所以,選擇什么樣的線程模型還需要根據項目的具體情況而定,一種比較好的做法是支持策略配置,例如阿里的Dubbo,支持通過配置化的方式讓用戶選擇業務在I/O線程池還是業務線程池中執行,比較靈活。
2. Netty客戶端連接問題
2.1. 問題描述
Netty客戶端想同時連接多個服務端,使用如下方式,是否可行,我簡單測試了下,暫時沒有發現問題。代碼如下:
EventLoopGroup group = new NioEventLoopGroup(); try { Bootstrap b = new Bootstrap(); b.group(group) ......代碼省略 // Start the client. ChannelFuture f1 = b.connect(HOST, PORT); ChannelFuture f2 = b.connect(HOST2, PORT2); // Wait until the connection is closed. f1.channel().closeFuture().sync(); f2.channel().closeFuture().sync(); ......代碼省略 }
2.2. 答疑解惑
上述代碼沒有問題,原因是盡管Bootstrap自身不是線程安全的,但是執行Bootstrap的連接操作是串行執行的,而且connect(String inetHost, int inetPort)方法本身是線程安全的,它會創建一個新的NioSocketChannel,並從初始構造的EventLoopGroup中選擇一個NioEventLoop線程執行真正的Channel連接操作,與執行Bootstrap的線程無關,所以通過一個Bootstrap連續發起多個連接操作是安全的,它的原理如下:
圖2-1 Netty BootStrap工作原理
2.3. 問題總結
注意事項-資源釋放問題: 在同一個Bootstrap中連續創建多個客戶端連接,需要注意的是EventLoopGroup是共享的,也就是說這些連接共用一個NIO線程組EventLoopGroup,當某個鏈路發生異常或者關閉時,只需要關閉並釋放Channel本身即可,不能同時銷毀Channel所使用的NioEventLoop和所在的線程組EventLoopGroup,例如下面的代碼片段就是錯誤的:
ChannelFuture f1 = b.connect(HOST, PORT); ChannelFuture f2 = b.connect(HOST2, PORT2); f1.channel().closeFuture().sync(); } finally { group.shutdownGracefully(); }
線程安全問題: 需要指出的是Bootstrap不是線程安全的,因此在多個線程中並發操作Bootstrap是一件非常危險的事情,Bootstrap是I/O操作工具類,它自身的邏輯處理非常簡單,真正的I/O操作都是由EventLoop線程負責的,所以通常多線程操作同一個Bootstrap實例也是沒有意義的,而且容易出錯,錯誤代碼如下:
Bootstrap b = new Bootstrap(); { //多線程執行初始化、連接等操作 }
如果復雜業務放到業務池里處理,怎么對客戶端進行返回:將Chanel傳遞到業務線程池中,然后調用Chanel的write方法即可。
=====================
http://blog.kazaff.me/2015/04/01/dubbo%E7%9A%84%E9%80%9A%E4%BF%A1%E6%A8%A1%E5%9E%8B/
從netty線程模型的分析中,可以認為netty提供的那些nio工作線程主要被用於消息鏈路的讀取、解碼、編碼和發送。而dubbo把業務邏輯的執行放在自身維護的線程池中是否就是為了貫徹netty的這一原則呢?
從上面給的鏈接中可以注意到下面這段話:
Netty是個異步高性能的NIO框架,它並不是個業務運行容器,因此它不需要也不應該提供業務容器和業務線程。合理的設計模式是Netty只負責提供和管理NIO線程,其它的業務層線程模型由用戶自己集成,Netty不應該提供此類功能,只要將分層划分清楚,就會更有利於用戶集成和擴展。
正如文中所說,dubbo這么做有利於分離通信層,方便的替換掉netty。至於是否還有更高深的理由,我就不清楚了,希望大牛賜教。
2.3.3. 聚焦而不是膨脹
Netty是個異步高性能的NIO框架,它並不是個業務運行容器,因此它不需要也不應該提供業務容器和業務線程。合理的設計模式是Netty只負責提供和管理NIO線程,其它的業務層線程模型由用戶自己集成,Netty不應該提供此類功能,只要將分層划分清楚,就會更有利於用戶集成和擴展。
令人遺憾的是在Netty 3系列版本中,Netty提供了類似Mina異步Filter的ExecutionHandler,它聚合了JDK的線程池java.util.concurrent.Executor,用戶異步執行后續的Handler。
ExecutionHandler是為了解決部分用戶Handler可能存在執行時間不確定而導致IO線程被意外阻塞或者掛住,從需求合理性角度分析這類需求本身是合理的,但是Netty提供該功能卻並不合適。原因總結如下:
1. 它打破了Netty堅持的串行化設計理念,在消息的接收和處理過程中發生了線程切換並引入新的線程池,打破了自身架構堅守的設計原則,實際是一種架構妥協;
2. 潛在的線程並發安全問題,如果異步Handler也操作它前面的用戶Handler,而用戶Handler又沒有進行線程安全保護,這就會導致隱蔽和致命的線程安全問題;
3. 用戶開發的復雜性,引入ExecutionHandler,打破了原來的ChannelPipeline串行執行模式,用戶需要理解Netty底層的實現細節,關心線程安全等問題,這會導致得不償失。
鑒於上述原因,Netty的后續版本徹底刪除了ExecutionHandler,而且也沒有提供類似的相關功能類,把精力聚焦在Netty的IO線程NioEventLoop上,這無疑是一種巨大的進步,Netty重新開始聚焦在IO線程本身,而不是提供用戶相關的業務線程模型。
2.4. Netty線程開發最佳實踐
2.4.1. 時間可控的簡單業務直接在IO線程上處理
如果業務非常簡單,執行時間非常短,不需要與外部網元交互、訪問數據庫和磁盤,不需要等待其它資源,則建議直接在業務ChannelHandler中執行,不需要再啟業務的線程或者線程池。避免線程上下文切換,也不存在線程並發問題。
2.4.2. 復雜和時間不可控業務建議投遞到后端業務線程池統一處理
對於此類業務,不建議直接在業務ChannelHandler中啟動線程或者線程池處理,建議將不同的業務統一封裝成Task,統一投遞到后端的業務線程池中進行處理。
過多的業務ChannelHandler會帶來開發效率和可維護性問題,不要把Netty當作業務容器,對於大多數復雜的業務產品,仍然需要集成或者開發自己的業務容器,做好和Netty的架構分層。
2.4.3. 業務線程避免直接操作ChannelHandler
對於ChannelHandler,IO線程和業務線程都可能會操作,因為業務通常是多線程模型,這樣就會存在多線程操作ChannelHandler。為了盡量避免多線程並發問題,建議按照Netty自身的做法,通過將操作封裝成獨立的Task由NioEventLoop統一執行,而不是業務線程直接操作,相關代碼如下所示:
圖2-31 封裝成Task防止多線程並發操作
如果你確認並發訪問的數據或者並發操作是安全的,則無需多此一舉,這個需要根據具體的業務場景進行判斷,靈活處理。
if(ctx.executor().inEventLoop()){ //如果當前線程就是業務線程ctx.executor(),執行任務
}else{
ctx.executor().execute(new Runnable(){ //否則把任務投遞到業務線程里
}
}
======
對於絕大多數業務,業務邏輯耗時是<<<IO耗時,使用默認配置沒有問題。
2.如果業務邏輯復雜,耗時長,則建議使用自己實現的線程池。
tomcat也使用了線程池,但是他有限制連接數。所以 使用自己線程池的時候要么也限流,要么實現自己線程池,當任務超過一定量的提交任務時阻塞。
鏈接:https://www.zhihu.com/question/35487154/answer/89255483
http://www.52im.net/thread-184-1-1.html
http://www.infoq.com/cn/articles/netty-version-upgrade-history-thread-part/
Netty 3.x 線程模型
Netty 3.X的I/O操作線程模型比較復雜,它的處理模型包括兩部分:
- Inbound:主要包括鏈路建立事件、鏈路激活事件、讀事件、I/O異常事件、鏈路關閉事件等;
- Outbound:主要包括寫事件、連接事件、監聽綁定事件、刷新事件等。
我們首先分析下Inbound操作的線程模型:
<ignore_js_op>
從上圖可以看出,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操作的線程模型:
<ignore_js_op>
從上圖可以看出,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在循環中統一調度和執行。放入任務隊列之后,業務線程返回;【執行寫操作的業務線程不是socket對應的IO線程,則進行寫操作封裝並投遞給寫線程】
- 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版本肯定會更高。
使用業務線程池把I/O操作和業務操作的隔離
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事件處理線程模型