天天看點

Java異步NIO架構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。當client的并發壓力或者網絡時延增大之後。同步堵塞IO會因為頻繁的wait導緻IO線程常常性的堵塞。因為線程無法高效的工作,IO處理能力自然下降。

以下,我們通過BIO通信模型圖看下BIO通信的弊端:

Java異步NIO架構Netty實作高性能高并發

圖2-1 BIO通信模型圖

採用BIO通信模型的服務端,通常由一個獨立的Acceptor線程負責監聽client的連接配接。接收到client連接配接之後為client連接配接建立一個新的線程處理請求消息,處理完畢之後,傳回應答消息給client,線程銷毀,這就是典型的一請求一應答模型。該架構最大的問題就是不具備彈性伸縮能力,當并發訪問量添加後,服務端的線程個數和并發訪問數成線性正比,因為線程是JAVA虛拟機很寶貴的系統資源,當線程數膨脹之後,系統的性能急劇下降。随着并發量的繼續添加。可能會發生句柄溢出、線程堆棧溢出等問題,并導緻server終于當機。

序列化方式問題: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線程模型的不同,對性能的影響也很大。

Java異步NIO架構Netty實作高性能高并發

圖2-2 RPC調用性能三要素

2.2. Netty高性能之道

2.2.1. 異步非堵塞通信

在IO程式設計過程中,當須要同一時候處理多個client接入請求時,能夠利用多線程或者IO多路複用技術進行處理。IO多路複用技術通過把多個IO的堵塞複用到同一個select的堵塞上,進而使得系統在單線程的情況下能夠同一時候處理多個client請求。

與傳統的多線程/多程序模型比,I/O多路複用的最大優勢是系統開銷小。系統不須要建立新的額外程序或者線程。也不須要維護這些程序和線程的執行,減少了系統的維護工作量,節省了系統資源。

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

JDK NIO通信模型例如以下所看到的:

Java異步NIO架構Netty實作高性能高并發

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

與Socket類和ServerSocket類相相應,NIO也提供了SocketChannel和ServerSocketChannel兩種不同的套接字通道實作。

這兩種新增的通道都支援堵塞和非堵塞兩種模式。

堵塞模式使用很easy,可是性能和可靠性都不好。非堵塞模式正好相反。開發者一般能夠依據自己的須要來選擇合适的模式,一般來說。低負載、低并發的應用程式能夠選擇同步堵塞IO以減少程式設計複雜度。可是對于高負載、高并發的網絡應用,須要使用NIO的非堵塞模式進行開發。

Netty架構依照Reactor模式設計和實作。它的服務端通信序列圖例如以下:

Java異步NIO架構Netty實作高性能高并發

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

client通信序列圖例如以下:

Java異步NIO架構Netty實作高性能高并發

圖2-4 NIOclient通信序列圖

Netty的IO線程NioEventLoop因為聚合了多路複用器Selector,能夠同一時候并發處理成百上千個clientChannel,因為讀寫操作都是非堵塞的。這就能夠充分提升IO線程的執行效率。避免因為頻繁IO堵塞導緻的線程挂起。

另外,因為Netty採用了異步通信模式,一個IO線程能夠并發處理N個client連接配接和讀寫操作,這從根本上攻克了傳統同步堵塞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的建立:

Java異步NIO架構Netty實作高性能高并發

圖2-5 異步消息讀取“零拷貝”

每循環讀取一次消息。就通過ByteBufAllocator的ioBuffer方法擷取ByteBuf對象,以下繼續看它的接口定義:

Java異步NIO架構Netty實作高性能高并發

圖2-6 ByteBufAllocator 通過ioBuffer配置設定堆外記憶體

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

以下我們繼續看另外一種“零拷貝”的實作CompositeByteBuf,它對外将多個ByteBuf封裝成一個ByteBuf。對外提供統一封裝後的ByteBuf接口,它的類定義例如以下:

Java異步NIO架構Netty實作高性能高并發

圖2-7 CompositeByteBuf類繼承關系

通過繼承關系我們能夠看出CompositeByteBuf實際就是個ByteBuf的包裝器。它将多個ByteBuf組合成一個集合,然後對外提供統一的ByteBuf接口,相關定義例如以下:

Java異步NIO架構Netty實作高性能高并發

圖2-8 CompositeByteBuf類定義

加入ByteBuf,不須要做記憶體拷貝。相關代碼例如以下:

Java異步NIO架構Netty實作高性能高并發

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

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

Java異步NIO架構Netty實作高性能高并發

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

Netty檔案傳輸DefaultFileRegion通過transferTo方法将檔案發送到目标Channel中。以下重點看FileChannel的transferTo方法。它的API DOC說明例如以下:

Java異步NIO架構Netty實作高性能高并發

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

對于非常多作業系統它直接将檔案緩沖區的内容發送到目标Channel中,而不須要通過拷貝的方式,這是一種更加高效的傳輸方式。它實作了檔案傳輸的“零拷貝”。

2.2.3. 記憶體池

随着JVM虛拟機和JIT即時編譯技術的發展,對象的配置設定和回收是個很輕量級的工作。

可是對于緩沖區Buffer,情況卻稍有不同,特别是對于堆外直接記憶體的配置設定和回收。是一件耗時的操作。為了盡量重用緩沖區,Netty提供了基于記憶體池的緩沖區重用機制。以下我們一起看下Netty ByteBuf的實作:

Java異步NIO架構Netty實作高性能高并發

圖2-12 記憶體池ByteBuf

Netty提供了多種記憶體管理政策,通過在啟動輔助類中配置相關參數。能夠實作差異化的定制。

以下通過性能測試,我們看下基于記憶體池循環利用的ByteBuf和普通ByteBuf的性能差異。

用例一,使用記憶體池配置設定器建立直接記憶體緩沖區:

Java異步NIO架構Netty實作高性能高并發

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

用例二,使用非堆記憶體配置設定器建立的直接記憶體緩沖區:

Java異步NIO架構Netty實作高性能高并發

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

各運作300萬次,性能對照結果例如以下所看到的:

Java異步NIO架構Netty實作高性能高并發

圖2-15 記憶體池和非記憶體池緩沖區寫入性能對照

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

以下我們一起簡單分析下Netty記憶體池的記憶體配置設定:

Java異步NIO架構Netty實作高性能高并發

圖2-16 AbstractByteBufAllocator的緩沖區配置設定

繼續看newDirectBuffer方法,我們發現它是一個抽象方法。由AbstractByteBufAllocator的子類負責詳細實作,代碼例如以下:

Java異步NIO架構Netty實作高性能高并發

圖2-17 newDirectBuffer的不同實作

代碼跳轉到PooledByteBufAllocator的newDirectBuffer方法,從Cache中擷取記憶體區域PoolArena。調用它的allocate方法進行記憶體配置設定:

Java異步NIO架構Netty實作高性能高并發

圖2-18 PooledByteBufAllocator的記憶體配置設定

PoolArena的allocate方法例如以下:

Java異步NIO架構Netty實作高性能高并發

圖2-18 PoolArena的緩沖區配置設定

我們重點分析newByteBuf的實作,它相同是個抽象方法,由子類DirectArena和HeapArena來實作不同類型的緩沖區配置設定,因為測試用例使用的是堆外記憶體,

Java異步NIO架構Netty實作高性能高并發

圖2-19 PoolArena的newByteBuf抽象方法

是以重點分析DirectArena的實作:假設沒有開啟使用sun的unsafe,則

Java異步NIO架構Netty實作高性能高并發

圖2-20 DirectArena的newByteBuf方法實作

運作PooledDirectByteBuf的newInstance方法,代碼例如以下:

Java異步NIO架構Netty實作高性能高并發

圖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服務端。接收client的TCP連接配接;

2) 作為NIOclient,向服務端發起TCP連接配接;

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

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

Reactor單線程模型示意圖例如以下所看到的:

Java異步NIO架構Netty實作高性能高并發

圖2-22 Reactor單線程模型

因為Reactor模式使用的是異步非堵塞IO,全部的IO操作都不會導緻堵塞。理論上一個線程能夠獨立處理全部IO相關的操作。從架構層面看。一個NIO線程确實能夠完畢其承擔的職責。

比如。通過Acceptor接收client的TCP連接配接請求消息,鍊路建立成功之後,通過Dispatch将相應的ByteBuffer派發到指定的Handler上進行消息解碼。使用者Handler能夠通過NIO線程将消息發送給client。

對于一些小容量應用場景,能夠使用單線程模型。可是對于高負載、大并發的應用卻不合适,主要原因例如以下:

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

2) 當NIO線程負載過重之後。處理速度将變慢,這會導緻大量client連接配接逾時,逾時之後往往會進行重發,這更加重了NIO線程的負載,終于會導緻大量消息積壓和處理逾時,NIO線程會成為系統的性能瓶頸。

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

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

Rector多線程模型與單線程模型最大的差别就是有一組NIO線程處理IO操作。它的原理圖例如以下:

Java異步NIO架構Netty實作高性能高并發

圖2-23 Reactor多線程模型

Reactor多線程模型的特點:

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

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

3) 1個NIO線程能夠同一時候處理N條鍊路,可是1個鍊路僅僅相應1個NIO線程。防止發生并發操作問題。

在絕大多數場景下,Reactor多線程模型都能夠滿足性能需求;可是,在極特殊應用場景中。一個NIO線程負責監聽和處理全部的client連接配接可能會存在性能問題。比如百萬client并發連接配接,或者服務端須要對client的握手消息進行安全認證,認證本身很損耗性能。在這類場景下。單獨一個Acceptor線程可能會存在性能不足問題。為了解決性能問題。産生了第三種Reactor線程模型-主從Reactor多線程模型。

主從Reactor線程模型的特點是:服務端用于接收client連接配接的不再是個1個單獨的NIO線程。而是一個獨立的NIO線程池。

Acceptor接收到clientTCP連接配接請求處理完畢後(可能包括接入認證等),将新建立的SocketChannel注冊到IO線程池(sub reactor線程池)的某個IO線程上,由它負責SocketChannel的讀寫和編解碼工作。Acceptor線程池隻隻用于client的登陸、握手和安全認證,一旦鍊路建立成功,就将鍊路注冊到後端subReactor線程池的IO線程上,由IO線程負責興許的IO操作。

它的線程模型例如以下圖所看到的:

Java異步NIO架構Netty實作高性能高并發

圖2-24 Reactor主從多線程模型

利用主從NIO線程模型,能夠解決1個服務端監聽線程無法有效處理全部client連接配接的性能不足問題。

是以,在Netty的官方demo中,推薦使用該線程模型。

其實,Netty的線程模型并不是固定不變。通過在啟動輔助類中建立不同的EventLoopGroup執行個體并通過适當的參數配置,就能夠支援上述三種Reactor線程模型。正是由于Netty 對Reactor線程模型的支援提供了靈活的定制能力,是以能夠滿足不同業務場景的性能訴求。

2.2.5. 無鎖化的串行設計理念

在大多數場景下。并行多線程處理能夠提升系統的并發性能。

可是,假設對于共享資源的并發訪問處理不當,會帶來嚴重的鎖競争,這終于會導緻性能的下降。

為了盡可能的避免鎖競争帶來的性能損耗,能夠通過串行化設計,即消息的處理盡可能在同一個線程内完畢。期間不進行線程切換,這樣就避免了多線程競争和同步鎖。

為了盡可能提升性能。Netty採用了串行無鎖化設計,在IO線程内部進行串行操作,避免多線程競争導緻的性能下降。表面上看,串行化設計似乎CPU使用率不高。并發程度不夠。

可是。通過調整NIO線程池的線程參數,能夠同一時候啟動多個串行化的線程并行執行,這樣的局部無鎖化的串行線程設計相比一個隊列-多個工作線程模型性能更優。

Netty的串行化設計工作原理圖例如以下:

Java異步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) 是否支援跨語言(異構系統的對接和開發語言切換)。

Netty預設提供了對Google Protobuf的支援,通過擴充Netty的編解碼接口,使用者能夠實作其他的高性能序列化架構,比如Thrift的壓縮二進制編解碼架構。

以下我們一起看下不同序列化&反序列化架構序列化後的位元組數組對照:

Java異步NIO架構Netty實作高性能高并發

圖2-26 各序列化架構序列化碼流大小對照

從上圖能夠看出。Protobuf序列化後的碼流僅僅有Java序列化的1/4左右。

正是因為Java原生序列化性能表現太差。才催生出了各種高性能的開源序列化技術和架構(性能差僅僅是當中的一個原因,還有跨語言、IDL定義等其他因素)。

2.2.8. 靈活的TCP參數配置能力

合理設定TCP參數在某些場景下對于性能的提升能夠起到顯著的效果,比如SO_RCVBUF和SO_SNDBUF。假設設定不當。對性能的影響是很大的。

以下我們總結下對性能影響比較大的幾個配置項:

1) SO_RCVBUF和SO_SNDBUF:通常建議值為128K或者256K。

2) SO_TCPNODELAY:NAGLE算法通過将緩沖區内的小封包自己主動相連。組成較大的封包。阻止大量小封包的發送堵塞網絡,進而提高網絡應用效率。可是對于時延敏感的應用場景須要關閉該優化算法;

3) 軟中斷:假設Linux核心版本号支援RPS(2.6.35以上版本号)。開啟RPS後能夠實作軟中斷,提升網絡吞吐量。RPS依據資料包的源位址,目的位址以及目的和源port,計算出一個hash值,然後依據這個hash值來選擇軟中斷執行的cpu,從上層來看。也就是說将每一個連接配接和cpu綁定,并通過這個hash值,來均衡軟中斷在多個cpu上,提升網絡并行處理性能。

Netty在啟動輔助類中能夠靈活的配置TCP參數。滿足不同的使用者場景。相關配置接口定義例如以下:

Java異步NIO架構Netty實作高性能高并發

圖2-27 Netty的TCP參數配置定義

2.3. 總結

通過對Netty的架構和性能模型進行分析,我們發現Netty架構的高性能是被精心設計和實作的,得益于高品質的架構和代碼,Netty支援10W TPS的跨節點服務調用并非件十分困難的事情。