1. 程式人生 > 實用技巧 >Netty 是如何支撐高效能網路通訊的?

Netty 是如何支撐高效能網路通訊的?

作為一個高效能的 NIO 通訊框架,Netty 被廣泛應用於大資料處理、網際網路訊息中介軟體、遊戲和金融行業等。大多數應用場景對底層的通訊框架都有很高的效能要求,作為綜合性能最高的 NIO 框架 之一,Netty 可以完全滿足不同領域對高效能通訊的需求。本文我們將從架構層對 Netty 的高效能設計和關鍵程式碼實現進行剖析,看 Netty 是如何支撐高效能網路通訊的。

RPC 呼叫效能模型分析

傳統 RPC 呼叫效能差的原因

網路傳輸方式問題

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

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

序列化效能差

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

1.Java 序列化機制是 Java 內部的一 種物件編解碼技術,無法跨語言使用。例如對於異構系統之間的對接,Java 序列化後的碼流需要能夠通過其他語言反序列化成原始物件,這很難支援。2.相比於其他開源的序列化框架,Java 序列化後的碼流太大,無論是網路傳輸還是持久化到磁碟,都會導致額外的資源佔用。3.序列化效能差,資源佔用率高( 主要是 CPU 資源佔用高 )。

執行緒模型問題

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

IO 通訊效能三原則

儘管影響 I/O 通訊效能的因素非常多,但是從架構層面看主要有三個要素。

1.傳輸:用什麼樣的通道將資料傳送給對方。可以選擇 BIO、NIO 或者 AIO,I/O 模型 在很大程度上決定了通訊的效能;2.協議:採用什麼樣的通訊協議,HTTP 等公有協議或者內部私有協議。協議的選擇不同,效能也不同。相比於公有協議,內部私有協議的效能通常可以被設計得更優;3.執行緒模型:資料報如何讀取?讀取之後的編解碼在哪個執行緒進行,編解碼後的訊息如何派發,Reactor 執行緒模型的不同,對效能的影響也非常大。

非同步非阻塞通訊

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

JDK1.4 提供了對非阻塞 I/O 的支援,JDK1.5 使用 epoll 替代了傳統的 select / poll,極大地提升了 NIO 通訊 的效能。

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

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

高效的 Reactor 執行緒模型

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

1.Reactor 單執行緒模型;2.Reactor 多執行緒模型;3.主從 Reactor 多執行緒模型。

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

1.作為 NIO 服務端,接收客戶端的 TCP 連線;2.作為 NIO 客戶端,向服務端發起 TCP 連線;3.讀取通訊對端的請求或者應答訊息;4.向通訊對端傳送訊息請求或者應答訊息。

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

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

1.一個 NIO 執行緒 同時處理成百上千的鏈路,效能上無法支撐。即便 NIO 執行緒 的 CPU 負荷 達到 100%,也無法滿足海量訊息的編碼,解碼、讀取和傳送;2.當 NIO 執行緒 負載過重之後,處理速度將變慢,這會導致大量客戶端連線超時,超時之後往往會進行重發,這更加重了 NIO 執行緒 的負載,最終會導致大量訊息積壓和處理超時,NIO 執行緒會成為系統的效能瓶頸;3.可靠性問題。一旦 NIO 執行緒意外跑飛,或者進入死迴圈,會導致整個系統通訊模組不可用,不能接收和處理外部訊息,造成節點故障。

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

Rector 多執行緒模型與單執行緒模型最大的區別就是有一組 NIO 執行緒 處理 I/O 操作,它的特點如下。

1.有一個專門的 NIO 執行緒—— Acceptor 執行緒 用於監聽服務埠,接收客戶端的 TCP 連線請求;2.網路 IO 操作—— 讀、寫等由一個 NIO 執行緒池 負責,執行緒池可以採用標準的 JDK 執行緒池 實現,它包含一個任務佇列和 N 個可用的執行緒,由這些 NIO 執行緒 負責訊息的讀取、解碼、編碼和傳送;3.1 個 NIO 執行緒 可以同時處理 N 條鏈路,但是 1 個鏈路只對應 1 個 NIO 執行緒,以防止發生併發操作問題。

在絕大多數場景下,Reactor 多執行緒模型 都可以滿足效能需求,但是,在極特殊應用場景中,一個 NIO 執行緒負責監聽和處理所有的客戶端連線可能會存在效能問題。例如百萬客戶端併發連線,或者服務端需要對客戶端的握手訊息進行安全認證,認證本身非常損耗效能。在這類場景下,單獨一個 Acceptor 執行緒 可能會存在效能不足問題,為了解決效能問題,產生了第三種 Reactor 執行緒模型 ——主從 Reactor 多執行緒模型。

主從 Reactor 執行緒模型的特點是,服務端用於接收客戶端連線的不再是個單執行緒的連線處理 Acceptor,而是一個獨立的 Acceptor 執行緒池。Acceptor 接收到客戶端 TCP 連線請求 處理完成後 ( 可能包含接入認證等 ),將新建立的 SocketChannel 註冊到 I/O 處理執行緒池 的某個 I/O 執行緒 上,由它負責 SocketChannel 的讀寫和編解碼工作。Acceptor 執行緒池 只用於客戶端的登入、握手和安全認證,一旦鏈路建立成功,就將鏈路註冊到 I/O 處理執行緒池的 I/O 執行緒 上,每個 I/O 執行緒 可以同時監聽 N 個鏈路,對鏈路產生的 IO 事件 進行相應的 訊息讀取、解碼、編碼及訊息傳送等操作。

利用主從 Reactor 執行緒模型,可以解決 1 個 Acceptor 執行緒 無法有效處理所有客戶端連線的效能問題。因此,Netty 官方也推薦使用該執行緒模型。

事實上,Netty 的執行緒模型並非固定不變,通過在啟動輔助類中建立不同的 EventLoopGroup 例項 並進行適當的引數配置,就可以支援上述三種 Reactor 執行緒模型。可以根據業務場景的效能訴求,選擇不同的執行緒模型。

Netty 單執行緒模型服務端程式碼示例如下:

    EventLoopGroup reactor = new NioEventLoopGroup(1);    ServerBootstrap bootstrap = new ServerBootstrap();    bootstrap.group(reactor, reactor)            .channel(NioServerSocketChannel.class)            ......

Netty 多執行緒模型程式碼示例如下:
    EventLoopGroup acceptor = new NioEventLoopGroup(1);    EventLoopGroup ioGroup = new NioEventLoopGroup();    ServerBootstrap bootstrap = new ServerBootstrap();    bootstrap.group(acceptor, ioGroup)            .channel(NioServerSocketChannel.class)            ......

Netty 主從多執行緒模型程式碼示例如下:
    EventLoopGroup acceptorGroup = new NioEventLoopGroup();    EventLoopGroup ioGroup = new NioEventLoopGroup();    ServerBootstrap bootstrap = new ServerBootstrap();    bootstrap.group(acceptorGroup, ioGroup)            .channel(NioServerSocketChannel.class)            ......

無鎖化的序列設計

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

為了儘可能提升效能,Netty 對訊息的處理採用了序列無鎖化設計,在 I/O 執行緒 內部進行序列操作,避免多執行緒競爭導致的效能下降。Netty 的序列化設計工作原理圖如下圖所示。

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

零拷貝

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

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

下面我們繼續看第二種“ 零拷貝 ” 的實現 CompositeByteBuf,它對外將多個 ByteBuf 封裝成一個 ByteBuf,對外提供統一封裝後的 ByteBuf 介面。CompositeByteBuf 實際就是個 ByteBuf 的裝飾器,它將多個 ByteBuf 組合成一個集合,然後對外提供統一的 ByteBuf 介面,新增 ByteBuf,不需要做記憶體拷貝。

第三種“ 零拷貝 ” 就是檔案傳輸,Netty 檔案傳輸類 DefaultFileRegion 通過 transferTo() 方法 將檔案傳送到目標 Channel 中。很多作業系統直接將檔案緩衝區的內容傳送到目標 Channel 中,而不需要通過迴圈拷貝的方式,這是一種更加高效的傳輸方式,提升了傳輸效能,降低了 CPU 和記憶體佔用,實現了檔案傳輸的 “ 零拷貝 ” 。

記憶體池

隨著 JVM 虛擬機器 和 JIT 即時編譯技術 的發展,物件的分配和回收是個非常輕量級的工作。但是對於緩衝區 Buffer,情況卻稍有不同,特別是對於堆外直接記憶體的分配和回收,是一件耗時的操作。為了儘量重用緩衝區,Netty 提供了基於記憶體池的緩衝區重用機制。ByteBuf 的子類中提供了多種 PooledByteBuf 的實現,基於這些實現 Netty 提供了多種記憶體管理策略,通過在啟動輔助類中配置相關引數,可以實現差異化的定製。