轉載自http://www.infoq.com/cn/articles/netty-high-performance 1. 背景 1.1. 驚人的性能數據 最近一個圈內朋友通過私信告訴我,通過使用Netty4 + Thrift壓縮二進位編解碼技術,他們實現了10W TPS(1K的複雜POJO對象)的... ...
轉載自http://www.infoq.com/cn/articles/netty-high-performance
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) 是否支持跨語言(異構系統的對接和開發語言切換)。
Netty預設提供了對Google Protobuf的支持,通過擴展Netty的編解碼介面,用戶可以實現其它的高性能序列化框架,例如Thrift的壓縮二進位編解碼框架。
下麵我們一起看下不同序列化&反序列化框架序列化後的位元組數組對比:
圖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根據數據包的源地址,目的地址以及目的和源埠,計算出一個hash值,然後根據這個hash值來選擇軟中斷運行的cpu,從上層來看,也就是說將每個連接和cpu綁定,並通過這個hash值,來均衡軟中斷在多個cpu上,提升網路並行處理性能。
Netty在啟動輔助類中可以靈活的配置TCP參數,滿足不同的用戶場景。相關配置介面定義如下:
圖2-27 Netty的TCP參數配置定義
2.3. 總結
通過對Netty的架構和性能模型進行分析,我們發現Netty架構的高性能是被精心設計和實現的,得益於高質量的架構和代碼,Netty支持10W TPS的跨節點服務調用並不是件十分困難的事情。
3. 作者簡介
李林鋒,2007年畢業於東北大學,2008年進入華為公司從事高性能通信軟體的設計和開發工作,有6年NIO設計和開發經驗,精通Netty、Mina等NIO框架。Netty中國社區創始人,《Netty權威指南》作者。
聯繫方式:新浪微博 Nettying 微信:Nettying