Netty系列之Netty線程模型

1. 背景

1.1. Java線程模型的演進

1.1.1. 單線程
時間回到十幾年前,那時主流的CPU都還是單核(除了商用高性能的小機),CPU的核心頻率是機器最重要的指標之一。

在Java領域當時比較流行的是單線程編程,對於CPU密集型的應用程序而言,頻繁的通過多線程進行協作和搶佔時間片反而會降低性能。

1.1.2. 多線程
隨着硬件性能的提升,CPU的核數越來越越多,很多服務器標配已經達到32或64核。通過多線程併發編程,可以充分利用多核CPU的處理能力,提升系統的處理效率和併發性能。

從2005年開始,隨着多核處理器的逐步普及,java的多線程併發編程也逐漸流行起來,當時商用主流的JDK版本是1.4,用戶可以通過 new Thread()的方式創建新的線程。

由於JDK1.4並沒有提供類似線程池這樣的線程管理容器,多線程之間的同步、協作、創建和銷燬等工作都需要用戶自己實現。由於創建和銷燬線程是個相對比較重量級的操作,因此,這種原始的多線程編程效率和性能都不高。

1.1.3. 線程池
爲了提升Java多線程編程的效率和性能,降低用戶開發難度。JDK1.5推出了java.util.concurrent併發編程包。在併發編程類庫中,提供了線程池、線程安全容器、原子類等新的類庫,極大的提升了Java多線程編程的效率,降低了開發難度。

從JDK1.5開始,基於線程池的併發編程已經成爲Java多核編程的主流。

1.2. Reactor模型

無論是C++還是Java編寫的網絡框架,大多數都是基於Reactor模式進行設計和開發,Reactor模式基於事件驅動,特別適合處理海量的I/O事件。

1.2.1. 單線程模型
Reactor單線程模型,指的是所有的IO操作都在同一個NIO線程上面完成,NIO線程的職責如下:

1)作爲NIO服務端,接收客戶端的TCP連接;

2)作爲NIO客戶端,向服務端發起TCP連接;

3)讀取通信對端的請求或者應答消息;

4)向通信對端發送消息請求或者應答消息。

Reactor單線程模型示意圖如下所示:
這裏寫圖片描述

圖1-1 Reactor單線程模型

由於Reactor模式使用的是異步非阻塞IO,所有的IO操作都不會導致阻塞,理論上一個線程可以獨立處理所有IO相關的操作。從架構層面看,一個NIO線程確實可以完成其承擔的職責。例如,通過Acceptor類接收客戶端的TCP連接請求消息,鏈路建立成功之後,通過Dispatch將對應的ByteBuffer派發到指定的Handler上進行消息解碼。用戶線程可以通過消息編碼通過NIO線程將消息發送給客戶端。

對於一些小容量應用場景,可以使用單線程模型。但是對於高負載、大併發的應用場景卻不合適,主要原因如下:

1)一個NIO線程同時處理成百上千的鏈路,性能上無法支撐,即便NIO線程的CPU負荷達到100%,也無法滿足海量消息的編碼、解碼、讀取和發送;

2)當NIO線程負載過重之後,處理速度將變慢,這會導致大量客戶端連接超時,超時之後往往會進行重發,這更加重了NIO線程的負載,最終會導致大量消息積壓和處理超時,成爲系統的性能瓶頸;

3)可靠性問題:一旦NIO線程意外跑飛,或者進入死循環,會導致整個系統通信模塊不可用,不能接收和處理外部消息,造成節點故障。

爲了解決這些問題,演進出了Reactor多線程模型,下面我們一起學習下Reactor多線程模型。

1.2.2. 多線程模型
Rector多線程模型與單線程模型最大的區別就是有一組NIO線程處理IO操作,它的原理圖如下:
這裏寫圖片描述

圖1-2 Reactor多線程模型

Reactor多線程模型的特點:

1)有專門一個NIO線程-Acceptor線程用於監聽服務端,接收客戶端的TCP連接請求;

2)網絡IO操作-讀、寫等由一個NIO線程池負責,線程池可以採用標準的JDK線程池實現,它包含一個任務隊列和N個可用的線程,由這些NIO線程負責消息的讀取、解碼、編碼和發送;

3)1個NIO線程可以同時處理N條鏈路,但是1個鏈路只對應1個NIO線程,防止發生併發操作問題。

在絕大多數場景下,Reactor多線程模型都可以滿足性能需求;但是,在極個別特殊場景中,一個NIO線程負責監聽和處理所有的客戶端連接可能會存在性能問題。例如併發百萬客戶端連接,或者服務端需要對客戶端握手進行安全認證,但是認證本身非常損耗性能。在這類場景下,單獨一個Acceptor線程可能會存在性能不足問題,爲了解決性能問題,產生了第三種Reactor線程模型-主從Reactor多線程模型。

1.2.3. 主從多線程模型
主從Reactor線程模型的特點是:服務端用於接收客戶端連接的不再是個1個單獨的NIO線程,而是一個獨立的NIO線程池。Acceptor接收到客戶端TCP連接請求處理完成後(可能包含接入認證等),將新創建的SocketChannel註冊到IO線程池(sub reactor線程池)的某個IO線程上,由它負責SocketChannel的讀寫和編解碼工作。Acceptor線程池僅僅只用於客戶端的登陸、握手和安全認證,一旦鏈路建立成功,就將鏈路註冊到後端subReactor線程池的IO線程上,由IO線程負責後續的IO操作。

它的線程模型如下圖所示:

這裏寫圖片描述

圖1-3 主從Reactor多線程模型

利用主從NIO線程模型,可以解決1個服務端監聽線程無法有效處理所有客戶端連接的性能不足問題。

它的工作流程總結如下:

從主線程池中隨機選擇一個Reactor線程作爲Acceptor線程,用於綁定監聽端口,接收客戶端連接;
Acceptor線程接收客戶端連接請求之後創建新的SocketChannel,將其註冊到主線程池的其它Reactor線程上,由其負責接入認證、IP黑白名單過濾、握手等操作;
步驟2完成之後,業務層的鏈路正式建立,將SocketChannel從主線程池的Reactor線程的多路複用器上摘除,重新註冊到Sub線程池的線程上,用於處理I/O的讀寫操作。
2. Netty線程模型

2.1. Netty線程模型分類

事實上,Netty的線程模型與1.2章節中介紹的三種Reactor線程模型相似,下面章節我們通過Netty服務端和客戶端的線程處理流程圖來介紹Netty的線程模型。

2.1.1. 服務端線程模型
一種比較流行的做法是服務端監聽線程和IO線程分離,類似於Reactor的多線程模型,它的工作原理圖如下:
這裏寫圖片描述

圖2-1 Netty服務端線程工作流程

下面我們結合Netty的源碼,對服務端創建線程工作流程進行介紹:

第一步,從用戶線程發起創建服務端操作,代碼如下:
這裏寫圖片描述

圖2-2 用戶線程創建服務端代碼示例

通常情況下,服務端的創建是在用戶進程啓動的時候進行,因此一般由Main函數或者啓動類負責創建,服務端的創建由業務線程負責完成。在創建服務端的時候實例化了2個EventLoopGroup,1個EventLoopGroup實際就是一個EventLoop線程組,負責管理EventLoop的申請和釋放。

EventLoopGroup管理的線程數可以通過構造函數設置,如果沒有設置,默認取-Dio.netty.eventLoopThreads,如果該系統參數也沒有指定,則爲可用的CPU內核數 × 2。

bossGroup線程組實際就是Acceptor線程池,負責處理客戶端的TCP連接請求,如果系統只有一個服務端端口需要監聽,則建議bossGroup線程組線程數設置爲1。

workerGroup是真正負責I/O讀寫操作的線程組,通過ServerBootstrap的group方法進行設置,用於後續的Channel綁定。

第二步,Acceptor線程綁定監聽端口,啓動NIO服務端,相關代碼如下:
這裏寫圖片描述

圖2-3 從bossGroup中選擇一個Acceptor線程監聽服務端

其中,group()返回的就是bossGroup,它的next方法用於從線程組中獲取可用線程,代碼如下:
這裏寫圖片描述

圖2-4 選擇Acceptor線程

服務端Channel創建完成之後,將其註冊到多路複用器Selector上,用於接收客戶端的TCP連接,核心代碼如下:
這裏寫圖片描述

圖2-5 註冊ServerSocketChannel 到Selector

第三步,如果監聽到客戶端連接,則創建客戶端SocketChannel連接,重新註冊到workerGroup的IO線程上。首先看Acceptor如何處理客戶端的接入:
這裏寫圖片描述

圖2-6 處理讀或者連接事件

調用unsafe的read()方法,對於NioServerSocketChannel,它調用了NioMessageUnsafe的read()方法,代碼如下:
這裏寫圖片描述

圖2-7 NioServerSocketChannel的read()方法

最終它會調用NioServerSocketChannel的doReadMessages方法,代碼如下:

這裏寫圖片描述

圖2-8 創建客戶端連接SocketChannel

其中childEventLoopGroup就是之前的workerGroup, 從中選擇一個I/O線程負責網絡消息的讀寫。

第四步,選擇IO線程之後,將SocketChannel註冊到多路複用器上,監聽READ操作。
這裏寫圖片描述

圖2-9 監聽網絡讀事件

第五步,處理網絡的I/O讀寫事件,核心代碼如下:
這裏寫圖片描述

圖2-10 處理讀寫事件

2.1.2. 客戶端線程模型
相比於服務端,客戶端的線程模型簡單一些,它的工作原理如下:
這裏寫圖片描述

圖2-11 Netty客戶端線程模型

第一步,由用戶線程發起客戶端連接,示例代碼如下:
這裏寫圖片描述

圖2-12 Netty客戶端創建代碼示例

大家發現相比於服務端,客戶端只需要創建一個EventLoopGroup,因爲它不需要獨立的線程去監聽客戶端連接,也沒必要通過一個單獨的客戶端線程去連接服務端。Netty是異步事件驅動的NIO框架,它的連接和所有IO操作都是異步的,因此不需要創建單獨的連接線程。相關代碼如下:

這裏寫圖片描述

圖2-13 綁定客戶端連接線程

當前的group()就是之前傳入的EventLoopGroup,從中獲取可用的IO線程EventLoop,然後作爲參數設置到新創建的NioSocketChannel中。

第二步,發起連接操作,判斷連接結果,代碼如下:
這裏寫圖片描述

圖2-14 連接操作

判斷連接結果,如果沒有連接成功,則監聽連接網絡操作位SelectionKey.OP_CONNECT。如果連接成功,則調用pipeline().fireChannelActive()將監聽位修改爲READ。

第三步,由NioEventLoop的多路複用器輪詢連接操作結果,代碼如下:

這裏寫圖片描述

圖2-15 Selector發起輪詢操作

判斷連接結果,如果或連接成功,重新設置監聽位爲READ:

這裏寫圖片描述

圖2-16 判斷連接操作結果
這裏寫圖片描述

圖2-17 設置操作位爲READ

第四步,由NioEventLoop線程負責I/O讀寫,同服務端。

總結:客戶端創建,線程模型如下:

由用戶線程負責初始化客戶端資源,發起連接操作;
如果連接成功,將SocketChannel註冊到IO線程組的NioEventLoop線程中,監聽讀操作位;
如果沒有立即連接成功,將SocketChannel註冊到IO線程組的NioEventLoop線程中,監聽連接操作位;
連接成功之後,修改監聽位爲READ,但是不需要切換線程。
2.2. Reactor線程NioEventLoop

2.2.1. NioEventLoop介紹
NioEventLoop是Netty的Reactor線程,它的職責如下:

作爲服務端Acceptor線程,負責處理客戶端的請求接入;
作爲客戶端Connecor線程,負責註冊監聽連接操作位,用於判斷異步連接結果;
作爲IO線程,監聽網絡讀操作位,負責從SocketChannel中讀取報文;
作爲IO線程,負責向SocketChannel寫入報文發送給對方,如果發生寫半包,會自動註冊監聽寫事件,用於後續繼續發送半包數據,直到數據全部發送完成;
作爲定時任務線程,可以執行定時任務,例如鏈路空閒檢測和發送心跳消息等;
作爲線程執行器可以執行普通的任務線程(Runnable)。
在服務端和客戶端線程模型章節我們已經詳細介紹了NioEventLoop如何處理網絡IO事件,下面我們簡單看下它是如何處理定時任務和執行普通的Runnable的。

首先NioEventLoop繼承SingleThreadEventExecutor,這就意味着它實際上是一個線程個數爲1的線程池,類繼承關係如下所示:

這裏寫圖片描述

圖2-18 NioEventLoop繼承關係
這裏寫圖片描述

圖2-19 線程池和任務隊列定義

對於用戶而言,直接調用NioEventLoop的execute(Runnable task)方法即可執行自定義的Task,代碼實現如下:

這裏寫圖片描述

圖2-20 執行用戶自定義Task

這裏寫圖片描述

圖2-21 NioEventLoop實現ScheduledExecutorService

通過調用SingleThreadEventExecutor的schedule系列方法,可以在NioEventLoop中執行Netty或者用戶自定義的定時任務,接口定義如下:
這裏寫圖片描述

圖2-22 NioEventLoop的定時任務執行接口定義

2.3. NioEventLoop設計原理

2.3.1. 串行化設計避免線程競爭
我們知道當系統在運行過程中,如果頻繁的進行線程上下文切換,會帶來額外的性能損耗。多線程併發執行某個業務流程,業務開發者還需要時刻對線程安全保持警惕,哪些數據可能會被併發修改,如何保護?這不僅降低了開發效率,也會帶來額外的性能損耗。

串行執行Handler鏈

爲了解決上述問題,Netty採用了串行化設計理念,從消息的讀取、編碼以及後續Handler的執行,始終都由IO線程NioEventLoop負責,這就意外着整個流程不會進行線程上下文的切換,數據也不會面臨被併發修改的風險,對於用戶而言,甚至不需要了解Netty的線程細節,這確實是個非常好的設計理念,它的工作原理圖如下:
這裏寫圖片描述

圖2-23 NioEventLoop串行執行ChannelHandler

一個NioEventLoop聚合了一個多路複用器Selector,因此可以處理成百上千的客戶端連接,Netty的處理策略是每當有一個新的客戶端接入,則從NioEventLoop線程組中順序獲取一個可用的NioEventLoop,當到達數組上限之後,重新返回到0,通過這種方式,可以基本保證各個NioEventLoop的負載均衡。一個客戶端連接只註冊到一個NioEventLoop上,這樣就避免了多個IO線程去併發操作它。

Netty通過串行化設計理念降低了用戶的開發難度,提升了處理性能。利用線程組實現了多個串行化線程水平並行執行,線程之間並沒有交集,這樣既可以充分利用多核提升並行處理能力,同時避免了線程上下文的切換和併發保護帶來的額外性能損耗。

2.3.2. 定時任務與時間輪算法
在Netty中,有很多功能依賴定時任務,比較典型的有兩種:

客戶端連接超時控制;
鏈路空閒檢測。
一種比較常用的設計理念是在NioEventLoop中聚合JDK的定時任務線程池ScheduledExecutorService,通過它來執行定時任務。這樣做單純從性能角度看不是最優,原因有如下三點:

在IO線程中聚合了一個獨立的定時任務線程池,這樣在處理過程中會存在線程上下文切換問題,這就打破了Netty的串行化設計理念;
存在多線程併發操作問題,因爲定時任務Task和IO線程NioEventLoop可能同時訪問並修改同一份數據;
JDK的ScheduledExecutorService從性能角度看,存在性能優化空間。
最早面臨上述問題的是操作系統和協議棧,例如TCP協議棧,其可靠傳輸依賴超時重傳機制,因此每個通過TCP傳輸的 packet 都需要一個 timer來調度 timeout 事件。這類超時可能是海量的,如果爲每個超時都創建一個定時器,從性能和資源消耗角度看都是不合理的。

根據George Varghese和Tony Lauck 1996年的論文《Hashed and Hierarchical Timing Wheels: data structures to efficiently implement a timer facility》提出了一種定時輪的方式來管理和維護大量的timer調度。Netty的定時任務調度就是基於時間輪算法調度,下面我們一起來看下Netty的實現。

定時輪是一種數據結構,其主體是一個循環列表,每個列表中包含一個稱之爲slot的結構,它的原理圖如下:

這裏寫圖片描述

圖2-24 時間輪工作原理

定時輪的工作原理可以類比於時鍾,如上圖箭頭(指針)按某一個方向按固定頻率輪動,每一次跳動稱爲一個tick。這樣可以看出定時輪由個3個重要的屬性參數:ticksPerWheel(一輪的tick數),tickDuration(一個tick的持續時間)以及 timeUnit(時間單位),例如當ticksPerWheel=60,tickDuration=1,timeUnit=秒,這就和時鐘的秒針走動完全類似了。

下面我們具體分析下Netty的實現:時間輪的執行由NioEventLoop來複雜檢測,首先看任務隊列中是否有超時的定時任務和普通任務,如果有則按照比例循環執行這些任務,代碼如下:

這裏寫圖片描述

圖2-25 執行任務隊列

如果沒有需要理解執行的任務,則調用Selector的select方法進行等待,等待的時間爲定時任務隊列中第一個超時的定時任務時延,代碼如下:

這裏寫圖片描述

圖2-26 計算時延

從定時任務Task隊列中彈出delay最小的Task,計算超時時間,代碼如下:

這裏寫圖片描述

圖2-27 從定時任務隊列中獲取超時時間

定時任務的執行:經過週期tick之後,掃描定時任務列表,將超時的定時任務移除到普通任務隊列中,等待執行,相關代碼如下:

這裏寫圖片描述

圖2-28 檢測超時的定時任務

檢測和拷貝任務完成之後,就執行超時的定時任務,代碼如下:

這裏寫圖片描述

圖2-29 執行定時任務

爲了保證定時任務的執行不會因爲過度擠佔IO事件的處理,Netty提供了IO執行比例供用戶設置,用戶可以設置分配給IO的執行比例,防止因爲海量定時任務的執行導致IO處理超時或者積壓。

因爲獲取系統的納秒時間是件耗時的操作,所以Netty每執行64個定時任務檢測一次是否達到執行的上限時間,達到則退出。如果沒有執行完,放到下次Selector輪詢時再處理,給IO事件的處理提供機會,代碼如下:
這裏寫圖片描述

圖2-30 執行時間上限檢測

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防止多線程併發操作

如果你確認併發訪問的數據或者併發操作是安全的,則無需多此一舉,這個需要根據具體的業務場景進行判斷,靈活處理。

3. 總結

儘管Netty的線程模型並不複雜,但是如何合理利用Netty開發出高性能、高併發的業務產品,仍然是個有挑戰的工作。只有充分理解了Netty的線程模型和設計原理,才能開發出高質量的產品。

  1. Netty學習推薦書籍

目前市面上介紹netty的文章很多,如果讀者希望系統性的學習Netty,推薦兩本書:

1) 《Netty in Action》,建議閱讀英文原版。

2) 《Netty權威指南》,建議通過理論聯繫實際方式學習。

*5. 作者簡介
李林鋒,2007年畢業於東北大學,2008年進入華爲公司從事高性能通信軟件的設計和開發工作,有6年NIO設計和開發經驗,精通Netty、Mina等NIO框架,Netty中國社區創始人和Netty框架推廣者*

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章