前言
對于高性能的 RPC 架構,Netty 作為異步通信架構,幾乎成為必備品。例如,Dubbo 架構中通信元件,還有 RocketMQ 中生産者和消費者的通信,都使用了 Netty。今天,我們來看看 Netty 的基本架構和原理。
Spring Boot實戰學習筆記
Netty 的特點與 NIO
Netty 是一個異步的、基于事件驅動的網絡應用架構,它可以用來開發高性能服務端和用戶端。
以前編寫網絡調用程式的時候,我們都會在用戶端建立一個 Socket,通過這個 Socket 連接配接到服務端。
服務端根據這個 Socket 建立一個 Thread,用來送出請求。用戶端在發起調用以後,需要等待服務端處理完成,才能繼續後面的操作。這樣線程會出現等待的狀态。
如果用戶端請求數越多,服務端建立的處理線程也會越多,JVM 如此多的線程并不是一件容易的事。
![](https://img.laitimes.com/img/9ZDMuAjOiMmIsIjOiQnIsICM0ITMvw1dvwlMvwlM3VWaWV2Zh1WaDdTJwlmc0N3LcRnbllmcv1yb0VXYvwlMyd2bNV2Zh1Wa-cmbw5SO4QDMwUmMlFzN4EzYkVjNtgjMzAzM2QjMvw1cldWYtl2XkF2bsBXdvw1bp5SdoNnbhlmauMXZnFWbp1CZh9GbwV3Lc9CX6MHc0RHaiojIsJye.png)
為了解決上述的問題,推出了 NIO 的概念,也就是(Non-blocking I/O)。其中,Selector 機制就是 NIO 的核心。
當每次用戶端請求時,會建立一個 Socket Channel,并将其注冊到 Selector 上(多路複用器)。
然後,Selector 關注服務端 IO 讀寫事件,此時用戶端并不用等待 IO 事件完成,可以繼續做接下來的工作。
一旦,服務端完成了 IO 讀寫操作,Selector 會接到通知,同時告訴用戶端 IO 操作已經完成。
接到通知的用戶端,就可以通過 SocketChannel 擷取需要的資料了。
上面描述的過程有點異步的意思,不過,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,在構造函數中傳入需要監聽的端口号。
接下來就是服務的啟動方法:
Server 的啟動方法涉及到了一些元件的調用,例如 EventLoopGroup,Channel。這些會在後面詳細講解。
這裡有個大緻的印象就好:
- 建立 EventLoopGroup。
- 建立 ServerBootstrap。
- 指定所使用的 NIO 傳輸 Channel。
- 使用指定的端口設定套接字位址。
- 添加一個 ServerHandler 到 Channel 的 ChannelPipeline。
- 異步地綁定伺服器;調用 sync() 方法阻塞等待直到綁定完成。
- 擷取 Channel 的 CloseFuture,并且阻塞目前線程直到它完成。
- 關閉 EventLoopGroup,釋放所有的資源。
NettyServer 啟動以後會監聽某個端口的請求,當接受到了請求就需要處理了。在 Netty 中用戶端請求服務端,被稱為“入站”操作。
可以通過
ChannelInboundHandlerAdapter 實作,具體内容如下:
從上面的代碼可以看出,服務端處理的代碼包含了三個方法。這三個方法都是根據事件觸發的。
他們分别是:
- 當接收到消息時的操作,channelRead。
- 消息讀取完成時的方法,channelReadComplete。
- 出現異常時的方法,exceptionCaught。
用戶端代碼
用戶端和服務端的代碼基本相似,在初始化時需要輸入服務端的 IP 和 Port。
同樣在用戶端啟動函數中包括以下内容:
用戶端啟動程式的順序:
- 建立 Bootstrap。
- 指定 EventLoopGroup 用來監聽事件。
- 定義 Channel 的傳輸模式為 NIO(Non-BlockingInputOutput)。
- 設定伺服器的 InetSocketAddress。
- 在建立 Channel 時,向 ChannelPipeline 中添加一個 EchoClientHandler 執行個體。
- 連接配接到遠端節點,阻塞等待直到連接配接完成。
- 阻塞,直到 Channel 關閉。
- 關閉線程池并且釋放所有的資源。
用戶端在完成以上操作以後,會與服務端建立連接配接進而傳輸資料。同樣在接受到 Channel 中觸發的事件時,用戶端會觸發對應事件的操作。
例如 Channel 激活,用戶端接受到服務端的消息,或者發生異常的捕獲。
從代碼結構上看還是比較簡單的。服務端和用戶端分别初始化建立監聽和連接配接。然後分别定義各自的 Handler 處理對方的請求。
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)。
了解了 EventLoop,再來說 EventLoopGroup 就容易了,EventLoopGroup 是用來生成 EventLoop 的,還記得例子代碼中第一行就 new 了 EventLoopGroup 對象。
一個 EventLoopGroup 中包含了多個 EventLoop 對象。
EventLoopGroup 要做的就是建立一個新的 Channel,并且給它配置設定一個 EventLoop。
在異步傳輸的情況下,一個 EventLoop 是可以處理多個 Channel 中産生的事件的,它主要的工作就是事件的發現以及通知。
相對于以前一個 Channel 就占用一個 Thread 的情況。Netty 的方式就要合理多了。
用戶端發送消息到服務端,EventLoop 發現以後會告訴服務端:“你去擷取消息”,同時用戶端進行其他的工作。
當 EventLoop 檢測到服務端傳回的消息,也會通知用戶端:“消息傳回了,你去取吧“。用戶端再去擷取消息。整個過程 EventLoop 就是螢幕+傳聲筒。
③ChannelHandler,ChannelPipeline 和 ChannelHandlerContext
如果說 EventLoop 是事件的通知者,那麼 ChannelHandler 就是事件的處理者。
在 ChannelHandler 中可以添加一些業務代碼,例如資料轉換,邏輯運算等等。
正如上面例子中展示的,Server 和 Client 分别都有一個 ChannelHandler 來處理,讀取資訊,網絡可用,網絡異常之類的資訊。
并且,針對出站和入站的事件,有不同的 ChannelHandler,分别是:
- ChannelInBoundHandler(入站事件處理器)
- ChannelOutBoundHandler(出站事件處理器)
假設每次請求都會觸發事件,而由 ChannelHandler 來處理這些事件,這個事件的處理順序是由 ChannelPipeline 來決定的。
ChannelPipeline 為 ChannelHandler 鍊提供了容器。到 Channel 被建立的時候,會被 Netty 架構自動配置設定到 ChannelPipeline 上。
ChannelPipeline 保證 ChannelHandler 按照一定順序處理事件,當事件觸發以後,會将資料通過 ChannelPipeline 按照一定的順序通過 ChannelHandler。
說白了,ChannelPipeline 是負責“排隊”的。這裡的“排隊”是處理事件的順序。
同時,ChannelPipeline 也可以添加或者删除 ChannelHandler,管理整個隊列。
如上圖,ChannelPipeline 使 ChannelHandler 按照先後順序排列,資訊按照箭頭所示方向流動并且被 ChannelHandler 處理。
說完了 ChannelPipeline 和 ChannelHandler,前者管理後者的排列順序。那麼它們之間的關聯就由 ChannelHandlerContext 來表示了。
每當有 ChannelHandler 添加到 ChannelPipeline 時,同時會建立 ChannelHandlerContext 。
ChannelHandlerContext 的主要功能是管理 ChannelHandler 和 ChannelPipeline 的互動。
不知道大家注意到沒有,開始的例子中 ChannelHandler 中處理事件函數,傳入的參數就是 ChannelHandlerContext。
ChannelHandlerContext 參數貫穿 ChannelPipeline,将資訊傳遞給每個 ChannelHandler,是個合格的“通訊員”。
把上面提到的幾個核心元件歸納一下,用下圖表示友善記憶他們之間的關系。
Netty 的資料容器
前面介紹了 Netty 的幾個核心元件,伺服器在資料傳輸的時候,産生事件,并且對事件進行監控和處理。
接下來看看資料是如何存放以及是如何讀寫的。Netty 将 ByteBuf 作為資料容器,來存放資料。
ByteBuf 工作原理
從結構上來說,ByteBuf 由一串位元組數組構成。數組中每個位元組用來存放資訊。
ByteBuf 提供了兩個索引,一個用于讀取資料,一個用于寫入資料。這兩個索引通過在位元組數組中移動,來定位需要讀或者寫資訊的位置。
當從 ByteBuf 讀取,它的 readerIndex(讀索引)将會根據讀取的位元組數遞增。
同樣,當寫 ByteBuf 時,它的 writerIndex 也會根據寫入的位元組數進行遞增。
需要注意的是極限的情況是 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 對象,後面所有的配置都是基于這個對象展開的。
Bootstrap 的作用就是将 Netty 核心元件配置到程式中,并且讓他們運作起來。
從 Bootstrap 的繼承結構來看,分為兩類分别是 Bootstrap 和 ServerBootstrap,一個對應用戶端的引導,另一個對應服務端的引導。
用戶端引導 Bootstrap,主要有兩個方法 bind() 和 connect()。Bootstrap 通過 bind() 方法建立一個 Channel。
在 bind() 之後,通過調用 connect() 方法來建立 Channel 連接配接。
服務端引導 ServerBootstrap,與用戶端不同的是在 Bind() 方法之後會建立一個 ServerChannel,它不僅會建立新的 Channel 還會管理已經存在的 Channel。
通過上面的描述,服務端和用戶端的引導存在兩個差別:
- ServerBootstrap(服務端引導)綁定一個端口,用來監聽用戶端的連接配接請求。而 Bootstrap(用戶端引導)隻要知道服務端 IP 和 Port 建立連接配接就可以了。
- Bootstrap(用戶端引導)需要一個 EventLoopGroup,但是 ServerBootstrap(服務端引導)則需要兩個 EventLoopGroup。因為伺服器需要兩組不同的 Channel。第一組 ServerChannel 自身監聽本地端口的套接字。第二組用來監聽用戶端請求的套接字。