Netty 學習和進階策略

《Netty 進階之路》、《分佈式服務框架原理與實踐》作者李林鋒手把手教你Netty框架如何學習和進階。李林鋒此後還將在InfoQ上開設Netty專題持續出稿,感興趣的同學可以持續關注。

背景

Netty框架的特點

Netty的一個特點就是入門相對比較容易,但是真正掌握並精通是非常困難的,原因有如下幾個:

  1. 涉及的知識面比較廣:Netty作爲一個高性能的NIO通信框架,涉及到的知識點包括網絡通信、多線程編程、序列化和反序列化、異步和同步編程模型、SSL/TLS安全、內存池、HTTP、MQTT等各種協議棧,這些知識點在Java語言中本身就是難點和重點,如果對這些基礎知識掌握不紮實,是很難真正掌握好Netty的。
  2. 調試比較困難:因爲大量使用異步編程接口,以及消息處理過程中的各種線程切換,相比於傳統同步代碼,調試難度比較大。
  3. 類繼承層次比較深,有些代碼很晦澀(例如內存池、Reactor線程模型等),對於初學者而言,通過閱讀代碼來掌握Netty難度還是比較大的。
  4. 代碼規模龐大:目前,Netty的代碼規模已經非常龐大,特別是協議棧部分,提供了對HTTP/2、MQTT、WebSocket、SMTP等多種協議的支持,相關代碼非常多。如果學習方式不當,抓不住重點,全量閱讀Netty源碼,既耗時又很難吃透,很容易半途而廢。
  5. 資料比較零散,缺乏實踐相關的案例:網上各種Netty的資料非常多,但是以理論講解爲主,Netty在各行業中的應用、問題定位技巧以及案例實踐方面的資料很少,缺乏系統性的實踐總結,也是Netty學習的一大痛點。

初學者常見問題

對於很多初學者,在學習過程中經常會遇到如下幾個問題:

  1. 相關領域知識的儲備不足:想了解學習Netty需要儲備哪些技能,掌握哪些知識點,有什麼學習技巧可以更快的掌握Netty。由於對Java多線程編程、Socket通信、TCP/IP協議棧等知識掌握不紮實,後續在學習Netty的過程中會遇到很多困難。
  2. 理論學習完,實踐遇到難題:學習完理論知識之後,想在實際項目中使用,但是真正跟具體項目結合在一起解決實際問題時,又感覺比較棘手,不知道自己使用的方式是否最優,希望能夠多學一些案例實踐方面的知識,以便更好的在業務中使用Netty。
  3. 出了問題不會定位:在項目中遇到了問題,但是由於對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屬性,它的屬性集定義如下:

private final Map<ChannelOption<?>, Object> options = new LinkedHashMap<ChannelOption<?>, Object>();

由於是非線程安全的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中讀取時,由於少了一次內存複製,速度比堆內存快。

2、Channel和Unsafe:io.netty.channel.Channel是Netty網絡操作抽象類,它聚合了一組功能,包括但不限於網路的讀、寫,客戶端發起連接、主動關閉連接,鏈路關閉,獲取通信雙方的網絡地址等。它也包含了Netty框架相關的一些功能,包括獲取該Chanel的EventLoop,獲取緩衝分配器ByteBufAllocator和pipeline等。Unsafe是個內部接口,聚合在Channel中協助進行網絡讀寫相關的操作,它提供的主要功能如下表所示:

Unsafe API功能列表

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接收不到消息,可能的原因如下:

  1. 業務的解碼ChannelHandler存在BUG,導致消息解碼失敗,沒有投遞到後端。
  2. 業務發送的是畸形或者錯誤碼流(例如長度錯誤),導致業務解碼ChannelHandler無法正確解碼出業務消息。
  3. 業務ChannelHandler執行了一些耗時或者阻塞操作,導致Netty的NioEventLoop被掛住,無法讀取消息。
  4. 執行業務ChannelHandler的線程池隊列積壓,導致新接收的消息在排隊,沒有得到及時處理。
  5. 對方確實沒有發送消息。

定位策略如下:

  1. 在業務的首個ChannelHandler的channelRead方法中打斷點調試,看是否讀取到消息。
  2. 在ChannelHandler中添加LoggingHandler,打印接口日誌。
  3. 查看NioEventLoop線程狀態,看是否發生了阻塞。
  4. 通過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進階之路》、《分佈式服務框架原理與實踐》作者。目前在華爲終端應用市場負責業務微服務化、雲化、全球化等相關設計和開發工作。

聯繫方式

新浪微博 Nettying
微信:Nettying
Email:[email protected]

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章