本文基於 Netty 4.1 展開介紹相關理論模型,使用場景,基本元件、整體架構,知其然且知其所以然,希望給大家在實際開發實踐、學習開源專案方面提供參考。
Netty 是一個非同步事件驅動的網路應用程式框架,用於快速開發可維護的高效能協議伺服器和客戶端。
JDK 原生 NIO 程式的問題
JDK 原生也有一套網路應用程式 API,但是存在一系列問題,主要如下:
NIO 的類庫和 API 繁雜,使用麻煩。你需要熟練掌握 Selector、ServerSocketChannel、SocketChannel、ByteBuffer 等。
需要具備其他的額外技能做鋪墊。例如熟悉 Java 多執行緒程式設計,因為 NIO 程式設計涉及到 Reactor 模式,你必須對多執行緒和網路程式設計非常熟悉,才能編寫出高質量的 NIO 程式。
可靠效能力補齊,開發工作量和難度都非常大。例如客戶端面臨斷連重連、網路閃斷、半包讀寫、失敗快取、網路擁塞和異常碼流的處理等等。
NIO 程式設計的特點是功能開發相對容易,但是可靠效能力補齊工作量和難度都非常大。
JDK NIO 的 Bug。例如臭名昭著的 Epoll Bug,它會導致 Selector 空輪詢,最終導致 CPU 100%。
官方聲稱在 JDK 1.6 版本的 update 18 修復了該問題,但是直到 JDK 1.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。
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(); …} 迴圈的執行緒,會源源不斷的產生新的事件,稱作反應堆很貼切。
Netty 執行緒模型
Netty 主要基於主從 Reactors 多執行緒模型(如下圖)做了一定的修改,其中主從 Reactor 多執行緒模型有多個 Reactor:
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 操作期間可以執行別的程式,在高併發情形下會更穩定和更高的吞吐量。
Netty 架構設計
前面介紹完 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 的 Javadoc 4.1 中 ChannelPipeline 的說明,描述了 ChannelPipeline 中 ChannelHandler 通常如何處理 I/O 事件。
I/O 事件由 ChannelInboundHandler 或 ChannelOutboundHandler 處理,並通過呼叫 ChannelHandlerContext 中定義的事件傳播方法。
例如 ChannelHandlerContext.fireChannelRead(Object)和 ChannelOutboundInvoker.write(Object)轉發到其最近的處理程式。
入站事件由自下而上方向的入站處理程式處理,如圖左側所示。入站 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 工作原理架構
初始化並啟動 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 + "]繫結失敗!");
}
});
}
複製程式碼
基本過程如下:
初始化建立 2 個 NioEventLoopGroup,其中 boosGroup 用於 Accetpt 連線建立事件並分發請求,workerGroup 用於處理 I/O 讀寫事件和業務邏輯。
基於 ServerBootstrap(服務端啟動引導類),配置 EventLoopGroup、Channel 型別,連線引數、配置入站、出站事件 handler。
繫結埠,開始工作。
結合上面介紹的 Netty Reactor 模型,介紹服務端 Netty 的工作架構圖:
Server 端包含 1 個 Boss NioEventLoopGroup 和 1 個 Worker NioEventLoopGroup。
NioEventLoopGroup 相當於 1 個事件迴圈組,這個組裡包含多個事件迴圈 NioEventLoop,每個 NioEventLoop 包含 1 個 Selector 和 1 個事件迴圈執行緒。
每個 Boss NioEventLoop 迴圈執行的任務包含 3 步:
輪詢 Accept 事件。
處理 Accept I/O 事件,與 Client 建立連線,生成 NioSocketChannel,並將 NioSocketChannel 註冊到某個 Worker NioEventLoop 的 Selector 上。
處理任務佇列中的任務,runAllTasks。任務佇列中的任務包括使用者呼叫 eventloop.execute 或 schedule 執行的任務,或者其他執行緒提交到該 eventloop 的任務。
每個 Worker NioEventLoop 迴圈執行的任務包含 3 步:
輪詢 Read、Write 事件。
處理 I/O 事件,即 Read、Write 事件,在 NioSocketChannel 可讀、可寫事件發生時進行處理。
處理任務佇列中的任務,runAllTasks。
其中任務佇列中的 Task 有 3 種典型使用場景。
①使用者程式自定義的普通任務
ctx.channel().eventLoop().execute(new Runnable() {
@Override
public void run() {
//...
}
});複製程式碼
②非當前 Reactor 執行緒呼叫 Channel 的各種方法
例如在推送系統的業務執行緒裡面,根據使用者的標識,找到對應的 Channel 引用,然後呼叫 Write 類方法向該使用者推送訊息,就會進入到這種場景。最終的 Write 會提交到任務佇列中後被非同步消費。
③使用者自定義定時任務
ctx.channel().eventLoop().schedule(new Runnable() {
@Override
public void run() {
}
}, 60, TimeUnit.SECONDS);複製程式碼
總結
現在穩定推薦使用的主流版本還是 Netty4,Netty5 中使用了 ForkJoinPool,增加了程式碼的複雜度,但是對效能的改善卻不明顯,所以這個版本不推薦使用,官網也沒有提供下載連結。
Netty 入門門檻相對較高,是因為這方面的資料較少,並不是因為它有多難,大家其實都可以像搞透 Spring 一樣搞透 Netty。
在學習之前,建議先理解透整個框架原理結構,執行過程,可以少走很多彎路。
參考資料:
- Netty入門與實戰:仿寫微信 IM 即時通訊系統
- Netty官網
- Netty 4.x學習筆記 - 執行緒模型
- Netty入門與實戰
- 理解高效能網路模型
- Netty基本原理介紹
- software-architecture-patterns.pdf
- Netty高效能之道 —— 李林鋒
- 《Netty In Action》
- 《Netty權威指南》