從 Spring Boot 程序啟動深入理解 Netty 異步架構原理

迎關注我的頭條號:Wooola,10年Java軟件開發及架構設計經驗,專注於Java、Go語言、微服務架構,致力於每天分享原創文章、快樂編碼和開源技術。

對於高性能的 RPC 框架,Netty 作為異步通信框架,幾乎成為必備品。例如,Dubbo 框架中通信組件,還有 RocketMQ 中生產者和消費者的通信,都使用了 Netty。今天,我們來看看 Netty 的基本架構和原理。

從 Spring Boot 程序啟動深入理解 Netty 異步架構原理

Netty 的特點與 NIO

Netty 是一個異步的、基於事件驅動的網絡應用框架,它可以用來開發高性能服務端和客戶端。

以前編寫網絡調用程序的時候,我們都會在客戶端創建一個 Socket,通過這個 Socket 連接到服務端。

服務端根據這個 Socket 創建一個 Thread,用來發出請求。客戶端在發起調用以後,需要等待服務端處理完成,才能繼續後面的操作。這樣線程會出現等待的狀態。

如果客戶端請求數越多,服務端創建的處理線程也會越多,JVM 如此多的線程並不是一件容易的事。

從 Spring Boot 程序啟動深入理解 Netty 異步架構原理

使用阻塞 I/O 處理多個連接

為了解決上述的問題,推出了 NIO 的概念,也就是(Non-blocking I/O)。其中,Selector 機制就是 NIO 的核心。

當每次客戶端請求時,會創建一個 Socket Channel,並將其註冊到 Selector 上(多路複用器)。

然後,Selector 關注服務端 IO 讀寫事件,此時客戶端並不用等待 IO 事件完成,可以繼續做接下來的工作。

一旦,服務端完成了 IO 讀寫操作,Selector 會接到通知,同時告訴客戶端 IO 操作已經完成。

接到通知的客戶端,就可以通過 SocketChannel 獲取需要的數據了。

從 Spring Boot 程序啟動深入理解 Netty 異步架構原理

NIO 機制與 Selector

上面描述的過程有點異步的意思,不過,Selector 實現的並不是真正意義上的異步操作。

因為 Selector 需要通過線程阻塞的方式監聽 IO 事件變更,只是這種方式沒有讓客戶端等待,是 Selector 在等待 IO 返回,並且通知客戶端去獲取數據。真正“異步 IO”(AIO)這裡不展開介紹,有興趣可以自行查找。

說好了 NIO 再來談談 Netty,Netty 作為 NIO 的實現,它適用於服務器/客戶端通訊的場景,以及針對於 TCP 協議下的高併發應用。

對於開發者來說,它具有以下特點:

  • 對 NIO 進行封裝,開發者不需要關注 NIO 的底層原理,只需要調用 Netty 組件就能夠完成工作。
  • 對網絡調用透明,從 Socket 建立 TCP 連接到網絡異常的處理都做了包裝。
  • 對數據處理靈活, Netty 支持多種序列化框架,通過“ChannelHandler”機制,可以自定義“編/解碼器”。
  • 對性能調優友好,Netty 提供了線程池模式以及 Buffer 的重用機制(對象池化),不需要構建複雜的多線程模型和操作隊列。

從一個簡單的例子開始

開篇講到了,為了滿足高併發下網絡請求,引入了 NIO 的概念。Netty 是針對 NIO 的實現,在 NIO 封裝,網絡調用,數據處理以及性能優化等方面都有不俗的表現。

學習架構最容易的方式就是從實例入手,從客戶端訪問服務端的代碼來看看 Netty 是如何運作的。再一次介紹代碼中調用的組件以及組件的工作原理。

假設有一個客戶端去調用一個服務端,假設服務端叫做 EchoServer,客戶端叫做 EchoClient,用 Netty 架構實現代碼如下。

服務端代碼

構建服務器端,假設服務器接受客戶端傳來的信息,然後在控制檯打印。首先,生成 EchoServer,在構造函數中傳入需要監聽的端口號。

從 Spring Boot 程序啟動深入理解 Netty 異步架構原理

構造函數中傳入需要監聽的端口號

接下來就是服務的啟動方法:

從 Spring Boot 程序啟動深入理解 Netty 異步架構原理

啟動 NettyServer 的 Start 方法

Server 的啟動方法涉及到了一些組件的調用,例如 EventLoopGroup,Channel。這些會在後面詳細講解。

這裡有個大致的印象就好:

  • 創建 EventLoopGroup。
  • 創建 ServerBootstrap。
  • 指定所使用的 NIO 傳輸 Channel。
  • 使用指定的端口設置套接字地址。
  • 添加一個 ServerHandler 到 Channel 的 ChannelPipeline。
  • 異步地綁定服務器;調用 sync() 方法阻塞等待直到綁定完成。
  • 獲取 Channel 的 CloseFuture,並且阻塞當前線程直到它完成。
  • 關閉 EventLoopGroup,釋放所有的資源。

NettyServer 啟動以後會監聽某個端口的請求,當接受到了請求就需要處理了。在 Netty 中客戶端請求服務端,被稱為“入站”操作。

可以通過 ChannelInboundHandlerAdapter 實現,具體內容如下:

從 Spring Boot 程序啟動深入理解 Netty 異步架構原理

處理來自客戶端的請求


從上面的代碼可以看出,服務端處理的代碼包含了三個方法。這三個方法都是根據事件觸發的。

他們分別是:

  • 當接收到消息時的操作,channelRead。
  • 消息讀取完成時的方法,channelReadComplete。
  • 出現異常時的方法,exceptionCaught。

客戶端代碼

客戶端和服務端的代碼基本相似,在初始化時需要輸入服務端的 IP 和 Port。

從 Spring Boot 程序啟動深入理解 Netty 異步架構原理

同樣在客戶端啟動函數中包括以下內容:

從 Spring Boot 程序啟動深入理解 Netty 異步架構原理

客戶端啟動程序的順序:

  • 創建 Bootstrap。
  • 指定 EventLoopGroup 用來監聽事件。
  • 定義 Channel 的傳輸模式為 NIO(Non-BlockingInputOutput)。
  • 設置服務器的 InetSocketAddress。
  • 在創建 Channel 時,向 ChannelPipeline 中添加一個 EchoClientHandler 實例。
  • 連接到遠程節點,阻塞等待直到連接完成。
  • 阻塞,直到 Channel 關閉。
  • 關閉線程池並且釋放所有的資源。

客戶端在完成以上操作以後,會與服務端建立連接從而傳輸數據。同樣在接受到 Channel 中觸發的事件時,客戶端會觸發對應事件的操作。

從 Spring Boot 程序啟動深入理解 Netty 異步架構原理

例如 Channel 激活,客戶端接受到服務端的消息,或者發生異常的捕獲。

從代碼結構上看還是比較簡單的。服務端和客戶端分別初始化創建監聽和連接。然後分別定義各自的 Handler 處理對方的請求。

從 Spring Boot 程序啟動深入理解 Netty 異步架構原理

服務端/客戶端初始化和事件處理

Netty 核心組件

通過上面的簡單例子,發現有些 Netty 組件在服務初始化以及通訊時被用到,下面就來介紹一下這些組件的用途和關係。

①Channel

通過上面例子可以看出,當客戶端和服務端連接的時候會建立一個 Channel。

這個 Channel 我們可以理解為 Socket 連接,它負責基本的 IO 操作,例如:bind(),connect(),read(),write() 等等。

簡單的說,Channel 就是代表連接,實體之間的連接,程序之間的連接,文件之間的連接,設備之間的連接。同時它也是數據入站和出站的載體。

②EventLoop 和 EventLoopGroup

既然有了 Channel 連接服務,讓信息之間可以流動。如果服務發出的消息稱作“出站”消息,服務接受的消息稱作“入站”消息。那麼消息的“出站”/“入站”就會產生事件(Event)。

例如:連接已激活;數據讀取;用戶事件;異常事件;打開鏈接;關閉鏈接等等。

順著這個思路往下想,有了數據,數據的流動產生事件,那麼就有一個機制去監控和協調事件。

這個機制(組件)就是 EventLoop。在 Netty 中每個 Channel 都會被分配到一個 EventLoop。一個 EventLoop 可以服務於多個 Channel。

每個 EventLoop 會佔用一個 Thread,同時這個 Thread 會處理 EventLoop 上面發生的所有 IO 操作和事件(Netty 4.0)。

從 Spring Boot 程序啟動深入理解 Netty 異步架構原理

EventLoop 與 Channel 關係

理解了 EventLoop,再來說 EventLoopGroup 就容易了,EventLoopGroup 是用來生成 EventLoop 的,還記得例子代碼中第一行就 new 了 EventLoopGroup 對象。

一個 EventLoopGroup 中包含了多個 EventLoop 對象。

從 Spring Boot 程序啟動深入理解 Netty 異步架構原理

創建 EventLoopGroup

EventLoopGroup 要做的就是創建一個新的 Channel,並且給它分配一個 EventLoop。

從 Spring Boot 程序啟動深入理解 Netty 異步架構原理

EventLoopGroup,EventLoop 和 Channel 的關係

在異步傳輸的情況下,一個 EventLoop 是可以處理多個 Channel 中產生的事件的,它主要的工作就是事件的發現以及通知。

相對於以前一個 Channel 就佔用一個 Thread 的情況。Netty 的方式就要合理多了。

客戶端發送消息到服務端,EventLoop 發現以後會告訴服務端:“你去獲取消息”,同時客戶端進行其他的工作。

當 EventLoop 檢測到服務端返回的消息,也會通知客戶端:“消息返回了,你去取吧“。客戶端再去獲取消息。整個過程 EventLoop 就是監視器+傳聲筒。

③ChannelHandler,ChannelPipeline 和 ChannelHandlerContext

如果說 EventLoop 是事件的通知者,那麼 ChannelHandler 就是事件的處理者。

在 ChannelHandler 中可以添加一些業務代碼,例如數據轉換,邏輯運算等等。

正如上面例子中展示的,Server 和 Client 分別都有一個 ChannelHandler 來處理,讀取信息,網絡可用,網絡異常之類的信息。

並且,針對出站和入站的事件,有不同的 ChannelHandler,分別是:

  • ChannelInBoundHandler(入站事件處理器)
  • ChannelOutBoundHandler(出站事件處理器)
從 Spring Boot 程序啟動深入理解 Netty 異步架構原理

假設每次請求都會觸發事件,而由 ChannelHandler 來處理這些事件,這個事件的處理順序是由 ChannelPipeline 來決定的。

從 Spring Boot 程序啟動深入理解 Netty 異步架構原理

ChannelHanlder 處理,出站/入站的事件

ChannelPipeline 為 ChannelHandler 鏈提供了容器。到 Channel 被創建的時候,會被 Netty 框架自動分配到 ChannelPipeline 上。

ChannelPipeline 保證 ChannelHandler 按照一定順序處理事件,當事件觸發以後,會將數據通過 ChannelPipeline 按照一定的順序通過 ChannelHandler。

說白了,ChannelPipeline 是負責“排隊”的。這裡的“排隊”是處理事件的順序。

同時,ChannelPipeline 也可以添加或者刪除 ChannelHandler,管理整個隊列。

從 Spring Boot 程序啟動深入理解 Netty 異步架構原理

如上圖,ChannelPipeline 使 ChannelHandler 按照先後順序排列,信息按照箭頭所示方向流動並且被 ChannelHandler 處理。

說完了 ChannelPipeline 和 ChannelHandler,前者管理後者的排列順序。那麼它們之間的關聯就由 ChannelHandlerContext 來表示了。

每當有 ChannelHandler 添加到 ChannelPipeline 時,同時會創建 ChannelHandlerContext 。

ChannelHandlerContext 的主要功能是管理 ChannelHandler 和 ChannelPipeline 的交互。

不知道大家注意到沒有,開始的例子中 ChannelHandler 中處理事件函數,傳入的參數就是 ChannelHandlerContext。

從 Spring Boot 程序啟動深入理解 Netty 異步架構原理

ChannelHandlerContext 參數貫穿 ChannelPipeline,將信息傳遞給每個 ChannelHandler,是個合格的“通訊員”。

從 Spring Boot 程序啟動深入理解 Netty 異步架構原理

ChannelHandlerContext 負責傳遞消息

把上面提到的幾個核心組件歸納一下,用下圖表示方便記憶他們之間的關係。

從 Spring Boot 程序啟動深入理解 Netty 異步架構原理

Netty 核心組件關係圖

Netty 的數據容器

前面介紹了 Netty 的幾個核心組件,服務器在數據傳輸的時候,產生事件,並且對事件進行監控和處理。

接下來看看數據是如何存放以及是如何讀寫的。Netty 將 ByteBuf 作為數據容器,來存放數據。

ByteBuf 工作原理

從結構上來說,ByteBuf 由一串字節數組構成。數組中每個字節用來存放信息。

ByteBuf 提供了兩個索引,一個用於讀取數據,一個用於寫入數據。這兩個索引通過在字節數組中移動,來定位需要讀或者寫信息的位置。

當從 ByteBuf 讀取,它的 readerIndex(讀索引)將會根據讀取的字節數遞增。

同樣,當寫 ByteBuf 時,它的 writerIndex 也會根據寫入的字節數進行遞增。

從 Spring Boot 程序啟動深入理解 Netty 異步架構原理

ByteBuf 讀寫索引圖例

需要注意的是極限的情況是 readerIndex 剛好讀到了 writerIndex 寫入的地方。

如果 readerIndex 超過了 writerIndex 的時候,Netty 會拋出 IndexOutOf-BoundsException 異常。

ByteBuf 使用模式

談了 ByteBuf 的工作原理以後,再來看看它的使用模式。

根據存放緩衝區的不同分為三類:

  • 堆緩衝區,ByteBuf 將數據存儲在 JVM 的堆中,通過數組實現,可以做到快速分配。由於在堆上被 JVM 管理,在不被使用時可以快速釋放。可以通過 ByteBuf.array() 來獲取 byte[] 數據。
  • 直接緩衝區,在 JVM 的堆之外直接分配內存,用來存儲數據。其不佔用堆空間,使用時需要考慮內存容量。它在使用 Socket 傳遞時性能較好,因為間接從緩衝區發送數據,在發送之前 JVM 會先將數據複製到直接緩衝區再進行發送。由於,直接緩衝區的數據分配在堆之外,通過 JVM 進行垃圾回收,並且分配時也需要做複製的操作,因此使用成本較高。
  • 複合緩衝區,顧名思義就是將上述兩類緩衝區聚合在一起。Netty 提供了一個 CompsiteByteBuf,可以將堆緩衝區和直接緩衝區的數據放在一起,讓使用更加方便。

ByteBuf 的分配

聊完了結構和使用模式,再來看看 ByteBuf 是如何分配緩衝區的數據的。

Netty 提供了兩種 ByteBufAllocator 的實現,他們分別是:

  • PooledByteBufAllocator,實現了 ByteBuf 的對象的池化,提高性能減少內存碎片。
  • Unpooled-ByteBufAllocator,沒有實現對象的池化,每次會生成新的對象實例。

對象池化的技術和線程池,比較相似,主要目的是提高內存的使用率。池化的簡單實現思路,是在 JVM 堆內存上構建一層內存池,通過 allocate 方法獲取內存池中的空間,通過 release 方法將空間歸還給內存池。

對象的生成和銷燬,會大量地調用 allocate 和 release 方法,因此內存池面臨碎片空間回收的問題,在頻繁申請和釋放空間後,內存池需要保證連續的內存空間,用於對象的分配。

基於這個需求,有兩種算法用於優化這一塊的內存分配:夥伴系統和 slab 系統。

夥伴系統,用完全二叉樹管理內存區域,左右節點互為夥伴,每個節點代表一個內存塊。內存分配將大塊內存不斷二分,直到找到滿足所需的最小內存分片。

內存釋放會判斷釋放內存分片的夥伴(左右節點)是否空閒,如果空閒則將左右節點合成更大塊內存。

slab 系統,主要解決內存碎片問題,將大塊內存按照一定內存大小進行等分,形成相等大小的內存片構成的內存集。

按照內存申請空間的大小,申請儘量小塊內存或者其整數倍的內存,釋放內存時,也是將內存分片歸還給內存集。

Netty 內存池管理以 Allocate 對象的形式出現。一個 Allocate 對象由多個 Arena 組成,每個 Arena 能執行內存塊的分配和回收。

Arena 內有三類內存塊管理單元:

  • TinySubPage
  • SmallSubPage
  • ChunkList

Tiny 和 Small 符合 Slab 系統的管理策略,ChunkList 符合夥伴系統的管理策略。

當用戶申請內存介於 tinySize 和 smallSize 之間時,從 tinySubPage 中獲取內存塊。

申請內存介於 smallSize 和 pageSize 之間時,從 smallSubPage 中獲取內存塊;介於 pageSize 和 chunkSize 之間時,從 ChunkList 中獲取內存;大於 ChunkSize(不知道分配內存的大小)的內存塊不通過池化分配。

Netty 的 Bootstrap

說完了 Netty 的核心組件以及數據存儲。再回到最開始的例子程序,在程序最開始的時候會 new 一個 Bootstrap 對象,後面所有的配置都是基於這個對象展開的。

從 Spring Boot 程序啟動深入理解 Netty 異步架構原理

生成 Bootstrap 對象


Bootstrap 的作用就是將 Netty 核心組件配置到程序中,並且讓他們運行起來。

從 Bootstrap 的繼承結構來看,分為兩類分別是 Bootstrap 和 ServerBootstrap,一個對應客戶端的引導,另一個對應服務端的引導。

從 Spring Boot 程序啟動深入理解 Netty 異步架構原理

支持客戶端和服務端的程序引導

客戶端引導 Bootstrap,主要有兩個方法 bind() 和 connect()。Bootstrap 通過 bind() 方法創建一個 Channel。

在 bind() 之後,通過調用 connect() 方法來創建 Channel 連接。

從 Spring Boot 程序啟動深入理解 Netty 異步架構原理

Bootstrap 通過 bind 和 connect 方法創建連接

服務端引導 ServerBootstrap,與客戶端不同的是在 Bind() 方法之後會創建一個 ServerChannel,它不僅會創建新的 Channel 還會管理已經存在的 Channel。

從 Spring Boot 程序啟動深入理解 Netty 異步架構原理

ServerBootstrap 通過 bind 方法創建/管理連接

通過上面的描述,服務端和客戶端的引導存在兩個區別:

  • ServerBootstrap(服務端引導)綁定一個端口,用來監聽客戶端的連接請求。而 Bootstrap(客戶端引導)只要知道服務端 IP 和 Port 建立連接就可以了。
  • Bootstrap(客戶端引導)需要一個 EventLoopGroup,但是 ServerBootstrap(服務端引導)則需要兩個 EventLoopGroup。因為服務器需要兩組不同的 Channel。第一組 ServerChannel 自身監聽本地端口的套接字。第二組用來監聽客戶端請求的套接字。
從 Spring Boot 程序啟動深入理解 Netty 異步架構原理

ServerBootstrap 有兩組 EventLoopGroup


總結

我們從 NIO 入手,談到了 Selector 的核心機制。然後通過介紹 Netty 客戶端和服務端源代碼運行流程,讓大家對 Netty 編寫代碼有基本的認識。

在 Netty 的核心組件中,Channel 提供 Socket 的連接通道,EventLoop 會對應 Channel 監聽其產生的事件,並且通知執行者。EventloopGroup 的容器,負責生成和管理 EventLoop。

ChannelPipeline 作為 ChannelHandler 的容器會綁定到 Channel 上,然後由 ChannelHandler 提供具體事件處理。另外,ChannelHandlerContext 為 ChannelHandler 和 ChannelPipeline 提供信息共享。

ByteBuf 作為 Netty 的數據容器,通過字節數組的方式存儲數據,並且通過讀索引和寫索引來引導讀寫操作。

上述的核心組件都是通過 Bootstrap 來配置並且引導啟動的,Bootstrap 啟動方式雖然一致,但是針對客戶端和服務端有些許的區別。


來源:https://mp.weixin.qq.com/s/Sosyv2pRrB8ry471mk5w6g

原文《高性能底層怎麼運作?一文幫你吃透Netty架構原理》

侵刪


分享到:


相關文章: