Netty學習和進階策略
作者 | 李林鋒
編輯 | 小智
《Netty 進階之路》、《分散式服務框架原理與實踐》作者李林鋒手把手教你 Netty 框架如何學習和進階。李林鋒此後還將在 InfoQ 公眾號上開設 Netty 專題持續出稿,感興趣的同學可以持續關注。
背 景
Netty 框架的特點
Netty 的一個特點就是入門相對比較容易,但是真正掌握並精通是非常困難的,原因有如下幾個:
涉及的知識面比較廣:Netty 作為一個高性能的 NIO 通信框架,涉及到的知識點包括網路通信、多線程編程、序列化和反序列化、非同步和同步編程模型、SSL/TLS 安全、內存池、HTTP、MQTT 等各種協議棧,這些知識點在 Java 語言中本身就是難點和重點,如果對這些基礎知識掌握不紮實,是很難真正掌握好 Netty 的。
調試比較困難:因為大量使用非同步編程介面,以及消息處理過程中的各種線程切換,相比於傳統同步代碼,調試難度比較大。
類繼承層次比較深,有些代碼很晦澀(例如內存池、Reactor 線程模型等),對於初學者而言,通過閱讀代碼來掌握 Netty 難度還是比較大的。
代碼規模龐大:目前,Netty 的代碼規模已經非常龐大,特別是協議棧部分,提供了對 HTTP/2、MQTT、WebSocket、SMTP 等多種協議的支持,相關代碼非常多。如果學習方式不當,抓不住重點,全量閱讀 Netty 源碼,既耗時又很難吃透,很容易半途而廢。
資料比較零散,缺乏實踐相關的案例:網上各種 Netty 的資料非常多,但是以理論講解為主,Netty 在各行業中的應用、問題定位技巧以及案例實踐方面的資料很少,缺乏系統性的實踐總結,也是 Netty 學習的一大痛點。
初學者常見問題
對於很多初學者,在學習過程中經常會遇到如下幾個問題:
相關領域知識的儲備不足:想了解學習 Netty 需要儲備哪些技能,掌握哪些知識點,有什麼學習技巧可以更快的掌握 Netty。由於對 Java 多線程編程、Socket 通信、TCP/IP 協議棧等知識掌握不紮實,後續在學習 Netty 的過程中會遇到很多困難。
理論學習完,實踐遇到難題:學習完理論知識之後,想在實際項目中使用,但是真正跟具體項目結合在一起解決實際問題時,又感覺比較棘手,不知道自己使用的方式是否最優,希望能夠多學一些案例實踐方面的知識,以便更好的在業務中使用 Netty。
出了問題不會定位:在項目中遇到了問題,但是由於對 Netty 底層細節掌握不紮實,無法有效的定位並解決問題,只能靠網上搜索相關案例來參考,問題解決效率比較低,甚至束手無策。
Netty 學習策略
Netty 入門相對簡單,但是要在實際項目中用好它,出了問題能夠快速定位和解決,卻並非易事。只有在入門階段紮實的學好 Netty,後面使用才能夠得心應手。
入門知識準備
Java NIO 類庫
需要熟悉和掌握的類庫主要包括:
緩衝區 Buffer。
通道 Channel。
多路復用器 Selector。
首先介紹緩衝區(Buffer)的概念,Buffer 是一個對象,它包含一些要寫入或者要讀出的數據。在 NIO 類庫中加入 Buffer 對象,體現了新庫與原 I/O 的一個重要區別。在面向流的 I/O 中,可以將數據直接寫入或者將數據直接讀到 Stream 對象中。在 NIO 庫中,所有數據都是用緩衝區處理的。在讀取數據時,它是直接讀到緩衝區中的;在寫入數據時,寫入到緩衝區中。任何時候訪問 NIO 中的數據,都是通過緩衝區進行操作。
緩衝區實質上是一個數組。通常它是一個位元組數組(ByteBuffer),也可以使用其他種類的數組。但是一個緩衝區不僅僅是一個數組,緩衝區提供了對數據的結構化訪問以及維護讀寫位置(limit)等信息。
最常用的緩衝區是 ByteBuffer,一個 ByteBuffer 提供了一組功能用於操作 byte 數組。比較常用的就是 get 和 put 系列方法,如下所示:
圖 1 ByteBuffer 常用介面定義
Channel 是一個通道,可以通過它讀取和寫入數據,它就像自來水管一樣,網路數據通過 Channel 讀取和寫入。通道與流的不同之處在於通道是雙向的,流只是在一個方向上移動(一個流必須是 InputStream 或者 OutputStream 的子類),而且通道可以用於讀、寫或者同時用於讀寫。因為 Channel 是全雙工的,所以它可以比流更好地映射底層操作系統的 API。特別是在 UNIX 網路編程模型中,底層操作系統的通道都是全雙工的,同時支持讀寫操作。
比較常用的 Channel 是 SocketChannel 和 ServerSocketChannel,其中 SocketChannel 的繼承關係如下圖所示:
圖 2 SocketChannel 繼承關係
Selector 是 Java NIO 編程的基礎,熟練地掌握 Selector 對於掌握 NIO 編程至關重要。多路復用器提供選擇已經就緒的任務的能力。簡單來講,Selector 會不斷地輪詢註冊在其上的 Channel,如果某個 Channel 上面有新的 TCP 連接接入、讀和寫事件,這個 Channel 就處於就緒狀態,會被 Selector 輪詢出來,然後通過 SelectionKey 可以獲取就緒 Channel 的集合,進行後續的 I/O 操作。
Java 多線程編程
作為非同步事件驅動、高性能的 NIO 框架,Netty 代碼中大量運用了 Java 多線程編程技巧,熟練掌握多線程編程是掌握 Netty 的必備條件。
需要掌握的多線程編程相關知識包括:
Java 內存模型。
關鍵字 synchronized。
讀寫鎖。
volatile 的正確使用。
CAS 指令和原子類。
JDK 線程池以及各種默認實現。
以關鍵字 synchronized 為例,它可以保證在同一時刻,只有一個線程可以執行某一個方法或者代碼塊。同步的作用不僅僅是互斥,它的另一個作用就是共享可變性,當某個線程修改了可變數據並釋放鎖後,其它的線程可以獲取被修改變數的最新值。如果沒有正確的同步,這種修改對其它線程是不可見的。
下面我們就通過對 Netty 的源碼進行分析,看看 Netty 是如何對並發可變數據進行正確同步的。以 AbstractBootstrap 為例進行分析,首先看它的 option 方法:
這個方法的作用是設置 ServerBootstrap 或 Bootstrap 的 Socket 屬性,它的屬性集定義如下:
由於是非線程安全的 LinkedHashMap, 所以如果多線程創建、訪問和修改 LinkedHashMap 時,必須在外部進行必要的同步。由於 ServerBootstrap 和 Bootstrap 被調用方線程創建和使用,無法保證它的方法和成員變數不被並發訪問。因此,作為成員變數的 options 必須進行正確的同步。由於考慮到鎖的範圍需要儘可能的小,所以對傳參的 option 和 value 的合法性判斷不需要加鎖,保證鎖的範圍儘可能的細粒度。
Netty 加鎖的地方非常多,大家在閱讀代碼的時候可以仔細體會下,為什麼有的地方要加鎖,有的地方有不需要?如果不需要,為什麼?當你對鎖的原理理解以後,對於這些鎖的使用時機和技巧理解起來就相對容易了。
Netty 源碼學習
關鍵類庫學習
Netty 的核心類庫可以分為 5 大類,需要熟練掌握:
1、ByteBuf 和相關輔助類:ByteBuf 是個 Byte 數組的緩衝區,它的基本功能應該與 JDK 的 ByteBuffer 一致,提供以下幾類基本功能:
7 種 Java 基礎類型、byte 數組、ByteBuffer(ByteBuf)等的讀寫。
緩衝區自身的 copy 和 slice 等。
設置網路位元組序。
構造緩衝區實例。
操作位置指針等方法。
動態的擴展和收縮。
從內存分配的角度看,ByteBuf 可以分為兩類:堆內存(HeapByteBuf)位元組緩衝區:特點是內存的分配和回收速度快,可以被 JVM 自動回收;缺點就是如果進行 Socket 的 I/O 讀寫,需要額外做一次內存複製,將堆內存對應的緩衝區複製到內核 Channel 中,性能會有一定程度的下降。直接內存(DirectByteBuf)位元組緩衝區:非堆內存,它在堆外進行內存分配,相比於堆內存,它的分配和回收速度會慢一些,但是將它寫入或者從 Socket Channel 中讀取時,由於少了一次內存複製,速度比堆內存快。
UnsafeAPI 功能列表
3、ChannelPipeline 和 ChannelHandler: Netty 的 ChannelPipeline 和 ChannelHandler 機制類似於 Servlet 和 Filter 過濾器,這類攔截器實際上是職責鏈模式的一種變形,主要是為了方便事件的攔截和用戶業務邏輯的定製。Servlet Filter 是 JEE Web 應用程序級的 Java 代碼組件,它能夠以聲明的方式插入到 HTTP 請求響應的處理過程中,用於攔截請求和響應,以便能夠查看、提取或以某種方式操作正在客戶端和伺服器之間交換的數據。
攔截器封裝了業務定製邏輯,能夠實現對 Web 應用程序的預處理和事後處理。過濾器提供了一種面向對象的模塊化機制,用來將公共任務封裝到可插入的組件中。
這些組件通過 Web 部署配置文件(web.xml)進行聲明,可以方便地添加和刪除過濾器,無須改動任何應用程序代碼或 JSP 頁面,由 Servlet 進行動態調用。通過在請求 / 響應鏈中使用過濾器,可以對應用程序(而不是以任何方式替代)的 Servlet 或 JSP 頁面提供的核心處理進行補充,而不破壞 Servlet 或 JSP 頁面的功能。由於是純 Java 實現,所以 Servlet 過濾器具有跨平台的可重用性,使得它們很容易地被部署到任何符合 Servlet 規範的 JEE 環境中。
Netty 的 Channel 過濾器實現原理與 Servlet Filter 機制一致,它將 Channel 的數據管道抽象為 ChannelPipeline,消息在 ChannelPipeline 中流動和傳遞。ChannelPipeline 持有 I/O 事件攔截器 ChannelHandler 的鏈表,由 ChannelHandler 對 I/O 事件進行攔截和處理,可以方便地通過新增和刪除 ChannelHandler 來實現不同的業務邏輯定製,不需要對已有的 ChannelHandler 進行修改,能夠實現對修改封閉和對擴展的支持。ChannelPipeline 是 ChannelHandler 的容器,它負責 ChannelHandler 的管理和事件攔截與調度:
圖 3 ChannelPipeline 對事件流的攔截和處理流
Netty 中的事件分為 inbound 事件和 outbound 事件。inbound 事件通常由 I/O 線程觸發,例如 TCP 鏈路建立事件、鏈路關閉事件、讀事件、異常通知事件等。
Outbound 事件通常是由用戶主動發起的網路 I/O 操作,例如用戶發起的連接操作、綁定操作、消息發送等操作。ChannelHandler 類似於 Servlet 的 Filter 過濾器,負責對 I/O 事件或者 I/O 操作進行攔截和處理,它可以選擇性地攔截和處理自己感興趣的事件,也可以透傳和終止事件的傳遞。基於 ChannelHandler 介面,用戶可以方便地進行業務邏輯定製,例如列印日誌、統一封裝異常信息、性能統計和消息編解碼等。
4、EventLoop:Netty 的 NioEventLoop 並不是一個純粹的 I/O 線程,它除了負責 I/O 的讀寫之外,還兼顧處理以下兩類任務:
普通 Task:通過調用 NioEventLoop 的 execute(Runnable task) 方法實現,Netty 有很多系統 Task,創建它們的主要原因是:當 I/O 線程和用戶線程同時操作網路資源時,為了防止並發操作導致的鎖競爭,將用戶線程的操作封裝成 Task 放入消息隊列中,由 I/O 線程負責執行,這樣就實現了局部無鎖化。
定時任務:通過調用 NioEventLoop 的 schedule(Runnable command, long delay, TimeUnit unit) 方法實現。
Netty 的線程模型並不是一成不變的,它實際取決於用戶的啟動參數配置。通過設置不同的啟動參數,Netty 可以同時支持 Reactor 單線程模型、多線程模型和主從 Reactor 多線層模型。它的工作原理如下所示:
圖 4 Netty 的線程模型
通過調整線程池的線程個數、是否共享線程池等方式,Netty 的 Reactor 線程模型可以在單線程、多線程和主從多線程間切換,這種靈活的配置方式可以最大程度地滿足不同用戶的個性化定製。
為了儘可能地提升性能,Netty 在很多地方進行了無鎖化的設計,例如在 I/O 線程內部進行串列操作,避免多線程競爭導致的性能下降問題。表面上看,串列化設計似乎 CPU 利用率不高,並發程度不夠。但是,通過調整 NIO 線程池的線程參數,可以同時啟動多個串列化的線程並行運行,這種局部無鎖化的串列線程設計相比一個隊列—多個工作線程的模型性能更優。它的設計原理如下圖所示:
圖 5 NioEventLoop 串列執行 ChannelHandler
5、Future 和 Promise:在 Netty 中,所有的 I/O 操作都是非同步的,這意味著任何 I/O 調用都會立即返回,而不是像傳統 BIO 那樣同步等待操作完成。非同步操作會帶來一個問題:調用者如何獲取非同步操作的結果?ChannelFuture 就是為了解決這個問題而專門設計的。下面我們一起看它的原理。ChannelFuture 有兩種狀態:uncompleted 和 completed。當開始一個 I/O 操作時,一個新的 ChannelFuture 被創建,此時它處於 uncompleted 狀態——非失敗、非成功、非取消,因為 I/O 操作此時還沒有完成。一旦 I/O 操作完成,ChannelFuture 將會被設置成 completed,它的結果有如下三種可能:
操作成功。
操作失敗。
操作被取消。
ChannelFuture 的狀態遷移圖如下所示:
圖 6 ChannelFuture 狀態遷移圖
Promise 是可寫的 Future,Future 自身並沒有寫操作相關的介面,Netty 通過 Promise 對 Future 進行擴展,用於設置 I/O 操作的結果,它的介面定義如下:
圖 7 Netty 的 Promise 介面定義
關鍵流程學習
需要重點掌握 Netty 服務端和客戶端的創建,以及創建過程中使用到的核心類庫和 API、以及消息的發送和接收、消息的編解碼。
Netty 服務端創建流程如下:
圖 8 Netty 服務端創建流程
Netty 客戶端創建流程如下:
圖 9 Netty 客戶端創建流程
Netty 項目實踐
實踐主要分為兩類,如果項目中需要用到 Netty,則直接在項目中應用,通過實踐來不斷提升對 Netty 的理解和掌握。如果暫時使用不到,則可以通過學習一些開源的 RPC 或者服務框架,看這些框架是怎麼集成並使用 Netty 的。以 gRPC Java 版為例,我們一起看下 gRPC 是如何使用 Netty 的。
gRPC 服務端
gRPC 通過對 Netty HTTP/2 的封裝,向用戶屏蔽底層 RPC 通信的協議細節,Netty HTTP/2 服務端的創建流程如下:
圖 10 Netty HTTP/2 服務端創建流程
服務端 HTTP/2 消息的讀寫主要通過 gRPC 的 NettyServerHandler 實現,它的類繼承關係如下所示:
圖 11 gRPC NettyServerHandler 類繼承關係
從類繼承關係可以看出,NettyServerHandler 主要負責 HTTP/2 協議消息相關的處理,例如 HTTP/2 請求消息體和消息頭的讀取、Frame 消息的發送、Stream 狀態消息的處理等,相關介面定義如下:
圖 12 NettyServerHandler 處理 HTTP/2 協議消息相關介面
gRPC 客戶端
gRPC 的客戶端調用主要包括基於 Netty 的 HTTP/2 客戶端創建、客戶端負載均衡、請求消息的發送和響應接收處理四個流程,gRPC 的客戶端調用總體流程如下圖所示:
gRPC 的客戶端調用總體流程如下圖所示:
圖 13 gRPC 客戶端總體調用流程
gRPC 的客戶端調用流程如下:
客戶端 Stub(GreeterBlockingStub) 調用 sayHello(request),發起 RPC 調用。
通過 DnsNameResolver 進行域名解析,獲取服務端的地址信息(列表),隨後使用默認的 LoadBalancer 策略,選擇一個具體的 gRPC 服務端實例。
如果與路由選中的服務端之間沒有可用的連接,則創建 NettyClientTransport 和 NettyClientHandler,發起 HTTP/2 連接。
對請求消息使用 PB(Protobuf)做序列化,通過 HTTP/2 Stream 發送給 gRPC 服務端。
接收到服務端響應之後,使用 PB(Protobuf)做反序列化。
回調 GrpcFuture 的 set(Response) 方法,喚醒阻塞的客戶端調用線程,獲取 RPC 響應。
需要指出的是,客戶端同步阻塞 RPC 調用阻塞的是調用方線程(通常是業務線程),底層 Transport 的 I/O 線程(Netty 的 NioEventLoop)仍然是非阻塞的。
線程模型
gRPC 服務端線程模型整體上可以分為兩大類:
網路通信相關的線程模型,基於 Netty4.1 的線程模型實現。
服務介面調用線程模型,基於 JDK 線程池實現。
gRPC 服務端線程模型和交互圖如下所示:
圖 14 gRPC 服務端線程模型
其中,HTTP/2 服務端創建、HTTP/2 請求消息的接入和響應發送都由 Netty 負責,gRPC 消息的序列化和反序列化、以及應用服務介面的調用由 gRPC 的 SerializingExecutor 線程池負責。
gRPC 客戶端的線程主要分為三類:
業務調用線程
客戶端連接和 I/O 讀寫線程
請求消息業務處理和響應回調線程
gRPC 客戶端線程模型工作原理如下圖所示(同步阻塞調用為例):
圖 15 客戶端調用線程模型
客戶端調用主要涉及的線程包括:
應用線程,負責調用 gRPC 服務端並獲取響應,其中請求消息的序列化由該線程負責。
客戶端負載均衡以及 Netty Client 創建,由 grpc-default-executor 線程池負責。
HTTP/2 客戶端鏈路創建、網路 I/O 數據的讀寫,由 Netty NioEventLoop 線程負責。
響應消息的反序列化由 SerializingExecutor 負責,與服務端不同的是,客戶端使用的是 ThreadlessExecutor,並非 JDK 線程池。
SerializingExecutor 通過調用 responseFuture 的 set(value),喚醒阻塞的應用線程,完成一次 RPC 調用。
gRPC 採用的是網路 I/O 線程和業務調用線程分離的策略,大部分場景下該策略是最優的。但是,對於那些介面邏輯非常簡單,執行時間很短,不需要與外部網元交互、訪問資料庫和磁碟,也不需要等待其它資源的,則建議介面調用直接在 Netty /O 線程中執行,不需要再投遞到後端的服務線程池。避免線程上下文切換,同時也消除了線程並發問題。
例如提供配置項或者介面,系統默認將消息投遞到後端服務調度線程,但是也支持短路策略,直接在 Netty 的 NioEventLoop 中執行消息的序列化和反序列化、以及服務介面調用。
減少鎖競爭優化:當前 gRPC 的線程切換策略如下:
圖 16 gRPC 線程鎖競爭
優化之後的 gRPC 線程切換策略:
圖 17 gRPC 線程鎖競爭優化
通過線程綁定技術(例如採用一致性 hash 做映射), 將 Netty 的 I/O 線程與後端的服務調度線程做綁定,1 個 I/O 線程綁定一個或者多個服務調用線程,降低鎖競爭,提升性能。
Netty 故障定位技巧
儘管 Netty 應用廣泛,非常成熟,但是由於對 Netty 底層機制不太了解,用戶在實際使用中還是會經常遇到各種問題,大部分問題都是業務使用不當導致的。Netty 使用者需要學習 Netty 的故障定位技巧,以便出了問題能夠獨立、快速的解決。『』
接收不到消息
如果業務的 ChannelHandler 接收不到消息,可能的原因如下:
業務的解碼 ChannelHandler 存在 BUG,導致消息解碼失敗,沒有投遞到後端。
業務發送的是畸形或者錯誤碼流(例如長度錯誤),導致業務解碼 ChannelHandler 無法正確解碼出業務消息。
業務 ChannelHandler 執行了一些耗時或者阻塞操作,導致 Netty 的 NioEventLoop 被掛住,無法讀取消息。
執行業務 ChannelHandler 的線程池隊列積壓,導致新接收的消息在排隊,沒有得到及時處理。
對方確實沒有發送消息。
定位策略如下:
在業務的首個 ChannelHandler 的 channelRead 方法中打斷點調試,看是否讀取到消息。
在 ChannelHandler 中添加 LoggingHandler,列印介面日誌。
查看 NioEventLoop 線程狀態,看是否發生了阻塞。
通過 tcpdump 抓包看消息是否發送成功。
內存泄漏
通過 jmap -dump:format=b,file=xx pid 命令 Dump 內存堆棧,然後使用 MemoryAnalyzer 工具對內存佔用進行分析,查找內存泄漏點,然後結合代碼進行分析,定位內存泄漏的具體原因,示例如下所示:
圖 18 通過 MemoryAnalyzer 工具分析內存堆棧
性能問題
如果出現性能問題,首先需要確認是 Netty 問題還是業務問題,通過 jstack 命令或者 jvisualvm 工具列印線程堆棧,按照線程 CPU 使用率進行排序(top -Hp 命令採集),看線程在忙什麼。通常如果採集幾次都發現 Netty 的 NIO 線程堆棧停留在 select 操作上,說明 I/O 比較空閑,性能瓶頸不在 Netty,需要繼續分析看是否是後端的業務處理線程存在性能瓶頸:
圖 19 Netty NIO 線程運行堆棧
如果發現性能瓶頸在網路 I/O 讀寫上,可以適當調大 NioEventLoopGroup 中的 work I/O 線程數,直到 I/O 處理性能能夠滿足業務需求。
作者介紹
李林鋒,10 年 Java NIO、平台中間件設計和開發經驗,精通 Netty、Mina、分散式服務框架、API Gateway、PaaS 等,《Netty 進階之路》、《分散式服務框架原理與實踐》作者。目前在華為終端應用市場負責業務微服務化、雲化、全球化等相關設計和開發工作。聯繫方式:
點一下好看試試微信的新功能?
TAG:InfoQ |