一文理解Netty模型架構
本文基於Netty4.1展開介紹相關理論模型,使用場景,基本元件、整體架構,知其然且知其所以然,希望給讀者提供學習實踐參考。
1 Netty簡介
Netty是 一個非同步事件驅動的網路應用程式框架,用於快速開發可維護的高效能協議伺服器和客戶端。
JDK原生NIO程式的問題
JDK原生也有一套網路應用程式API,但是存在一系列問題,主要如下:
- NIO的類庫和API繁雜,使用麻煩,你需要熟練掌握Selector、ServerSocketChannel、SocketChannel、ByteBuffer等
- 需要具備其它的額外技能做鋪墊,例如熟悉Java多執行緒程式設計,因為NIO程式設計涉及到Reactor模式,你必須對多執行緒和網路程式設計非常熟悉,才能編寫出高質量的NIO程式
- 可靠效能力補齊,開發工作量和難度都非常大。例如客戶端面臨斷連重連、網路閃斷、半包讀寫、失敗快取、網路擁塞和異常碼流的處理等等,NIO程式設計的特點是功能開發相對容易,但是可靠效能力補齊工作量和難度都非常大
- JDK NIO的BUG,例如臭名昭著的epoll bug,它會導致Selector空輪詢,最終導致CPU 100%。官方聲稱在JDK1.6版本的update18修復了該問題,但是直到JDK1.7版本該問題仍舊存在,只不過該bug發生概率降低了一些而已,它並沒有被根本解決
Netty的特點
Netty的對JDK自帶的NIO的API進行封裝,解決上述問題,主要特點有:
- 設計優雅 適用於各種傳輸型別的統一API - 阻塞和非阻塞Socket 基於靈活且可擴充套件的事件模型,可以清晰地分離關注點 高度可定製的執行緒模型 - 單執行緒,一個或多個執行緒池 真正的無連線資料報套接字支援(自3.1起)
- 使用方便 詳細記錄的Javadoc,使用者指南和示例 沒有其他依賴項,JDK 5(Netty 3.x)或6(Netty 4.x)就足夠了
- 高效能 吞吐量更高,延遲更低 減少資源消耗 最小化不必要的記憶體複製
- 安全 完整的SSL / TLS和StartTLS支援
- 社群活躍,不斷更新 社群活躍,版本迭代週期短,發現的BUG可以被及時修復,同時,更多的新功能會被加入
Netty常見使用常見
Netty常見的使用場景如下:
- 網際網路行業 在分散式系統中,各個節點之間需要遠端服務呼叫,高效能的RPC框架必不可少,Netty作為非同步高新能的通訊框架,往往作為基礎通訊元件被這些RPC框架使用。 典型的應用有:阿里分散式服務框架Dubbo的RPC框架使用Dubbo協議進行節點間通訊,Dubbo協議預設使用Netty作為基礎通訊元件,用於實現各程序節點之間的內部通訊。
- 遊戲行業 無論是手遊服務端還是大型的網路遊戲,Java語言得到了越來越廣泛的應用。Netty作為高效能的基礎通訊元件,它本身提供了TCP/UDP和HTTP協議棧。 非常方便定製和開發私有協議棧,賬號登入伺服器,地圖伺服器之間可以方便的通過Netty進行高效能的通訊
- 大資料領域 經典的Hadoop的高效能通訊和序列化元件Avro的RPC框架,預設採用Netty進行跨界點通訊,它的Netty Service基於Netty框架二次封裝實現
有興趣的讀者可以瞭解一下目前有哪些開源專案使用了 Netty:Related projects
2 Netty高效能設計
Netty作為非同步事件驅動的網路,高效能之處主要來自於其I/O模型和執行緒處理模型,前者決定如何收發資料,後者決定如何處理資料
I/O模型
用什麼樣的通道將資料傳送給對方,BIO、NIO或者AIO,I/O模型在很大程度上決定了框架的效能
阻塞I/O
傳統阻塞型I/O(BIO)可以用下圖表示:
特點
- 每個請求都需要獨立的執行緒完成資料read,業務處理,資料write的完整操作
問題
- 當併發數較大時,需要建立大量執行緒來處理連線,系統資源佔用較大
- 連線建立後,如果當前執行緒暫時沒有資料可讀,則執行緒就阻塞在read操作上,造成執行緒資源浪費
I/O複用模型
在I/O複用模型中,會用到select,這個函式也會使程序阻塞,但是和阻塞I/O所不同的的,這兩個函式可以同時阻塞多個I/O操作,而且可以同時對多個讀操作,多個寫操作的I/O函式進行檢測,直到有資料可讀或可寫時,才真正呼叫I/O操作函式
Netty的非阻塞I/O的實現關鍵是基於I/O複用模型,這裡用Selector物件表示:
Netty的IO執行緒NioEventLoop由於聚合了多路複用器Selector,可以同時併發處理成百上千個客戶端連線。當執行緒從某客戶端Socket通道進行讀寫資料時,若沒有資料可用時,該執行緒可以進行其他任務。執行緒通常將非阻塞 IO 的空閒時間用於在其他通道上執行 IO 操作,所以單獨的執行緒可以管理多個輸入和輸出通道。
由於讀寫操作都是非阻塞的,這就可以充分提升IO執行緒的執行效率,避免由於頻繁I/O阻塞導致的執行緒掛起,一個I/O執行緒可以併發處理N個客戶端連線和讀寫操作,這從根本上解決了傳統同步阻塞I/O一連線一執行緒模型,架構的效能、彈性伸縮能力和可靠性都得到了極大的提升。
基於buffer
傳統的I/O是面向位元組流或字元流的,以流式的方式順序地從一個Stream 中讀取一個或多個位元組, 因此也就不能隨意改變讀取指標的位置。
在NIO中, 拋棄了傳統的 I/O流, 而是引入了Channel和Buffer的概念. 在NIO中, 只能從Channel中讀取資料到Buffer中或將資料 Buffer 中寫入到 Channel。
基於buffer操作不像傳統IO的順序操作, NIO 中可以隨意地讀取任意位置的資料
執行緒模型
資料報如何讀取?讀取之後的編解碼在哪個執行緒進行,編解碼後的訊息如何派發,執行緒模型的不同,對效能的影響也非常大。
事件驅動模型
通常,我們設計一個事件處理模型的程式有兩種思路
- 輪詢方式 執行緒不斷輪詢訪問相關事件發生源有沒有發生事件,有發生事件就呼叫事件處理邏輯。
- 事件驅動方式 發生事件,主執行緒把事件放入事件佇列,在另外執行緒不斷迴圈消費事件列表中的事件,呼叫事件對應的處理邏輯處理事件。事件驅動方式也被稱為訊息通知方式,其實是設計模式中觀察者模式的思路。
以GUI的邏輯處理為例,說明兩種邏輯的不同:
- 輪詢方式 執行緒不斷輪詢是否發生按鈕點選事件,如果發生,呼叫處理邏輯
- 事件驅動方式 發生點選事件把事件放入事件佇列,在另外執行緒消費的事件列表中的事件,根據事件型別呼叫相關事件處理邏輯
這裡借用O'Reilly 大神關於事件驅動模型解釋圖
主要包括4個基本元件:- 事件佇列(event queue):接收事件的入口,儲存待處理事件
- 分發器(event mediator):將不同的事件分發到不同的業務邏輯單元
- 事件通道(event channel):分發器與處理器之間的聯絡渠道
- 事件處理器(event processor):實現業務邏輯,處理完成後會發出事件,觸發下一步操作
可以看出,相對傳統輪詢模式,事件驅動有如下優點:
- 可擴充套件性好,分散式的非同步架構,事件處理器之間高度解耦,可以方便擴充套件事件處理邏輯
- 高效能,基於佇列暫存事件,能方便並行非同步處理事件
Reactor執行緒模型
Reactor是反應堆的意思,Reactor模型,是指通過一個或多個輸入同時傳遞給服務處理器的服務請求的事件驅動處理模式。 服務端程式處理傳入多路請求,並將它們同步分派給請求對應的處理執行緒,Reactor模式也叫Dispatcher模式,即I/O多了複用統一監聽事件,收到事件後分發(Dispatch給某程序),是編寫高效能網路伺服器的必備技術之一。
Reactor模型中有2個關鍵組成:
-
Reactor Reactor在一個單獨的執行緒中執行,負責監聽和分發事件,分發給適當的處理程式來對IO事件做出反應。 它就像公司的電話接線員,它接聽來自客戶的電話並將線路轉移到適當的聯絡人
-
Handlers 處理程式執行I/O事件要完成的實際事件,類似於客戶想要與之交談的公司中的實際官員。Reactor通過排程適當的處理程式來響應I/O事件,處理程式執行非阻塞操作
取決於Reactor的數量和Hanndler執行緒數量的不同,Reactor模型有3個變種
- 單Reactor單執行緒
- 單Reactor多執行緒
- 主從Reactor多執行緒
可以這樣理解,Reactor就是一個執行while (true) { selector.select(); ...}迴圈的執行緒,會源源不斷的產生新的事件,稱作反應堆很貼切。
篇幅關係,這裡不再具體展開Reactor特性、優缺點比較,有興趣的讀者可以參考我之前另外一篇文章:《理解高效能網路模型》
Netty執行緒模型
Netty主要基於主從Reactors多執行緒模型(如下圖)做了一定的修改,其中主從Reactor多執行緒模型有多個Reactor:MainReactor和SubReactor:
- MainReactor負責客戶端的連線請求,並將請求轉交給SubReactor
- SubReactor負責相應通道的IO讀寫請求
- 非IO請求(具體邏輯處理)的任務則會直接寫入佇列,等待worker threads進行處理
這裡引用Doug Lee大神的Reactor介紹:Scalable IO in Java裡面關於主從Reactor多執行緒模型的圖
特別說明的是: 雖然Netty的執行緒模型基於主從Reactor多執行緒,借用了MainReactor和SubReactor的結構,但是實際實現上,SubReactor和Worker執行緒在同一個執行緒池中:
EventLoopGroup bossGroup = new NioEventLoopGroup();
EventLoopGroup workerGroup = new NioEventLoopGroup();
ServerBootstrap server = new ServerBootstrap();
server.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
複製程式碼
上面程式碼中的bossGroup 和workerGroup是Bootstrap構造方法中傳入的兩個物件,這兩個group均是執行緒池
- bossGroup執行緒池則只是在bind某個埠後,獲得其中一個執行緒作為MainReactor,專門處理埠的accept事件,每個埠對應一個boss執行緒
- workerGroup執行緒池會被各個SubReactor和worker執行緒充分利用
非同步處理
非同步的概念和同步相對。當一個非同步過程呼叫發出後,呼叫者不能立刻得到結果。實際處理這個呼叫的部件在完成後,通過狀態、通知和回撥來通知呼叫者。
Netty中的I/O操作是非同步的,包括bind、write、connect等操作會簡單的返回一個ChannelFuture,呼叫者並不能立刻獲得結果,通過Future-Listener機制,使用者可以方便的主動獲取或者通過通知機制獲得IO操作結果。
當future物件剛剛建立時,處於非完成狀態,呼叫者可以通過返回的ChannelFuture來獲取操作執行的狀態,註冊監聽函式來執行完成後的操,常見有如下操作:
- 通過isDone方法來判斷當前操作是否完成
- 通過isSuccess方法來判斷已完成的當前操作是否成功
- 通過getCause方法來獲取已完成的當前操作失敗的原因
- 通過isCancelled方法來判斷已完成的當前操作是否被取消
- 通過addListener方法來註冊監聽器,當操作已完成(isDone方法返回完成),將會通知指定的監聽器;如果future物件已完成,則理解通知指定的監聽器
例如下面的的程式碼中繫結埠是非同步操作,當繫結操作處理完,將會呼叫相應的監聽器處理邏輯
serverBootstrap.bind(port).addListener(future -> {
if (future.isSuccess()) {
System.out.println(new Date() + ": 埠[" + port + "]繫結成功!");
} else {
System.err.println("埠[" + port + "]繫結失敗!");
}
});
複製程式碼
相比傳統阻塞I/O,執行I/O操作後執行緒會被阻塞住, 直到操作完成;非同步處理的好處是不會造成執行緒阻塞,執行緒在I/O操作期間可以執行別的程式,在高併發情形下會更穩定和更高的吞吐量。
3 Netty架構設計
前面介紹完Netty相關一些理論介紹,下面從功能特性、模組元件、運作過程來介紹Netty的架構設計
功能特性
- 傳輸服務 支援BIO和NIO
- 容器整合 支援OSGI、JBossMC、Spring、Guice容器
- 協議支援 HTTP、Protobuf、二進位制、文字、WebSocket等一系列常見協議都支援。 還支援通過實行編碼解碼邏輯來實現自定義協議
- Core核心 可擴充套件事件模型、通用通訊API、支援零拷貝的ByteBuf緩衝物件
模組元件
Bootstrap、ServerBootstrap
Bootstrap意思是引導,一個Netty應用通常由一個Bootstrap開始,主要作用是配置整個Netty程式,串聯各個元件,Netty中Bootstrap類是客戶端程式的啟動引導類,ServerBootstrap是服務端啟動引導類。
Future、ChannelFuture
正如前面介紹,在Netty中所有的IO操作都是非同步的,不能立刻得知訊息是否被正確處理,但是可以過一會等它執行完成或者直接註冊一個監聽,具體的實現就是通過Future和ChannelFutures,他們可以註冊一個監聽,當操作執行成功或失敗時監聽會自動觸發註冊的監聽事件。
Channel
Netty網路通訊的元件,能夠用於執行網路I/O操作。 Channel為使用者提供:
- 當前網路連線的通道的狀態(例如是否開啟?是否已連線?)
- 網路連線的配置引數 (例如接收緩衝區大小)
- 提供非同步的網路I/O操作(如建立連線,讀寫,繫結埠),非同步呼叫意味著任何I / O呼叫都將立即返回,並且不保證在呼叫結束時所請求的I / O操作已完成。呼叫立即返回一個ChannelFuture例項,通過註冊監聽器到ChannelFuture上,可以I / O操作成功、失敗或取消時回撥通知呼叫方。
- 支援關聯I/O操作與對應的處理程式
不同協議、不同的阻塞型別的連線都有不同的 Channel 型別與之對應,下面是一些常用的 Channel 型別
- NioSocketChannel,非同步的客戶端 TCP Socket 連線
- NioServerSocketChannel,非同步的伺服器端 TCP Socket 連線
- NioDatagramChannel,非同步的 UDP 連線
- NioSctpChannel,非同步的客戶端 Sctp 連線
- NioSctpServerChannel,非同步的 Sctp 伺服器端連線 這些通道涵蓋了 UDP 和 TCP網路 IO以及檔案 IO.
Selector
Netty基於Selector物件實現I/O多路複用,通過 Selector, 一個執行緒可以監聽多個連線的Channel事件, 當向一個Selector中註冊Channel 後,Selector 內部的機制就可以自動不斷地查詢(select) 這些註冊的Channel是否有已就緒的I/O事件(例如可讀, 可寫, 網路連線完成等),這樣程式就可以很簡單地使用一個執行緒高效地管理多個 Channel 。
NioEventLoop
NioEventLoop中維護了一個執行緒和任務佇列,支援非同步提交執行任務,執行緒啟動時會呼叫NioEventLoop的run方法,執行I/O任務和非I/O任務:
- I/O任務 即selectionKey中ready的事件,如accept、connect、read、write等,由processSelectedKeys方法觸發。
- 非IO任務 新增到taskQueue中的任務,如register0、bind0等任務,由runAllTasks方法觸發。
兩種任務的執行時間比由變數ioRatio控制,預設為50,則表示允許非IO任務執行的時間與IO任務的執行時間相等。
NioEventLoopGroup
NioEventLoopGroup,主要管理eventLoop的生命週期,可以理解為一個執行緒池,內部維護了一組執行緒,每個執行緒(NioEventLoop)負責處理多個Channel上的事件,而一個Channel只對應於一個執行緒。
ChannelHandler
ChannelHandler是一個介面,處理I / O事件或攔截I / O操作,並將其轉發到其ChannelPipeline(業務處理鏈)中的下一個處理程式。
ChannelHandler本身並沒有提供很多方法,因為這個介面有許多的方法需要實現,方便使用期間,可以繼承它的子類:
- ChannelInboundHandler用於處理入站I / O事件
- ChannelOutboundHandler用於處理出站I / O操作
或者使用以下介面卡類:
- ChannelInboundHandlerAdapter用於處理入站I / O事件
- ChannelOutboundHandlerAdapter用於處理出站I / O操作
- ChannelDuplexHandler用於處理入站和出站事件
ChannelHandlerContext
儲存Channel相關的所有上下文資訊,同時關聯一個ChannelHandler物件
ChannelPipline
儲存ChannelHandler的List,用於處理或攔截Channel的入站事件和出站操作。 ChannelPipeline實現了一種高階形式的攔截過濾器模式,使使用者可以完全控制事件的處理方式,以及Channel中各個的ChannelHandler如何相互互動。
下圖引用Netty的Javadoc4.1中ChannelPipline的說明,描述了ChannelPipeline中ChannelHandler通常如何處理I/O事件。 I/O事件由ChannelInboundHandler或ChannelOutboundHandler處理,並通過呼叫ChannelHandlerContext中定義的事件傳播方法(例如ChannelHandlerContext.fireChannelRead(Object)和ChannelOutboundInvoker.write(Object))轉發到其最近的處理程式。
I/O Request
via Channel or
ChannelHandlerContext
|
+---------------------------------------------------+---------------+
| ChannelPipeline | |
| \|/ |
| +---------------------+ +-----------+----------+ |
| | Inbound Handler N | | Outbound Handler 1 | |
| +----------+----------+ +-----------+----------+ |
| /|\ | |
| | \|/ |
| +----------+----------+ +-----------+----------+ |
| | Inbound Handler N-1 | | Outbound Handler 2 | |
| +----------+----------+ +-----------+----------+ |
| /|\ . |
| . . |
| ChannelHandlerContext.fireIN_EVT() ChannelHandlerContext.OUT_EVT()|
| [ method call] [method call] |
| . . |
| . \|/ |
| +----------+----------+ +-----------+----------+ |
| | Inbound Handler 2 | | Outbound Handler M-1 | |
| +----------+----------+ +-----------+----------+ |
| /|\ | |
| | \|/ |
| +----------+----------+ +-----------+----------+ |
| | Inbound Handler 1 | | Outbound Handler M | |
| +----------+----------+ +-----------+----------+ |
| /|\ | |
+---------------+-----------------------------------+---------------+
| \|/
+---------------+-----------------------------------+---------------+
| | | |
| [ Socket.read() ] [ Socket.write() ] |
| |
| Netty Internal I/O Threads (Transport Implementation) |
+-------------------------------------------------------------------+
複製程式碼
入站事件由自下而上方向的入站處理程式處理,如圖左側所示。 入站Handler處理程式通常處理由圖底部的I / O執行緒生成的入站資料。 通常通過實際輸入操作(例如SocketChannel.read(ByteBuffer))從遠端讀取入站資料。
出站事件由上下方向處理,如圖右側所示。 出站Handler處理程式通常會生成或轉換出站傳輸,例如write請求。 I/O執行緒通常執行實際的輸出操作,例如SocketChannel.write(ByteBuffer)。
在 Netty 中每個 Channel 都有且僅有一個 ChannelPipeline 與之對應, 它們的組成關係如下:
一個 Channel 包含了一個 ChannelPipeline, 而 ChannelPipeline 中又維護了一個由 ChannelHandlerContext 組成的雙向連結串列, 並且每個 ChannelHandlerContext 中又關聯著一個 ChannelHandler。入站事件和出站事件在一個雙向連結串列中,入站事件會從連結串列head往後傳遞到最後一個入站的handler,出站事件會從連結串列tail往前傳遞到最前一個出站的handler,兩種型別的handler互不干擾。
工作原理架構
初始化並啟動Netty服務端過程如下:
public static void main(String[] args) {
// 建立mainReactor
NioEventLoopGroup boosGroup = new NioEventLoopGroup();
// 建立工作執行緒組
NioEventLoopGroup workerGroup = new NioEventLoopGroup();
final ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap
// 組裝NioEventLoopGroup
.group(boosGroup, workerGroup)
// 設定channel型別為NIO型別
.channel(NioServerSocketChannel.class)
// 設定連線配置引數
.option(ChannelOption.SO_BACKLOG, 1024)
.childOption(ChannelOption.SO_KEEPALIVE, true)
.childOption(ChannelOption.TCP_NODELAY, true)
// 配置入站、出站事件handler
.childHandler(new ChannelInitializer<NioSocketChannel>() {
@Override
protected void initChannel(NioSocketChannel ch) {
// 配置入站、出站事件channel
ch.pipeline().addLast(...);
ch.pipeline().addLast(...);
}
});
// 繫結埠
int port = 8080;
serverBootstrap.bind(port).addListener(future -> {
if (future.isSuccess()) {
System.out.println(new Date() + ": 埠[" + port + "]繫結成功!");
} else {
System.err.println("埠[" + port + "]繫結失敗!");
}
});
}
複製程式碼
- 基本過程如下:
- 1 初始化建立2個NioEventLoopGroup,其中boosGroup用於Accetpt連線建立事件並分發請求, workerGroup用於處理I/O讀寫事件和業務邏輯
- 2 基於ServerBootstrap(服務端啟動引導類),配置EventLoopGroup、Channel型別,連線引數、配置入站、出站事件handler
- 3 繫結埠,開始工作
結合上面的介紹的Netty Reactor模型,介紹服務端Netty的工作架構圖:
server端包含1個Boss NioEventLoopGroup和1個Worker NioEventLoopGroup,NioEventLoopGroup相當於1個事件迴圈組,這個組裡包含多個事件迴圈NioEventLoop,每個NioEventLoop包含1個selector和1個事件迴圈執行緒。
每個Boss NioEventLoop迴圈執行的任務包含3步:
- 1 輪詢accept事件
- 2 處理accept I/O事件,與Client建立連線,生成NioSocketChannel,並將NioSocketChannel註冊到某個Worker NioEventLoop的Selector上 *3 處理任務佇列中的任務,runAllTasks。任務佇列中的任務包括使用者呼叫eventloop.execute或schedule執行的任務,或者其它執行緒提交到該eventloop的任務。
每個Worker NioEventLoop迴圈執行的任務包含3步:
- 1 輪詢read、write事件;
- 2 處I/O事件,即read、write事件,在NioSocketChannel可讀、可寫事件發生時進行處理
- 3 處理任務佇列中的任務,runAllTasks。
其中任務佇列中的task有3種典型使用場景
- 1 使用者程式自定義的普通任務
ctx.channel().eventLoop().execute(new Runnable() {
@Override
public void run() {
//...
}
});
複製程式碼
-
2 非當前reactor執行緒呼叫channel的各種方法 例如在推送系統的業務執行緒裡面,根據使用者的標識,找到對應的channel引用,然後呼叫write類方法向該使用者推送訊息,就會進入到這種場景。最終的write會提交到任務佇列中後被非同步消費。
-
3 使用者自定義定時任務
ctx.channel().eventLoop().schedule(new Runnable() {
@Override
public void run() {
}
}, 60, TimeUnit.SECONDS);
複製程式碼
4 總結
現在穩定推薦使用的主流版本還是Netty4,Netty5 中使用了 ForkJoinPool,增加了程式碼的複雜度,但是對效能的改善卻不明顯,所以這個版本不推薦使用,官網也沒有提供下載連結。
Netty 入門門檻相對較高,其實是因為這方面的資料較少,並不是因為他有多難,大家其實都可以像搞透 Spring 一樣搞透 Netty。在學習之前,建議先理解透整個框架原理結構,執行過程,可以少走很多彎路。
參考
software-architecture-patterns.pdf
《Netty In Action》
《Netty權威指南》