1. 程式人生 > >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個服務端監聽執行緒無法有效處理所有客戶端連線的效能不足問題。

它的工作流程總結如下:

  1. 從主執行緒池中隨機選擇一個Reactor執行緒作為Acceptor執行緒,用於繫結監聽埠,接收客戶端連線;
  2. Acceptor執行緒接收客戶端連線請求之後建立新的SocketChannel,將其註冊到主執行緒池的其它Reactor執行緒上,由其負責接入認證、IP黑白名單過濾、握手等操作;
  3. 步驟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讀寫,同服務端。

總結:客戶端建立,執行緒模型如下:

  1. 由使用者執行緒負責初始化客戶端資源,發起連線操作;
  2. 如果連線成功,將SocketChannel註冊到IO執行緒組的NioEventLoop執行緒中,監聽讀操作位;
  3. 如果沒有立即連線成功,將SocketChannel註冊到IO執行緒組的NioEventLoop執行緒中,監聽連線操作位;
  4. 連線成功之後,修改監聽位為READ,但是不需要切換執行緒。

2.2. Reactor執行緒NioEventLoop

2.2.1. NioEventLoop介紹

NioEventLoop是Netty的Reactor執行緒,它的職責如下:

  1. 作為服務端Acceptor執行緒,負責處理客戶端的請求接入;
  2. 作為客戶端Connecor執行緒,負責註冊監聽連線操作位,用於判斷非同步連線結果;
  3. 作為IO執行緒,監聽網路讀操作位,負責從SocketChannel中讀取報文;
  4. 作為IO執行緒,負責向SocketChannel寫入報文傳送給對方,如果發生寫半包,會自動註冊監聽寫事件,用於後續繼續傳送半包資料,直到資料全部發送完成;
  5. 作為定時任務執行緒,可以執行定時任務,例如鏈路空閒檢測和傳送心跳訊息等;
  6. 作為執行緒執行器可以執行普通的任務執行緒(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中,有很多功能依賴定時任務,比較典型的有兩種:

  1. 客戶端連線超時控制;
  2. 鏈路空閒檢測。

一種比較常用的設計理念是在NioEventLoop中聚合JDK的定時任務執行緒池ScheduledExecutorService,通過它來執行定時任務。這樣做單純從效能角度看不是最優,原因有如下三點:

  1. 在IO執行緒中聚合了一個獨立的定時任務執行緒池,這樣在處理過程中會存線上程上下文切換問題,這就打破了Netty的序列化設計理念;
  2. 存在多執行緒併發操作問題,因為定時任務Task和IO執行緒NioEventLoop可能同時訪問並修改同一份資料;
  3. 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的執行緒模型和設計原理,才能開發出高質量的產品。

4. Netty學習推薦書籍

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

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

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

5. 作者簡介

李林鋒,2007年畢業於東北大學,2008年進入華為公司從事高效能通訊軟體的設計和開發工作,有6年NIO設計和開發經驗,精通Netty、Mina等NIO框架,Netty中國社群創始人和Netty框架推廣者。

新浪微博:Nettying 微信:Nettying Netty學習群:195820454