1. 程式人生 > >Netty實現高效能高併發

Netty實現高效能高併發

1. 背景

1.1. 驚人的效能資料

最近一個圈內朋友通過私信告訴我,通過使用Netty4 + Thrift壓縮二進位制編解碼技術,他們實現了10W TPS(1K的複雜POJO物件)的跨節點遠端服務呼叫。相比於傳統基於Java序列化+BIO(同步阻塞IO)的通訊框架,效能提升了8倍多。

事實上,我對這個資料並不感到驚訝,根據我5年多的NIO程式設計經驗,通過選擇合適的NIO框架,加上高效能的壓縮二進位制編解碼技術,精心的設計Reactor執行緒模型,達到上述效能指標是完全有可能的。

下面我們就一起來看下Netty是如何支援10W TPS的跨節點遠端服務呼叫的,在正式開始講解之前,我們先簡單介紹下Netty。

1.2. Netty基礎入門

Netty是一個高效能、非同步事件驅動的NIO框架,它提供了對TCP、UDP和檔案傳輸的支援,作為一個非同步NIO框架,Netty的所有IO操作都是非同步非阻塞的,通過Future-Listener機制,使用者可以方便的主動獲取或者通過通知機制獲得IO操作結果。

作為當前最流行的NIO框架,Netty在網際網路領域、大資料分散式計算領域、遊戲行業、通訊行業等獲得了廣泛的應用,一些業界著名的開源元件也基於Netty的NIO框架構建。

2. Netty高效能之道

2.1. RPC呼叫的效能模型分析

2.1.1. 傳統RPC呼叫效能差的三宗罪

網路傳輸方式問題:傳統的RPC框架或者基於RMI等方式的遠端服務(過程)呼叫採用了同步阻塞IO,當客戶端的併發壓力或者網路時延增大之後,同步阻塞IO會由於頻繁的wait導致IO執行緒經常性的阻塞,由於執行緒無法高效的工作,IO處理能力自然下降。

下面,我們通過BIO通訊模型圖看下BIO通訊的弊端:

圖2-1 BIO通訊模型圖

採用BIO通訊模型的服務端,通常由一個獨立的Acceptor執行緒負責監聽客戶端的連線,接收到客戶端連線之後為客戶端連線建立一個新的執行緒處理請求訊息,處理完成之後,返回應答訊息給客戶端,執行緒銷燬,這就是典型的一請求一應答模型。該架構最大的問題就是不具備彈性伸縮能力,當併發訪問量增加後,服務端的執行緒個數和併發訪問數成線性正比,由於執行緒是JAVA虛擬機器非常寶貴的系統資源,當執行緒數膨脹之後,系統的效能急劇下降,隨著併發量的繼續增加,可能會發生控制代碼溢位、執行緒堆疊溢位等問題,並導致伺服器最終宕機。

序列化方式問題:Java序列化存在如下幾個典型問題:

1) Java序列化機制是Java內部的一種物件編解碼技術,無法跨語言使用;例如對於異構系統之間的對接,Java序列化後的碼流需要能夠通過其它語言反序列化成原始物件(副本),目前很難支援;

2) 相比於其它開源的序列化框架,Java序列化後的碼流太大,無論是網路傳輸還是持久化到磁碟,都會導致額外的資源佔用;

3) 序列化效能差(CPU資源佔用高)。

執行緒模型問題:由於採用同步阻塞IO,這會導致每個TCP連線都佔用1個執行緒,由於執行緒資源是JVM虛擬機器非常寶貴的資源,當IO讀寫阻塞導致執行緒無法及時釋放時,會導致系統性能急劇下降,嚴重的甚至會導致虛擬機器無法建立新的執行緒。

2.1.2. 高效能的三個主題

1) 傳輸:用什麼樣的通道將資料傳送給對方,BIO、NIO或者AIO,IO模型在很大程度上決定了框架的效能。

2) 協議:採用什麼樣的通訊協議,HTTP或者內部私有協議。協議的選擇不同,效能模型也不同。相比於公有協議,內部私有協議的效能通常可以被設計的更優。

3) 執行緒:資料報如何讀取?讀取之後的編解碼在哪個執行緒進行,編解碼後的訊息如何派發,Reactor執行緒模型的不同,對效能的影響也非常大。

圖2-2 RPC呼叫效能三要素

2.2. Netty高效能之道

2.2.1. 非同步非阻塞通訊

在IO程式設計過程中,當需要同時處理多個客戶端接入請求時,可以利用多執行緒或者IO多路複用技術進行處理。IO多路複用技術通過把多個IO的阻塞複用到同一個select的阻塞上,從而使得系統在單執行緒的情況下可以同時處理多個客戶端請求。與傳統的多執行緒/多程序模型比,I/O多路複用的最大優勢是系統開銷小,系統不需要建立新的額外程序或者執行緒,也不需要維護這些程序和執行緒的執行,降低了系統的維護工作量,節省了系統資源。

JDK1.4提供了對非阻塞IO(NIO)的支援,JDK1.5_update10版本使用epoll替代了傳統的select/poll,極大的提升了NIO通訊的效能。

JDK NIO通訊模型如下所示:

圖2-3 NIO的多路複用模型圖

與Socket類和ServerSocket類相對應,NIO也提供了SocketChannel和ServerSocketChannel兩種不同的套接字通道實現。這兩種新增的通道都支援阻塞和非阻塞兩種模式。阻塞模式使用非常簡單,但是效能和可靠性都不好,非阻塞模式正好相反。開發人員一般可以根據自己的需要來選擇合適的模式,一般來說,低負載、低併發的應用程式可以選擇同步阻塞IO以降低程式設計複雜度。但是對於高負載、高併發的網路應用,需要使用NIO的非阻塞模式進行開發。

Netty架構按照Reactor模式設計和實現,它的服務端通訊序列圖如下:

圖2-3 NIO服務端通訊序列圖

客戶端通訊序列圖如下:

圖2-4 NIO客戶端通訊序列圖

Netty的IO執行緒NioEventLoop由於聚合了多路複用器Selector,可以同時併發處理成百上千個客戶端Channel,由於讀寫操作都是非阻塞的,這就可以充分提升IO執行緒的執行效率,避免由於頻繁IO阻塞導致的執行緒掛起。另外,由於Netty採用了非同步通訊模式,一個IO執行緒可以併發處理N個客戶端連線和讀寫操作,這從根本上解決了傳統同步阻塞IO一連線一執行緒模型,架構的效能、彈性伸縮能力和可靠性都得到了極大的提升。

2.2.2. 零拷貝

很多使用者都聽說過Netty具有“零拷貝”功能,但是具體體現在哪裡又說不清楚,本小節就詳細對Netty的“零拷貝”功能進行講解。

Netty的“零拷貝”主要體現在如下三個方面:

1) Netty的接收和傳送ByteBuffer採用DIRECT BUFFERS,使用堆外直接記憶體進行Socket讀寫,不需要進行位元組緩衝區的二次拷貝。如果使用傳統的堆記憶體(HEAP BUFFERS)進行Socket讀寫,JVM會將堆記憶體Buffer拷貝一份到直接記憶體中,然後才寫入Socket中。相比於堆外直接記憶體,訊息在傳送過程中多了一次緩衝區的記憶體拷貝。

2) Netty提供了組合Buffer物件,可以聚合多個ByteBuffer物件,使用者可以像操作一個Buffer那樣方便的對組合Buffer進行操作,避免了傳統通過記憶體拷貝的方式將幾個小Buffer合併成一個大的Buffer。

3) Netty的檔案傳輸採用了transferTo方法,它可以直接將檔案緩衝區的資料傳送到目標Channel,避免了傳統通過迴圈write方式導致的記憶體拷貝問題。

下面,我們對上述三種“零拷貝”進行說明,先看Netty 接收Buffer的建立:

圖2-5 非同步訊息讀取“零拷貝”

每迴圈讀取一次訊息,就通過ByteBufAllocator的ioBuffer方法獲取ByteBuf物件,下面繼續看它的介面定義:

圖2-6 ByteBufAllocator 通過ioBuffer分配堆外記憶體

當進行Socket IO讀寫的時候,為了避免從堆記憶體拷貝一份副本到直接記憶體,Netty的ByteBuf分配器直接建立非堆記憶體避免緩衝區的二次拷貝,通過“零拷貝”來提升讀寫效能。

下面我們繼續看第二種“零拷貝”的實現CompositeByteBuf,它對外將多個ByteBuf封裝成一個ByteBuf,對外提供統一封裝後的ByteBuf介面,它的類定義如下:

圖2-7 CompositeByteBuf類繼承關係

通過繼承關係我們可以看出CompositeByteBuf實際就是個ByteBuf的包裝器,它將多個ByteBuf組合成一個集合,然後對外提供統一的ByteBuf介面,相關定義如下:

圖2-8 CompositeByteBuf類定義

新增ByteBuf,不需要做記憶體拷貝,相關程式碼如下:

圖2-9 新增ByteBuf的“零拷貝”

最後,我們看下檔案傳輸的“零拷貝”:

圖2-10 檔案傳輸“零拷貝”

Netty檔案傳輸DefaultFileRegion通過transferTo方法將檔案傳送到目標Channel中,下面重點看FileChannel的transferTo方法,它的API DOC說明如下:

圖2-11 檔案傳輸 “零拷貝”

對於很多作業系統它直接將檔案緩衝區的內容傳送到目標Channel中,而不需要通過拷貝的方式,這是一種更加高效的傳輸方式,它實現了檔案傳輸的“零拷貝”。

2.2.3. 記憶體池

隨著JVM虛擬機器和JIT即時編譯技術的發展,物件的分配和回收是個非常輕量級的工作。但是對於緩衝區Buffer,情況卻稍有不同,特別是對於堆外直接記憶體的分配和回收,是一件耗時的操作。為了儘量重用緩衝區,Netty提供了基於記憶體池的緩衝區重用機制。下面我們一起看下Netty ByteBuf的實現:

圖2-12 記憶體池ByteBuf

Netty提供了多種記憶體管理策略,通過在啟動輔助類中配置相關引數,可以實現差異化的定製。

下面通過效能測試,我們看下基於記憶體池迴圈利用的ByteBuf和普通ByteBuf的效能差異。

用例一,使用記憶體池分配器建立直接記憶體緩衝區:

圖2-13 基於記憶體池的非堆記憶體緩衝區測試用例

用例二,使用非堆記憶體分配器建立的直接記憶體緩衝區:

圖2-14 基於非記憶體池建立的非堆記憶體緩衝區測試用例

各執行300萬次,效能對比結果如下所示:

圖2-15 記憶體池和非記憶體池緩衝區寫入效能對比

效能測試表明,採用記憶體池的ByteBuf相比於朝生夕滅的ByteBuf,效能高23倍左右(效能資料與使用場景強相關)。

下面我們一起簡單分析下Netty記憶體池的記憶體分配:

圖2-16 AbstractByteBufAllocator的緩衝區分配

繼續看newDirectBuffer方法,我們發現它是一個抽象方法,由AbstractByteBufAllocator的子類負責具體實現,程式碼如下:

圖2-17 newDirectBuffer的不同實現

程式碼跳轉到PooledByteBufAllocator的newDirectBuffer方法,從Cache中獲取記憶體區域PoolArena,呼叫它的allocate方法進行記憶體分配:

圖2-18 PooledByteBufAllocator的記憶體分配

PoolArena的allocate方法如下:

圖2-18 PoolArena的緩衝區分配

我們重點分析newByteBuf的實現,它同樣是個抽象方法,由子類DirectArena和HeapArena來實現不同型別的緩衝區分配,由於測試用例使用的是堆外記憶體,

圖2-19 PoolArena的newByteBuf抽象方法

因此重點分析DirectArena的實現:如果沒有開啟使用sun的unsafe,則

圖2-20 DirectArena的newByteBuf方法實現

執行PooledDirectByteBuf的newInstance方法,程式碼如下:

圖2-21 PooledDirectByteBuf的newInstance方法實現

通過RECYCLER的get方法迴圈使用ByteBuf物件,如果是非記憶體池實現,則直接建立一個新的ByteBuf物件。從緩衝池中獲取ByteBuf之後,呼叫AbstractReferenceCountedByteBuf的setRefCnt方法設定引用計數器,用於物件的引用計數和記憶體回收(類似JVM垃圾回收機制)。

2.2.4. 高效的Reactor執行緒模型

常用的Reactor執行緒模型有三種,分別如下:

1) Reactor單執行緒模型;

2) Reactor多執行緒模型;

3) 主從Reactor多執行緒模型

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

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

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

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

4) 向通訊對端傳送訊息請求或者應答訊息。

Reactor單執行緒模型示意圖如下所示:

圖2-22 Reactor單執行緒模型

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

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

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

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

3) 可靠性問題:一旦NIO執行緒意外跑飛,或者進入死迴圈,會導致整個系統通訊模組不可用,不能接收和處理外部訊息,造成節點故障。

為了解決這些問題,演進出了Reactor多執行緒模型,下面我們一起學習下Reactor多執行緒模型。

Rector多執行緒模型與單執行緒模型最大的區別就是有一組NIO執行緒處理IO操作,它的原理圖如下:

圖2-23 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操作。

它的執行緒模型如下圖所示:

圖2-24 Reactor主從多執行緒模型

利用主從NIO執行緒模型,可以解決1個服務端監聽執行緒無法有效處理所有客戶端連線的效能不足問題。因此,在Netty的官方demo中,推薦使用該執行緒模型。

事實上,Netty的執行緒模型並非固定不變,通過在啟動輔助類中建立不同的EventLoopGroup例項並通過適當的引數配置,就可以支援上述三種Reactor執行緒模型。正是因為Netty 對Reactor執行緒模型的支援提供了靈活的定製能力,所以可以滿足不同業務場景的效能訴求。

2.2.5. 無鎖化的序列設計理念

在大多數場景下,並行多執行緒處理可以提升系統的併發效能。但是,如果對於共享資源的併發訪問處理不當,會帶來嚴重的鎖競爭,這最終會導致效能的下降。為了儘可能的避免鎖競爭帶來的效能損耗,可以通過序列化設計,即訊息的處理儘可能在同一個執行緒內完成,期間不進行執行緒切換,這樣就避免了多執行緒競爭和同步鎖。

為了儘可能提升效能,Netty採用了序列無鎖化設計,在IO執行緒內部進行序列操作,避免多執行緒競爭導致的效能下降。表面上看,序列化設計似乎CPU利用率不高,併發程度不夠。但是,通過調整NIO執行緒池的執行緒引數,可以同時啟動多個序列化的執行緒並行執行,這種區域性無鎖化的序列執行緒設計相比一個佇列-多個工作執行緒模型效能更優。

Netty的序列化設計工作原理圖如下:

圖2-25 Netty序列化工作原理圖

Netty的NioEventLoop讀取到訊息之後,直接呼叫ChannelPipeline的fireChannelRead(Object msg),只要使用者不主動切換執行緒,一直會由NioEventLoop呼叫到使用者的Handler,期間不進行執行緒切換,這種序列化處理方式避免了多執行緒操作導致的鎖的競爭,從效能角度看是最優的。

2.2.6. 高效的併發程式設計

Netty的高效併發程式設計主要體現在如下幾點:

1) volatile的大量、正確使用;

2) CAS和原子類的廣泛使用;

3) 執行緒安全容器的使用;

4) 通過讀寫鎖提升併發效能。

如果大家想了解Netty高效併發程式設計的細節,可以閱讀之前我在微博分享的《多執行緒併發程式設計在 Netty 中的應用分析》,在這篇文章中對Netty的多執行緒技巧和應用進行了詳細的介紹和分析。

2.2.7. 高效能的序列化框架

影響序列化效能的關鍵因素總結如下:

1) 序列化後的碼流大小(網路頻寬的佔用);

2) 序列化&反序列化的效能(CPU資源佔用);

3) 是否支援跨語言(異構系