1. Netty 基礎
Netty 是一個高性能、異步事件驅動的 NIO 框架,它提供了對 TCP、UDP 和文件傳輸的支持,作爲一個異步 NIO 框架,Netty 的所有 IO 操作都是異步非阻塞的,通過 Future-Listener 機制,用戶可以方便的主動獲取或者通過通知機制獲得 IO 操作結果。它是一個網絡應用框架。
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 序列化存在如下幾個典型問題:
- Java 序列化機制是 Java 內部的一種對象編解碼技術,無法跨語言使用;例如對於異構系統之間的對接,Java 序列化後的碼流需要能夠通過其它語言反序列化成原始對象(副本),目前很難支持;
- 相比於其它開源的序列化框架,Java 序列化後的碼流太大,無論是網絡傳輸還是持久化到磁盤,都會導致額外的資源佔用;
- 序列化性能差(CPU 資源佔用高)。
線程模型問題:由於採用同步阻塞 IO,這會導致每個 TCP 連接都佔用1個線程,由於線程資源是 JVM 虛擬機非常寶貴的資源,當 IO 讀寫阻塞導致線程無法及時釋放時,會導致系統性能急劇下降,嚴重的甚至會導致虛擬機無法創建新的線程。
2.1.2. 高性能的三個主題
- 傳輸:用什麼樣的通道將數據發送給對方,BIO、NIO 或者 AIO,IO 模型在很大程度上決定了框架的性能。
- 協議:採用什麼樣的通信協議,HTTP 或者內部私有協議。協議的選擇不同,性能模型也不同。相比於公有協議,內部私有協議的性能通常可以被設計的更優。
- 線程:數據報如何讀取?讀取之後的編解碼在哪個線程進行,編解碼後的消息如何派發, 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 的“零拷貝”主要體現在如下三個方面:
- Netty 的接收和發送 ByteBuffer 採用 DIRECT BUFFERS,使用堆外直接內存進行 Socket 讀寫,不需要進行字節緩衝區的二次拷貝。如果使用傳統的堆內存(HEAP BUFFERS)進行 Socket 讀寫,JVM 會將堆內存 Buffer 拷貝一份到直接內存中,然後才寫入 Socket 中。相比於堆外直接內存,消息在發送過程中多了一次緩衝區的內存拷貝。
- Netty 提供了組合 Buffer 對象,可以聚合多個 ByteBuffer 對象,用戶可以像操作一個 Buffer 那樣方便的對組合 Buffer 進行操作,避免了傳統通過內存拷貝的方式將幾個小 Buffer 合併成一個大的 Buffer。
- 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 線程模型有三種,分別如下:
- Reactor 單線程模型;
- Reactor 多線程模型;
- 主從 Reactor 多線程模型(很重點)
Reactor 單線程模型,指的是所有的 IO 操作都在同一個 NIO 線程上面完成,NIO 線程的職責如下:
- 作爲 NIO 服務端,接收客戶端的 TCP 連接;
- 作爲 NIO 客戶端,向服務端發起 TCP 連接;
- 讀取通信對端的請求或者應答消息;
- 向通信對端發送消息請求或者應答消息。
Reactor 單線程模型示意圖如下所示:
圖2-22 Reactor 單線程模型
由於 Reactor 模式使用的是異步非阻塞 IO,所有的 IO 操作都不會導致阻塞,理論上一個線程可以獨立處理所有 IO 相關的操作。從架構層面看,一個 NIO 線程確實可以完成其承擔的職責。例如,通過 Acceptor 接收客戶端的 TCP 連接請求消息,鏈路建立成功之後,通過 Dispatch 將對應的 ByteBuffer 派發到指定的 Handler 上進行消息解碼。用戶 Handler 可以通過 NIO 線程將消息發送給客戶端。
對於一些小容量應用場景,可以使用單線程模型。但是對於高負載、大併發的應用卻不合適,主要原因如下:
- 一個 NIO 線程同時處理成百上千的鏈路,性能上無法支撐,即便 NIO 線程的 CPU 負荷達到100%,也無法滿足海量消息的編碼、解碼、讀取和發送;
- 當 NIO 線程負載過重之後,處理速度將變慢,這會導致大量客戶端連接超時,超時之後往往會進行重發,這更加重了 NIO 線程的負載,最終會導致大量消息積壓和處理超時,NIO 線程會成爲系統的性能瓶頸;
- 可靠性問題:一旦 NIO 線程意外跑飛,或者進入死循環,會導致整個系統通信模塊不可用,不能接收和處理外部消息,造成節點故障。
爲了解決這些問題,演進出了 Reactor 多線程模型,下面我們一起學習下 Reactor 多線程模型。
Rector 多線程模型與單線程模型最大的區別就是有一組 NIO 線程處理 IO 操作,它的原理圖如下:
圖2-23 Reactor 多線程模型
Reactor 多線程模型的特點:
- 有專門一個 NIO 線程-Acceptor 線程用於監聽服務端,接收客戶端的 TCP 連接請求;
- 網絡 IO 操作-讀、寫等由一個 NIO 線程池負責,線程池可以採用標準的 JDK 線程池實現,它包含一個任務隊列和 N 個可用的線程,由這些 NIO 線程負責消息的讀取、解碼、編碼和發送;
- 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 靈活的 TCP 參數配置能力
合理設置 TCP 參數在某些場景下對於性能的提升可以起到顯著的效果,例如 SO_RCVBUF 和 SO_SNDBUF。如果設置不當,對性能的影響是非常大的。下面我們總結下對性能影響比較大的幾個配置項:
- SO_RCVBUF 和 SO_SNDBUF:通常建議值爲 128 K 或者 256 K;
- SO_TCPNODELAY:NAGLE 算法通過將緩衝區內的小封包自動相連,組成較大的封包,阻止大量小封包的發送阻塞網絡,從而提高網絡應用效率。但是對於時延敏感的應用場景需要關閉該優化算法;
- 軟中斷:如果 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 的跨節點服務調用並不是件十分困難的事情。