很早以前其實就寫過關於 Netty 的使用,最近發現在CSDN上一直有人在看很早寫的 Netty 文章,個人感覺那時候寫的很粗糙,怕影響同行的閱讀質量,但是我也不知道為啥有這么多小伙伴關注Netty,所以決定重新寫一些關於Netty的文章,補充以前的不足吧。
Netty能做啥
簡單說就是用來處理網絡編程,寫一款能進行網絡通信的服務端和客戶端程序。
如果沒有 Netty,在 Java 的世界中如何處理網絡編程呢?
Java自帶的工具有:java.net
包,用於處理網絡通信,后面Java提供了 NIO 工具包用於提供非阻塞的通信。
與Netty同級別的第三方工具包:Mina,在設計上與Netty 有些許不同,但是核心都是提供網絡通信的能力。
傳統網絡通信模型
說Netty之前還是先講一下傳統的網絡編程是什么樣子。傳統的Socket編程開發步驟很簡單,只需要使用Socket類創建客戶端和服務端即可。但是為啥現在沒有人用它了呢?主要原因是它基於同步阻塞 IO 的線程模型去做的,在當今時代完全不能滿足生產需要,自然被out。
同步阻塞線程模型的問題在於一個請求必須綁定一個線程去處理,並且所有的請求都是同步操作,意味着該請求未處理完之前這個連接不會被釋放,如果並發高的情況必然會導致系統壓力過大。
Netty 的新線程模型
基於此,Java新增了非阻塞的IO操作包 NIO, NIO 的線程模型采用了Reactor 模式,即異步非阻塞的方式,解決了之前同步阻塞帶來的問題。
NIO 的全稱是 NoneBlocking IO,非阻塞 IO,區別與 BIO,BIO 的全稱是 Blocking IO,阻塞 IO。那這個阻塞是什么意思呢?
- Accept是阻塞的,只有新連接來了,Accept才會返回,主線程才能繼;
- Read是阻塞的,只有請求消息來了,Read才能返回,子線程才能繼續處理;
- Write是阻塞的,只有客戶端把消息收了,Write才能返回,子線程才能繼續讀取下一個請求。
服務器在處理響應的設計模式方面目前主要分為兩種:線程驅動 和 事件驅動。同步阻塞就是線程驅動的模式,最明顯的例子就是 Tomcat;對於事件驅動來說,沒有必要為每一個連接都創建一個線程去維護,參考觀察者模式,可以設置一個事件池,用一個單線程去循環監聽當前池中是否有完成的事件,如果有則取出該事件。
簡單說一下 Reactor 模式是如何解決線程等待問題的:在等待IO的時候,線程可以先退出不用一直等待IO操作。但是如果不等待那么IO處理完成之后返回給誰呢?Reactor模型采用了事件驅動機制,要求線程在退出前向event loop注冊回調函數,這樣IO完成之后 event loop 就可以調用回調函數完成數據返回。
在Reactor中有 4 個角色,所有的數據流入的處理統一稱為 Channel,就像是一個水管,Reactor 模型將每一種事件拆分為一個 event,相同類型的 event 歸為一類,這一類的統一處理邏輯被稱為一個 handler。那么怎么去讓一個或者多個線程去監聽所有的 Channel 呢? 所以就有 Selector,Selector 就像是一個管理者,你可以將多個 Channel 注冊到 一個Selector 線程上,它會使用一個阻塞方法去捕獲當前 Channel 上是否有事件發生,如果有則取出事件交給對應的 handler 去處理。
Netty 是建立在 NIO 之上的,並且 Netty 在 NIO 上面又提供了更多高層次 API 的封裝。
為什么不用 JDK 提供的 NIO
JDK 已經給我們提供了 NIO 的包,也是使用了 Reactor 模型來實現的異步非阻塞模式,那我們為啥在日常開發中沒有聽到誰直接使用 NIO 來開發網絡編程呢?實際上大家不使用的原因是因為它太難控制。Java NIO 類庫中主要提供的功能包括:
- 緩沖區 Buffer
- 通道 Channel
- 多路復用器 Selector
緩沖區 Buffer 其實就是一個對象,即所有流入或者流出的數據都在Buffer中存在。
新 IO 與老的面向流 IO 的區別在於老 IO 直接面向字節流進行處理,新 IO 是面向緩沖區進行處理,讀寫數據都是先讀寫到緩沖區中。緩沖區實質上是一個字節數組,NIO 提供了對緩沖區數據讀寫位置維護的操作能力。
Channel 通道,所有 Buffer內的數據都會往 Channel 上流,數據通過 Channel 留向處理邏輯,通過 Channel 將處理過的數據返回給客戶端。所以 Channel 是全雙工的,可以支持讀寫,這是它與 Stream 的區別。如果你使用Stream,讀數據只能使用 InputStream 進行操作,寫數據只能使用 OutputStream 進行操作。用現實世界中的事物比喻的話,傳統 IO 猶如水管,水流只能沿着管道往下流; NIO 猶如一條雙向公路,兩個方向都可以行車。
另外也正是因為 Buffer 的引入我們才能隨意的控制每次傳輸讀多少數據,如果上次讀取失敗,那么應該從多少偏移量重新讀取,這是傳統 I/O流無法比擬的。
Selector 選擇器,它是 NIO 的核心,一個 Selector 就是一個線程,NIO 允許一個 Selector 管理多個 Channel,即將 Channel 注冊到 Selector 上,Selector 會去監聽注冊的 Channel 上是否有事件准備就緒,如果有就取出處理。
關於 NIO 的代碼我就不寫了,是很龐大的一堆,大家百度一下就能看到。總之基於這個思想來進行網絡編程肯定是面對當今流量洪峰的最佳方式。而正好 Netty 底層基於 NIO 去做的封裝,已經給你屏蔽了這一大坨操作。
網絡編程還有一個問題就是跨平台性,NIO 底層是依賴系統的 IO API,不同的系統可能對 IO API 的實現也是不一樣的,這里如何你使用 NIO 那么就需要考慮系統兼容性問題了。
另外還有一個問題就是 NIO 有個很著名的 bug,JDK 的 NIO 底層由 epoll 實現,若Selector的輪詢結果為空,也沒有wakeup或新消息處理,則發生空輪詢,CPU 使用率100%。這個 bug 官方聲明已經修復,事實上沒有被 fix, 只是出現的概率會降低一些。
Netty 也對該 bug 進行了處理:對 Selector 的 select 操作周期進行統計,每完成一次空的 select 操作進行一次計數,若在某個周期內連續發生N次空輪詢,則觸發了 epoll 死循環bug。那么這個時候就重建 Selector,判斷是否是其他線程發起的重建請求,若不是則將原 SocketChannel 從舊的 Selector上去除注冊,重新注冊到新的 Selector 上,並將原來的 Selector 關閉。
網絡編程應該注意什么
既然說要學習 Netty, 它本身是基於 NIO 的封裝用於網絡通信,那么在編寫一段用於網絡通信的代碼我們應該注意一些什么呢?弄清楚這些問題,我們大概就知道 Netty 都做了什么。
談到網絡就不能避免說到 OSI 7層模型 和 TCP / IP 4層模型。
Java 網絡編程主要使用的是 Socket 套接字編程,基於 4層 協議的網絡編程,即基於 TCP/ UDP 協議的封裝。編寫一個 Socket 通信都有哪些步驟呢?
-
創建一個 ServerSocket,監聽並綁定一個端口;
-
一系列客戶端來請求這個端口;
-
服務器使用 Accept,獲得一個來自客戶端的Socket連接對象;
-
啟動一個新線程處理連接;
- 讀 Socket,得到字節流,
- 解碼協議,得到Http請求對象,
- 處理 HTTP 請求,得到一個結果,封裝成一個 HttpResponse 對象,
- 編碼協議,將結果序列化字節流,
- 寫 Socket,將字節流發給客戶端,
-
繼續循環步驟3。
根據以上的數據傳輸流程,我們可以提出一些問題:
- 如何約定字節流長度格式,以保證每次讀到的字節流都是最新的而不會和上次重復;
- 傳輸字節流的編解碼問題;
- 一個服務端肯定會有多個客戶端鏈接,如何管理眾多的客戶端鏈接,比如如何維護斷線重連,連接超時以及關閉機制;
上面這些問題我們在接下來的 Netty 學習中都會找到答案。
Netty 核心組件
在還未入門 Netty 之前我們先了解一下 Netty 里面都有哪些類,做到有的放矢,后面學習帶着這些關鍵信息不回亂。
Bootstrap、ServerBootstrap
一個 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 類型:
1. NioSocketChannel,異步的客戶端 TCP Socket 連接;
2. NioServerSocketChannel,異步的服務器端 TCP Socket 連接;
3. NioDatagramChannel,異步的 UDP 連接;
4. NioSctpChannel,異步的客戶端 Sctp 連接;
5. 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 任務:
1. I/O 任務,即 selectionKey 中 ready 的事件,如 accept、connect、read、write 等,由 processSelectedKeys 方法觸發;
2. 非 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 的入站事件和出站操作。它實現了一種高級形式的攔截過濾器模式,使用戶可以完全控制事件的處理方式,以及 Channel 中各個的 ChannelHandler 如何相互交互。在 Netty 中每個 Channel 都有且僅有一個 ChannelPipeline 與之對應。
關於 Netty 的簡介就先說這么多。下面的章節就帶着 Socket 通信應該解決的問題 和 上面提到的Netty關鍵組件我們一起看看Netty是如何實現高性能網絡通信的。