[轉]:超詳細Netty 與 RPC!(高性能、Netty RPC 實現、RMI 實現方式、Thrift)

一、Netty 與 RPC

1.1. Netty 原理

Netty 是一個高性能、異步事件驅動的 NIO 框架,基於 JAVA NIO 提供的 API 實現。它提供了對TCP、UDP 和文件傳輸的支持,作爲一個異步 NIO 框架,Netty 的所有 IO 操作都是異步非阻塞的,通過 Future-Listener 機制,用戶可以方便的主動獲取或者通過通知機制獲得 IO 操作結果。

1.2. Netty 高性能

在 IO 編程過程中,當需要同時處理多個客戶端接入請求時,可以利用多線程或者 IO 多路複用技術進行處理。IO 多路複用技術通過把多個 IO 的阻塞複用到同一個 select 的阻塞上,從而使得系統在單線程的情況下可以同時處理多個客戶端請求。與傳統的多線程/多進程模型比,I/O 多路複用的最大優勢是系統開銷小,系統不需要創建新的額外進程或者線程,也不需要維護這些進程和線程的運行,降低了系統的維護工作量,節省了系統資源。與 Socket 類和 ServerSocket 類相對應,NIO 也提供了 SocketChannel 和 ServerSocketChannel兩種不同的套接字通道實現。

1.2.1. 多路複用通訊方式

Netty 架構按照 Reactor 模式設計和實現,它的服務端通信序列圖如下:

 

 


在這裏插入圖片描述

客戶端通信序列圖如下:

 

 


在這裏插入圖片描述

Netty 的 IO 線程 NioEventLoop 由於聚合了多路複用器 Selector,可以同時併發處理成百上千個客戶端 Channel,由於讀寫操作都是非阻塞的,這就可以充分提升 IO 線程的運行效率,避免由於頻繁 IO 阻塞導致的線程掛起。

1.2.2. 異步通訊 NIO

由於 Netty 採用了異步通信模式,一個 IO 線程可以併發處理 N 個客戶端連接和讀寫操作,這從根本上解決了傳統同步阻塞 IO 一連接一線程模型,架構的性能、彈性伸縮能力和可靠性都得到了極大的提升。

1.2.3. 零拷貝(DIRECT BUFFERS 使用堆外直接內存)

  1. Netty 的接收和發送 ByteBuffer 採用 DIRECT BUFFERS,使用堆外直接內存進行 Socket 讀寫,
    不需要進行字節緩衝區的二次拷貝。如果使用傳統的堆內存(HEAP BUFFERS)進行 Socket 讀寫,
    JVM 會將堆內存 Buffer 拷貝一份到直接內存中,然後才寫入 Socket 中。相比於堆外直接內存,
    消息在發送過程中多了一次緩衝區的內存拷貝。
  2. Netty 提供了組合 Buffer 對象,可以聚合多個 ByteBuffer 對象,用戶可以像操作一個 Buffer 那樣
    方便的對組合 Buffer 進行操作,避免了傳統通過內存拷貝的方式將幾個小 Buffer 合併成一個大的
    Buffer。
  3. Netty的文件傳輸採用了transferTo方法,它可以直接將文件緩衝區的數據發送到目標Channel,
    避免了傳統通過循環 write 方式導致的內存拷貝問題

1.2.4. 內存池(基於內存池的緩衝區重用機制)

隨着 JVM 虛擬機和 JIT 即時編譯技術的發展,對象的分配和回收是個非常輕量級的工作。但是對於緩衝區 Buffer,情況卻稍有不同,特別是對於堆外直接內存的分配和回收,是一件耗時的操作。爲了儘量重用緩衝區,Netty 提供了基於內存池的緩衝區重用機制。

1.2.5. 高效的 Reactor 線程模型

常用的 Reactor 線程模型有三種,Reactor 單線程模型, Reactor 多線程模型, 主從 Reactor 多線程模型。
Reactor 單線程模型
Reactor 單線程模型,指的是所有的 IO 操作都在同一個 NIO 線程上面完成,NIO 線程的職責如下:

  1. 作爲 NIO 服務端,接收客戶端的 TCP 連接;
  2. 作爲 NIO 客戶端,向服務端發起 TCP 連接;
  3. 讀取通信對端的請求或者應答消息;
  4. 向通信對端發送消息請求或者應答消息。

     


在這裏插入圖片描述

由於 Reactor 模式使用的是異步非阻塞 IO,所有的 IO 操作都不會導致阻塞,理論上一個線程可以獨立處理所有 IO 相關的操作。從架構層面看,一個 NIO 線程確實可以完成其承擔的職責。例如,通過Acceptor 接收客戶端的 TCP 連接請求消息,鏈路建立成功之後,通過 Dispatch 將對應的 ByteBuffer派發到指定的 Handler 上進行消息解碼。用戶 Handler 可以通過 NIO 線程將消息發送給客戶端。
Reactor 多線程模型
Rector 多線程模型與單線程模型最大的區別就是有一組 NIO 線程處理 IO 操作。 有專門一個
NIO 線程-Acceptor 線程用於監聽服務端,接收客戶端的 TCP 連接請求; 網絡 IO 操作-讀、寫等由一個 NIO 線程池負責,線程池可以採用標準的 JDK 線程池實現,它包含一個任務隊列和 N個可用的線程,由這些 NIO 線程負責消息的讀取、解碼、編碼和發送;

 

 

主從 Reactor 多線程模型
服務端用於接收客戶端連接的不再是個 1 個單獨的 NIO 線程,而是一個獨立的 NIO 線程池。
Acceptor 接收到客戶端 TCP 連接請求處理完成後(可能包含接入認證等),將新創建的
SocketChannel 註冊到 IO 線程池(sub reactor 線程池)的某個 IO 線程上,由它負責
SocketChannel 的讀寫和編解碼工作。Acceptor 線程池僅僅只用於客戶端的登陸、握手和安全認證,一旦鏈路建立成功,就將鏈路註冊到後端 subReactor 線程池的 IO 線程上,由 IO 線程負責後續的 IO 操作。
 

 

1.2.6. 無鎖設計、線程綁定

Netty 採用了串行無鎖化設計,在 IO 線程內部進行串行操作,避免多線程競爭導致的性能下降。表面上看,串行化設計似乎 CPU 利用率不高,併發程度不夠。但是,通過調整 NIO 線程池的線程參數,可以同時啓動多個串行化的線程並行運行,這種局部無鎖化的串行線程設計相比一個隊列-多個工作線程模型性能更優。

 

Netty 的 NioEventLoop 讀取到消息之後,直接調用 ChannelPipeline 的fireChannelRead(Object msg),只要用戶不主動切換線程,一直會由 NioEventLoop 調用
到用戶的 Handler,期間不進行線程切換,這種串行化處理方式避免了多線程操作導致的鎖
的競爭,從性能角度看是最優的。

1.2.7. 高性能的序列化框架

Netty 默認提供了對 Google Protobuf 的支持,通過擴展 Netty 的編解碼接口,用戶可以實現其它的高性能序列化框架,例如 Thrift 的壓縮二進制編解碼框架。

  1. SO_RCVBUF 和 SO_SNDBUF:通常建議值爲 128K 或者 256K。
    小包封大包,防止網絡阻塞
  2. SO_TCPNODELAY:NAGLE 算法通過將緩衝區內的小封包自動相連,組成較大的封包,阻止大量小封包的發送阻塞網絡,從而提高網絡應用效率。但是對於時延敏感的應用場景需要關閉該優化算法。軟中斷 Hash 值和 CPU 綁定
  3. 軟中斷:開啓 RPS 後可以實現軟中斷,提升網絡吞吐量。RPS 根據數據包的源地址,目的地址以
    及目的和源端口,計算出一個 hash 值,然後根據這個 hash 值來選擇軟中斷運行的 cpu,從上層
    來看,也就是說將每個連接和 cpu 綁定,並通過這個 hash 值,來均衡軟中斷在多個 cpu 上,提升
    網絡並行處理性能。

1.3. Netty RPC 實現

1.3.1. 概念

RPC,即 Remote Procedure Call(遠程過程調用),調用遠程計算機上的服務,就像調用本地服務一
樣。RPC 可以很好的解耦系統,如 WebService 就是一種基於 Http 協議的 RPC。這個 RPC 整體框架
如下:

 

 

1.3.2. 關鍵技術

  1. 服務發佈與訂閱:服務端使用 Zookeeper 註冊服務地址,客戶端從 Zookeeper 獲取可用的服務
    地址。
  2. 通信:使用 Netty 作爲通信框架。
  3. Spring:使用 Spring 配置服務,加載 Bean,掃描註解。
  4. 動態代理:客戶端使用代理模式透明化服務調用。
  5. 消息編解碼:使用 Protostuff 序列化和反序列化消息。

1.3.3. 核心流程

  1. 服務消費方(client)調用以本地調用方式調用服務;
  2. client stub 接收到調用後負責將方法、參數等組裝成能夠進行網絡傳輸的消息體;
  3. client stub 找到服務地址,並將消息發送到服務端;
  4. server stub 收到消息後進行解碼;
  5. server stub 根據解碼結果調用本地的服務;
  6. 本地服務執行並將結果返回給 server stub;
  7. server stub 將返回結果打包成消息併發送至消費方;
  8. client stub 接收到消息,並進行解碼;
  9. 服務消費方得到最終結果。
    RPC 的目標就是要 2~8 這些步驟都封裝起來,讓用戶對這些細節透明。JAVA 一般使用動態代理方式實現遠程調用。

     

1.3.4. 消息編解碼

息數據結構(接口名稱+方法名+參數類型和參數值+超時時間+ requestID)
客戶端的請求消息結構一般需要包括以下內容:

  1. 接口名稱:在我們的例子裏接口名是“HelloWorldService”,如果不傳,服務端就不知道調用哪個接口了;
  2. 方法名:一個接口內可能有很多方法,如果不傳方法名服務端也就不知道調用哪個方法;
  3. 參數類型和參數值:參數類型有很多,比如有 bool、int、long、double、string、map、list,甚至如 struct(class);以及相應的參數值;
  4. 超時時間:
  5. requestID,標識唯一請求 id,在下面一節會詳細描述 requestID 的用處。
  6. 服務端返回的消息 : 一般包括以下內容。返回值+狀態 code+requestID
    序列化
    目前互聯網公司廣泛使用 Protobuf、Thrift、Avro 等成熟的序列化解決方案來搭建 RPC 框架,這些都是久經考驗的解決方案。

1.3.5. 通訊過程

核心問題(線程暫停、消息亂序)
如果使用 netty 的話,一般會用 channel.writeAndFlush()方法來發送消息二進制串,這個方
法調用後對於整個遠程調用(從發出請求到接收到結果)來說是一個異步的,即對於當前線程來說,將請求發送出來後,線程就可以往後執行了,至於服務端的結果,是服務端處理完成後,再以消息的形式發送給客戶端的。於是這裏出現以下兩個問題:

  1. 怎麼讓當前線程“暫停”,等結果回來後,再向後執行?
  2. 如果有多個線程同時進行遠程方法調用,這時建立在 client server 之間的 socket 連接上
    會有很多雙方發送的消息傳遞,前後順序也可能是隨機的,server 處理完結果後,將結
    果消息發送給 client,client 收到很多消息,怎麼知道哪個消息結果是原先哪個線程調用
    的?如下圖所示,線程 A 和線程 B 同時向 client socket 發送請求 requestA 和 requestB,
    socket 先後將 requestB 和 requestA 發送至 server,而 server 可能將 responseB 先返
    回,儘管 requestB 請求到達時間更晚。我們需要一種機制保證 responseA 丟給
    ThreadA,responseB 丟給 ThreadB。

     

 

通訊流程
requestID 生成-AtomicLong

  1. client 線程每次通過 socket 調用一次遠程接口前,生成一個唯一的 ID,即 requestID
    (requestID 必需保證在一個 Socket 連接裏面是唯一的),一般常常使用 AtomicLong
    從 0 開始累計數字生成唯一 ID;
    存放回調對象 callback 到全局 ConcurrentHashMap
  2. 將處理結果的回調對象 callback ,存放到全局 ConcurrentHashMap 裏 面
    put(requestID, callback);
    synchronized 獲取回調對象 callback 的鎖並自旋 wait
  3. 當線程調用 channel.writeAndFlush()發送消息後,緊接着執行 callback 的 get()方法試
    圖獲取遠程返回的結果。在 get()內部,則使用 synchronized 獲取回調對象 callback 的
    鎖,再先檢測是否已經獲取到結果,如果沒有,然後調用 callback 的 wait()方法,釋放
    callback 上的鎖,讓當前線程處於等待狀態。
    監聽消息的線程收到消息,找到 callback 上的鎖並喚醒
  4. 服務端接收到請求並處理後,將 response 結果(此結果中包含了前面的 requestID)發
    送給客戶端,客戶端 socket 連接上專門監聽消息的線程收到消息,分析結果,取到
    requestID ,再從前面的 ConcurrentHashMap 裏 面 get(requestID) ,從而找到
    callback 對象,再用 synchronized 獲取 callback 上的鎖,將方法調用結果設置到
    callback 對象裏,再調用 callback.notifyAll()喚醒前面處於等待狀態的線程。

public Object get() { synchronized (this) { // 旋鎖 while (true) { // 是否有結果了 If (!isDone){ wait(); //沒結果釋放鎖,讓當前線程處於等待狀態 }else{//獲取數據並處理 } } } } private void setDone(Response res) { this.res = res; isDone = true; synchronized (this) { //獲取鎖,因爲前面 wait()已經釋放了 callback 的鎖了 notifyAll(); // 喚醒處於等待的線程 } }

1.4. RMI 實現方式

Java 遠程方法調用,即 Java RMI(Java Remote Method Invocation)是 Java 編程語言裏,一種用於實現遠程過程調用的應用程序編程接口。它使客戶機上運行的程序可以調用遠程服務器上的對象。遠
程方法調用特性使 Java 編程人員能夠在網絡環境中分佈操作。RMI 全部的宗旨就是儘可能簡化遠程接
口對象的使用。

1.4.1. 實現步驟

  1. 編寫遠程服務接口,該接口必須繼承 java.rmi.Remote 接口,方法必須拋出
    java.rmi.RemoteException 異常;
  2. 編寫遠程接口實現類,該實現類必須繼承 java.rmi.server.UnicastRemoteObject 類;
  3. 運行 RMI 編譯器(rmic),創建客戶端 stub 類和服務端 skeleton 類;
  4. 啓動一個 RMI 註冊表,以便駐留這些服務;
    13/04/2018 Page 156 of 283
  5. 在 RMI 註冊表中註冊服務;
  6. 客戶端查找遠程對象,並調用遠程方法;

1:創建遠程接口,繼承 java.rmi.Remote 接口
public interface GreetService extends java.rmi.Remote {
String sayHello(String name) throws RemoteException;
}2:實現遠程接口,繼承 java.rmi.server.UnicastRemoteObject 類
public class GreetServiceImpl extends java.rmi.server.UnicastRemoteObject implements GreetService { private static final long serialVersionUID = 3434060152387200042L;
public GreetServiceImpl() throws RemoteException {
super(); }
@Override
public String sayHello(String name) throws RemoteException {
return "Hello " + name; } }
3:生成 Stub 和 Skeleton;
4:執行 rmiregistry 命令註冊服務
5:啓動服務 LocateRegistry.createRegistry(1098); Naming.bind("rmi://10.108.1.138:1098/GreetService", new GreetServiceImpl());
6.客戶端調用 GreetService greetService = (GreetService) Naming.lookup("rmi://10.108.1.138:1098/GreetService"); System.out.println(greetService.sayHello("Jobs"));

1.5. Protoclol Buffer

protocol buffer 是 google 的一個開源項目,它是用於結構化數據串行化的靈活、高效、自動的方法,例如 XML,不過它比 xml 更小、更快、也更簡單。你可以定義自己的數據結構,然後使用代碼生成器生成的代碼來讀寫這個數據結構。你甚至可以在無需重新部署程序的情況下更新數據結構。

1.5.1. 特點

 

 

Protocol Buffer 的序列化 & 反序列化簡單 & 速度快的原因是:

  1. 編碼 / 解碼 方式簡單(只需要簡單的數學運算 = 位移等等)
  2. 採用 Protocol Buffer 自身的框架代碼 和 編譯器 共同完成
    Protocol Buffer 的數據壓縮效果好(即序列化後的數據量體積小)的原因是:
  3. a. 採用了獨特的編碼方式,如 Varint、Zigzag 編碼方式等等
  4. b. 採用 T - L - V 的數據存儲方式:減少了分隔符的使用 & 數據存儲得緊湊

1.6. Thrift

Apache Thrift 是 Facebook 實現的一種高效的、支持多種編程語言的遠程服務調用的框架。本文將從Java 開發人員角度詳細介紹 Apache Thrift 的架構、開發和部署,並且針對不同的傳輸協議和服務類型給出相應的 Java 實例,同時詳細介紹 Thrift 異步客戶端的實現,最後提出使用 Thrift 需要注意的事項。
目前流行的服務調用方式有很多種,例如基於 SOAP 消息格式的 Web Service,基於 JSON 消息格式的 RESTful 服務等。其中所用到的數據傳輸方式包括 XML,JSON 等,然而 XML 相對體積太大,傳輸效率低,JSON 體積較小,新穎,但還不夠完善。本文將介紹由 Facebook 開發的遠程服務調用框架Apache Thrift,它採用接口描述語言定義並創建服務,支持可擴展的跨語言服務開發,所包含的代碼生成引擎可以在多種語言中,如 C++, Java, Python, PHP, Ruby, Erlang, Perl, Haskell, C#, Cocoa, Smalltalk 等創建高效的、無縫的服務,其傳輸數據採用二進制格式,相對 XML 和 JSON 體積更小,
對於高併發、大數據量和多語言的環境更有優勢。本文將詳細介紹 Thrift 的使用,並且提供豐富的實例代碼加以解釋說明,幫助使用者快速構建服務。
爲什麼要 Thrift: 1、多語言開發的需要 2、性能問題

 

 

 

 

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