超詳細Netty 與 RPC!看了受益匪淺!(原理、高性能、Netty RPC 實現(xiàn)、RMI 實現(xiàn)方式、Thrift)

一、Netty 與 RPC

1.1. Netty 原理

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

1.2. Netty 高性能

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

1.2.1. 多路復用通訊方式

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


在這里插入圖片描述

客戶端通信序列圖如下:


在這里插入圖片描述

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

1.2.2. 異步通訊 NIO

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

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

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

1.2.4. 內存池(基于內存池的緩沖區(qū)重用機制)

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

1.2.5. 高效的 Reactor 線程模型

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

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


    在這里插入圖片描述

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

在這里插入圖片描述

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

在這里插入圖片描述

1.2.6. 無鎖設計、線程綁定

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


在這里插入圖片描述

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

1.2.7. 高性能的序列化框架

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

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

1.3. Netty RPC 實現(xiàn)

1.3.1. 概念

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


在這里插入圖片描述

1.3.2. 關鍵技術

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

1.3.3. 核心流程

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


    在這里插入圖片描述

1.3.4. 消息編解碼

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

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

1.3.5. 通訊過程

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

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


    在這里插入圖片描述

通訊流程
requestID 生成-AtomicLong

  1. client 線程每次通過 socket 調用一次遠程接口前,生成一個唯一的 ID,即 requestID
    (requestID 必需保證在一個 Socket 連接里面是唯一的),一般常常使用 AtomicLong
    從 0 開始累計數(shù)字生成唯一 ID;
    存放回調對象 callback 到全局 ConcurrentHashMap
  2. 將處理結果的回調對象 callback ,存放到全局 ConcurrentHashMap 里 面
    put(requestID, callback);
    synchronized 獲取回調對象 callback 的鎖并自旋 wait
  3. 當線程調用 channel.writeAndFlush()發(fā)送消息后,緊接著執(zhí)行 callback 的 get()方法試
    圖獲取遠程返回的結果。在 get()內部,則使用 synchronized 獲取回調對象 callback 的
    鎖,再先檢測是否已經(jīng)獲取到結果,如果沒有,然后調用 callback 的 wait()方法,釋放
    callback 上的鎖,讓當前線程處于等待狀態(tài)。
    監(jiān)聽消息的線程收到消息,找到 callback 上的鎖并喚醒
  4. 服務端接收到請求并處理后,將 response 結果(此結果中包含了前面的 requestID)發(fā)
    送給客戶端,客戶端 socket 連接上專門監(jiān)聽消息的線程收到消息,分析結果,取到
    requestID ,再從前面的 ConcurrentHashMap 里 面 get(requestID) ,從而找到
    callback 對象,再用 synchronized 獲取 callback 上的鎖,將方法調用結果設置到
    callback 對象里,再調用 callback.notifyAll()喚醒前面處于等待狀態(tài)的線程。
 public Object get() {
 synchronized (this) { // 旋鎖
 while (true) { // 是否有結果了
If (!isDone){
 wait(); //沒結果釋放鎖,讓當前線程處于等待狀態(tài)
}else{//獲取數(shù)據(jù)并處理
}
 }
 }
 }
private void setDone(Response res) {
 this.res = res;
 isDone = true;
 synchronized (this) { //獲取鎖,因為前面 wait()已經(jīng)釋放了 callback 的鎖了
 notifyAll(); // 喚醒處于等待的線程
 }
 }

1.4. RMI 實現(xiàn)方式

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

1.4.1. 實現(xiàn)步驟

  1. 編寫遠程服務接口,該接口必須繼承 java.rmi.Remote 接口,方法必須拋出
    java.rmi.RemoteException 異常;
  2. 編寫遠程接口實現(xiàn)類,該實現(xiàn)類必須繼承 java.rmi.server.UnicastRemoteObject 類;
  3. 運行 RMI 編譯器(rmic),創(chuàng)建客戶端 stub 類和服務端 skeleton 類;
  4. 啟動一個 RMI 注冊表,以便駐留這些服務;
    13/04/2018 Page 156 of 283
  5. 在 RMI 注冊表中注冊服務;
  6. 客戶端查找遠程對象,并調用遠程方法;
1:創(chuàng)建遠程接口,繼承 java.rmi.Remote 接口
public interface GreetService extends java.rmi.Remote {
 String sayHello(String name) throws RemoteException;
}2:實現(xiàn)遠程接口,繼承 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:執(zhí)行 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 的一個開源項目,它是用于結構化數(shù)據(jù)串行化的靈活、高效、自動的方法,例如 XML,不過它比 xml 更小、更快、也更簡單。你可以定義自己的數(shù)據(jù)結構,然后使用代碼生成器生成的代碼來讀寫這個數(shù)據(jù)結構。你甚至可以在無需重新部署程序的情況下更新數(shù)據(jù)結構。

1.5.1. 特點

在這里插入圖片描述

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

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

1.6. Thrift

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


在這里插入圖片描述
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容