天天看點

Netty線程模型詳解

時間回到十幾年前,那時主流的cpu都還是單核(除了商用高性能的小機),cpu的核心頻率是機器最重要的名額之一。

在java領域當時比較流行的是單線程程式設計,對于cpu密集型的應用程式而言,頻繁的通過多線程進行協作和搶占時間片反而會降低性能。

随着硬體性能的提升,cpu的核數越來越越多,很多伺服器标配已經達到32或64核。通過多線程并發程式設計,可以充分利用多核cpu的處理能力,提升系統的處理效率和并發性能。

從2005年開始,随着多核處理器的逐漸普及,java的多線程并發程式設計也逐漸流行起來,當時商用主流的jdk版本是1.4,使用者可以通過 new thread()的方式建立新的線程。

由于jdk1.4并沒有提供類似線程池這樣的線程管理容器,多線程之間的同步、協作、建立和銷毀等工作都需要使用者自己實作。由于建立和銷毀線程是個相對比較重量級的操作,是以,這種原始的多線程程式設計效率和性能都不高。

為了提升java多線程程式設計的效率和性能,降低使用者開發難度。jdk1.5推出了java.util.concurrent并發程式設計包。在并發程式設計類庫中,提供了線程池、線程安全容器、原子類等新的類庫,極大的提升了java多線程程式設計的效率,降低了開發難度。

從jdk1.5開始,基于線程池的并發程式設計已經成為java多核程式設計的主流。

無論是c++還是java編寫的網絡架構,大多數都是基于reactor模式進行設計和開發,reactor模式基于事件驅動,特别适合處理海量的i/o事件。

reactor單線程模型,指的是所有的io操作都在同一個nio線程上面完成,nio線程的職責如下:

1)作為nio服務端,接收用戶端的tcp連接配接;

2)作為nio用戶端,向服務端發起tcp連接配接;

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

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

reactor單線程模型示意圖如下所示:

Netty線程模型詳解

圖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多線程模型。

rector多線程模型與單線程模型最大的差別就是有一組nio線程處理io操作,它的原理圖如下:

Netty線程模型詳解

圖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多線程模型。

主從reactor線程模型的特點是:服務端用于接收用戶端連接配接的不再是個1個單獨的nio線程,而是一個獨立的nio線程池。acceptor接收到用戶端tcp連接配接請求處理完成後(可能包含接入認證等),将新建立的socketchannel注冊到io線程池(sub reactor線程池)的某個io線程上,由它負責socketchannel的讀寫和編解碼工作。acceptor線程池僅僅隻用于用戶端的登陸、握手和安全認證,一旦鍊路建立成功,就将鍊路注冊到後端subreactor線程池的io線程上,由io線程負責後續的io操作。

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

Netty線程模型詳解

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

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

它的工作流程總結如下:

從主線程池中随機選擇一個reactor線程作為acceptor線程,用于綁定監聽端口,接收用戶端連接配接;

acceptor線程接收用戶端連接配接請求之後建立新的socketchannel,将其注冊到主線程池的其它reactor線程上,由其負責接入認證、ip黑白名單過濾、握手等操作;

步驟2完成之後,業務層的鍊路正式建立,将socketchannel從主線程池的reactor線程的多路複用器上摘除,重新注冊到sub線程池的線程上,用于處理i/o的讀寫操作。

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

一種比較流行的做法是服務端監聽線程和io線程分離,類似于reactor的多線程模型,它的工作原理圖如下:

Netty線程模型詳解

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

下面我們結合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服務端,相關代碼如下:

Netty線程模型詳解

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

其中,group()傳回的就是bossgroup,它的next方法用于從線程組中擷取可用線程,代碼如下:

Netty線程模型詳解

圖2-4 選擇acceptor線程

服務端channel建立完成之後,将其注冊到多路複用器selector上,用于接收用戶端的tcp連接配接,核心代碼如下:

Netty線程模型詳解

圖2-5 注冊serversocketchannel 到selector

第三步,如果監聽到用戶端連接配接,則建立用戶端socketchannel連接配接,重新注冊到workergroup的io線程上。首先看acceptor如何處理用戶端的接入:

Netty線程模型詳解

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

調用unsafe的read()方法,對于nioserversocketchannel,它調用了niomessageunsafe的read()方法,代碼如下:

Netty線程模型詳解

圖2-7 nioserversocketchannel的read()方法

最終它會調用nioserversocketchannel的doreadmessages方法,代碼如下:

Netty線程模型詳解

圖2-8 建立用戶端連接配接socketchannel

其中childeventloopgroup就是之前的workergroup, 從中選擇一個i/o線程負責網絡消息的讀寫。

第四步,選擇io線程之後,将socketchannel注冊到多路複用器上,監聽read操作。

Netty線程模型詳解

圖2-9 監聽網絡讀事件

第五步,處理網絡的i/o讀寫事件,核心代碼如下:

Netty線程模型詳解

圖2-10 處理讀寫事件

相比于服務端,用戶端的線程模型簡單一些,它的工作原理如下:

Netty線程模型詳解

圖2-11 netty用戶端線程模型

第一步,由使用者線程發起用戶端連接配接,示例代碼如下:

Netty線程模型詳解

圖2-12 netty用戶端建立代碼示例

大家發現相比于服務端,用戶端隻需要建立一個eventloopgroup,因為它不需要獨立的線程去監聽用戶端連接配接,也沒必要通過一個單獨的用戶端線程去連接配接服務端。netty是異步事件驅動的nio架構,它的連接配接和所有io操作都是異步的,是以不需要建立單獨的連接配接線程。相關代碼如下:

Netty線程模型詳解

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

目前的group()就是之前傳入的eventloopgroup,從中擷取可用的io線程eventloop,然後作為參數設定到新建立的niosocketchannel中。

第二步,發起連接配接操作,判斷連接配接結果,代碼如下:

Netty線程模型詳解

圖2-14 連接配接操作

判斷連接配接結果,如果沒有連接配接成功,則監聽連接配接網絡操作位selectionkey.op_connect。如果連接配接成功,則調用pipeline().firechannelactive()将監聽位修改為read。

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

Netty線程模型詳解

圖2-15 selector發起輪詢操作

判斷連接配接結果,如果或連接配接成功,重新設定監聽位為read:

Netty線程模型詳解

圖2-16 判斷連接配接操作結果

Netty線程模型詳解

圖2-17 設定操作位為read

第四步,由nioeventloop線程負責i/o讀寫,同服務端。

總結:用戶端建立,線程模型如下:

由使用者線程負責初始化用戶端資源,發起連接配接操作;

如果連接配接成功,将socketchannel注冊到io線程組的nioeventloop線程中,監聽讀操作位;

如果沒有立即連接配接成功,将socketchannel注冊到io線程組的nioeventloop線程中,監聽連接配接操作位;

連接配接成功之後,修改監聽位為read,但是不需要切換線程。

nioeventloop是netty的reactor線程,它的職責如下:

作為服務端acceptor線程,負責處理用戶端的請求接入;

作為用戶端connecor線程,負責注冊監聽連接配接操作位,用于判斷異步連接配接結果;

作為io線程,監聽網絡讀操作位,負責從socketchannel中讀取封包;

作為io線程,負責向socketchannel寫入封包發送給對方,如果發生寫半包,會自動注冊監聽寫事件,用于後續繼續發送半包資料,直到資料全部發送完成;

作為定時任務線程,可以執行定時任務,例如鍊路空閑檢測和發送心跳消息等;

作為線程執行器可以執行普通的任務線程(runnable)。

在服務端和用戶端線程模型章節我們已經詳細介紹了nioeventloop如何處理網絡io事件,下面我們簡單看下它是如何處理定時任務和執行普通的runnable的。

首先nioeventloop繼承singlethreadeventexecutor,這就意味着它實際上是一個線程個數為1的線程池,類繼承關系如下所示:

Netty線程模型詳解

圖2-18 nioeventloop繼承關系

Netty線程模型詳解

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

對于使用者而言,直接調用nioeventloop的execute(runnable task)方法即可執行自定義的task,代碼實作如下:

Netty線程模型詳解

圖2-20 執行使用者自定義task

Netty線程模型詳解

圖2-21 nioeventloop實作scheduledexecutorservice

通過調用singlethreadeventexecutor的schedule系列方法,可以在nioeventloop中執行netty或者使用者自定義的定時任務,接口定義如下:

Netty線程模型詳解

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

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

串行執行handler鍊

為了解決上述問題,netty采用了串行化設計理念,從消息的讀取、編碼以及後續handler的執行,始終都由io線程nioeventloop負責,這就意外着整個流程不會進行線程上下文的切換,資料也不會面臨被并發修改的風險,對于使用者而言,甚至不需要了解netty的線程細節,這确實是個非常好的設計理念,它的工作原理圖如下:

Netty線程模型詳解

圖2-23 nioeventloop串行執行channelhandler

一個nioeventloop聚合了一個多路複用器selector,是以可以處理成百上千的用戶端連接配接,netty的處理政策是每當有一個新的用戶端接入,則從nioeventloop線程組中順序擷取一個可用的nioeventloop,當到達數組上限之後,重新傳回到0,通過這種方式,可以基本保證各個nioeventloop的負載均衡。一個用戶端連接配接隻注冊到一個nioeventloop上,這樣就避免了多個io線程去并發操作它。

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

在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的結構,它的原理圖如下:

Netty線程模型詳解

圖2-24 時間輪工作原理

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

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

Netty線程模型詳解

圖2-25 執行任務隊列

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

Netty線程模型詳解

圖2-26 計算時延

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

Netty線程模型詳解

圖2-27 從定時任務隊列中擷取逾時時間

定時任務的執行:經過周期tick之後,掃描定時任務清單,将逾時的定時任務移除到普通任務隊列中,等待執行,相關代碼如下:

Netty線程模型詳解

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

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

Netty線程模型詳解

圖2-29 執行定時任務

為了保證定時任務的執行不會因為過度擠占io事件的處理,netty提供了io執行比例供使用者設定,使用者可以設定配置設定給io的執行比例,防止因為海量定時任務的執行導緻io處理逾時或者積壓。

因為擷取系統的納秒時間是件耗時的操作,是以netty每執行64個定時任務檢測一次是否達到執行的上限時間,達到則退出。如果沒有執行完,放到下次selector輪詢時再處理,給io事件的處理提供機會,代碼如下:

Netty線程模型詳解

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

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線程本身,而不是提供使用者相關的業務線程模型。

如果業務非常簡單,執行時間非常短,不需要與外部網元互動、通路資料庫和磁盤,不需要等待其它資源,則建議直接在業務channelhandler中執行,不需要再啟業務的線程或者線程池。避免線程上下文切換,也不存線上程并發問題。

對于此類業務,不建議直接在業務channelhandler中啟動線程或者線程池處理,建議将不同的業務統一封裝成task,統一投遞到後端的業務線程池中進行處理。

過多的業務channelhandler會帶來開發效率和可維護性問題,不要把netty當作業務容器,對于大多數複雜的業務産品,仍然需要內建或者開發自己的業務容器,做好和netty的架構分層。

對于channelhandler,io線程和業務線程都可能會操作,因為業務通常是多線程模型,這樣就會存在多線程操作channelhandler。為了盡量避免多線程并發問題,建議按照netty自身的做法,通過将操作封裝成獨立的task由nioeventloop統一執行,而不是業務線程直接操作,相關代碼如下所示:

Netty線程模型詳解

圖2-31 封裝成task防止多線程并發操作

如果你确認并發通路的資料或者并發操作是安全的,則無需多此一舉,這個需要根據具體的業務場景進行判斷,靈活處理。

盡管netty的線程模型并不複雜,但是如何合理利用netty開發出高性能、高并發的業務産品,仍然是個有挑戰的工作。隻有充分了解了netty的線程模型和設計原理,才能開發出高品質的産品。

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

1) 《netty in action》,建議閱讀英文原版。

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

李林鋒,2007年畢業于東北大學,2008年進入華為公司從事高性能通信軟體的設計和開發工作,有6年nio設計和開發經驗,精通netty、mina等nio架構,netty中國社群創始人和netty架構推廣者。

新浪微網誌:nettying 微信:nettying netty學習群:195820454