一、Netty原理
Netty是一個高性能、異步事件驅動的NIO框架,基於Java NIO提供的API實現。它提供了對TCP、UDP和文件傳輸的支持,作為一個異步NIO框架,Netty的所有IO操作都是異步非阻塞的,通過Future-Listener機制,用戶可以方便的主動獲取或通過通知機制獲得IO操作結果。
二、Netty的高性能
在IO編程過程中,當需要同時處理多個客戶端接入請求時,可以利用多線程或IO多路復用技術進行處理。IO多路復用技術通過多個IO阻塞復用到同一個select的阻塞上,從而使得系統在單線程的情況下可以同時處理多個客戶端請求。與傳統的多線程/多進程模型相比,IO多路復用的最大優勢是系統開銷小,系統不需要創建新的額外進程或線程,也不需要維護這些進程和線程的運行,降低了系統的維護工作量,節省了系統資源。
與socket類和serversocket類相對應,NIO也提供了socketchannel和serversocketchannel兩種不同的套接字通道實現。
1.多路復用通訊方式
Netty架構按照Reactor模式設計和實現,它的服務端通信序列圖如下:
客戶端通信序列圖如下:
Netty的IO線程NIOEventLoop由於聚合了多路復用器Selector,可以同時並發處理成敗上千個客戶端Channel,由於讀寫操作都是非阻塞的,這就可以充分提升IO線程的運行效率,避免由於頻繁IO阻塞導致的線程掛起。
2.異步通訊NIO
由於Netty采用了異步通信模式,一個IO線程可以並發處理N個客戶端連接和讀寫操作,這從根本上解決了傳統同步阻塞IO一連接一線程模型,架構的性能、彈性伸縮能力和可靠性都得到了極大的提升。
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方法導致的內存拷貝問題。
4.內存池(基於內存池的緩沖區重用機制)
隨着JVM虛擬機和JIT即時編譯技術的發展,對象的分配和回收是個非常輕量級的工作。但是對於緩沖區buffer,情況卻稍有不同,特別是對於堆外直接內存的分配和回收,是一件耗時的操作。為了盡量重用緩沖區,Netty提供了基於內存池的緩沖區重用機制。
5.高效的Reactor線程模型
常用的reactor線程模型有三種,reactor單線程模型,reactor多線程模型,主從reactor多線程模型。
1)reactor單線程模型,指的是所有的IO操作都在同一個NIO線程上面完成,NIO線程的職責如下:
- 作為NIO服務端,接收客戶端的TCP連接;
- 作為NIO客戶端,向服務端發起TCP連接;
- 讀取通信對端的請求或應答消息;
- 向通信對端發送消息請求或應答消息;
由於reactor模式使用的是異步非阻塞IO,所有的IO操作都不會導致阻塞,理論上一個線程可以獨立處理所有IO相關的操作。從架構層面看,一個NIO線程確實可以完成其承擔的職責。例如,通過acceptor接收客戶端的TCP連接請求消息,鏈路建立成功后,通過dispatch將對應的ByteBuffer派發到指定的handler上進行消息解碼。用戶handler可以通過NIO線程將消息發送給客戶端。
2)reactor多線程模型
reactor多線程模型與單線程模型最大的區別就是有一組NIO線程處理IO操作。有專門一個NIO線程-Acceptor線程用於監聽服務端,接收客戶端的TCP連接請求;網絡IO操作-讀、寫等由一個NIO線程池負責,線程池可以采用標准的JDK線程池實現,它包含一個任務隊列和N個可用的線程,由這些NIO負責消息的讀取、解碼、編碼和發送;
3)主從reactor多線程模型
服務端用於接收客戶端連接的不再是一個單獨的NIO線程,而是一個獨立的NIO線程池。acceptor接收客戶端TCP連接請求處理完成后(可能包含接入認證等),將新創建的socketchannel注冊到IO線程池(subReactor 線程池)的某個IO線程上,由它負責socketchannel的毒血和編解碼工作。acceptor線程池僅僅只用於客戶端的登陸、握手和安全認證,一旦鏈路建立成功,就將鏈路注冊到后端subReactor線程池的IO線程上,由IO線程負責后續的IO操作。
6.無鎖設計、線程鎖定
Netty采用了串行無鎖化設計,在IO線程內部進行串行操作,避免多線程競爭導致的性能下降。表面上看,串行化設計似乎CPU利用率不高,並發程度不夠。但是,通過調整NIO線程池的線程參數,可以同時啟動多個串行化的線程並行運行,這種局部無鎖化的串行線程設計相比一個隊列-多個工作線程模型性能更優。
Netty的NioEventLoop讀取到消息后,直接調用ChannelPipeline的fireChannelRead(Object msg),只要用戶不主動切換線程,一直會由NioEventLoop調用到用戶的handler,期間不進行線程切換,這種串行化處理方式避免了多線程操作導致的鎖競爭,從性能角度看是最優的。
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上,提升網絡並行處理性能。
三、Netty RPC實現
RPC,即Remote Procedure Call(遠程過程調用),調用遠程計算機上的服務,就像調用本地服務一樣。RPC可以很好的解耦系統,如webservice就是一種基於HTTP協議的RPC。
這個RPC整體框架如下:
1.關鍵技術
1)服務發布與訂閱:服務端使用zookeeper注冊服務地址,客戶端從zookeeper獲取可用的服務地址。
2)通信:使用Netty作為通信框架。
3)Spring:使用spring配置服務,加載bean,掃描注解。
4)動態代理:客戶端使用代理模式透明化服務調用。
5)消息編解碼:使用Protostuff序列化和反序列化消息。
2.核心流程
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一般使用動態代理方式實現遠程調用。
3.消息編解碼
消息數據結構(接口名稱+方法名+參數類型和參數值+超時時間+requestID)
客戶端的請求消息結構一般需要包括以下內容:
1)接口名稱:在我們的例子里接口名是“HelloWorldService”,如果不傳,服務端就不知道調用哪個接口了;
2)方法名:一個接口內可能有很多方法,如果不傳方法名,服務端就不知道調用的哪個方法;
3)參數類型和參數值:參數類型有很多,例如有boolean、int、long、double、string、map、list,甚至如struct(class);以及相應的參數值;
4)超時時間
5)requestID:標識唯一請求id;
6)服務端返回的消息:一般包括:返回值+狀態code+requestID
序列化
目前互聯網公司廣泛使用Protobuf、Thrift、Avro等成熟的序列化解決方案來搭建RPC框架,這些都是久經考驗的解決方案。
4.通訊過程
核心問題(線程暫停、消息亂序)
如果使用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,再從前面的ConcurrnetHashMap里面get(requestID),從而找到callback對象,再用synchronized獲取callback上的鎖,將方法調用結果設置到callback對象里,再調用callback.notifyAll()喚醒前面處於等待狀態的線程。
1 public Object get() { 2 synchronized (this) { // 旋鎖
3 while (true) { // 是否有結果了
4 If (!isDone){ 5 wait(); //沒結果釋放鎖,讓當前線程處於等待狀態
6 }else{//獲取數據並處理
7 } 8 } 9 } 10 } 11 private void setDone(Response res) { 12 this.res = res; 13 isDone = true; 14 synchronized (this) { //獲取鎖,因為前面 wait()已經釋放了 callback 的鎖了
15 notifyAll(); // 喚醒處於等待的線程
16 } 17 }
四、RMI實現方式
Java遠程方法調用,即Java RMI(Java remote method invocation)是Java編程語言里,一種用於實現遠程調用的應用程序編程接口。它使客戶機上運行的程序可以調用遠程服務器上的對象。遠程方法調用特性使Java編程人員能夠在網絡環境中分布操作。RMI全部的宗旨就是盡可能簡化遠程接口對象的使用。
1.實現步驟
1)編寫遠程服務接口,該接口必須繼承java.rmi.Remote接口,方法必須拋出java.rmi.RemoteException異常。
2)編寫遠程接口實現類,該實現類必須繼承java.rmi.server.UnicastRemoteObject類;
3)運行RMI編譯器(rmic),創建客戶端stub類和服務端skeleton類;
4)啟動一個RMI注冊表,以便駐留這些服務;
5)在RMI注冊表中注冊服務;
6)客戶端查找遠程對象,並調用遠程方法;
1 1:創建遠程接口,繼承 java.rmi.Remote 接口 2 public interface GreetService extends java.rmi.Remote { 3 String sayHello(String name) throws RemoteException; 4 } 5 2:實現遠程接口,繼承 java.rmi.server.UnicastRemoteObject 類 6 public class GreetServiceImpl extends java.rmi.server.UnicastRemoteObject implements GreetService { 7 private static final long serialVersionUID = 3434060152387200042L; 8 public GreetServiceImpl() throws RemoteException { 9 super(); 10 } 11 @Override 12 public String sayHello(String name) throws RemoteException { 13 return "Hello " + name; 14 } 15 } 16 3:生成 Stub 和 Skeleton; 17 4:執行 rmiregistry 命令注冊服務 18 5:啟動服務 19 LocateRegistry.createRegistry(1098); 20 Naming.bind("rmi://10.108.1.138:1098/GreetService", new GreetServiceImpl()); 21 6.客戶端調用 22 GreetService greetService = (GreetService) Naming.lookup("rmi://10.108.1.138:1098/GreetService"); 23 System.out.println(greetService.sayHello("Jobs"));
五、Protocol Buffer
Protocol buffer是Google的一個開源項目,它是用於結構化數據串行化的靈活、高效、自動的方法,例如XML,不過它比XML更小、更快、更簡單。你可以定義自己的數據結構,然后使用代碼生成器的代碼來讀寫這個數據結構。你甚至可以在無需重新部署程序的情況下更新數據結構。
1.特點
Protocol Buffer的序列化 & 反序列化簡單 & 速度快的原因是:
1)編碼/解碼方式簡單(只需要簡單的數字運算=位移等)
2)采用protocol buffer 自身的框架代碼和編譯器共同完成;
Protocol Buffer的數據壓縮效果好(即序列化的數據量體積小)的原因是:
1)采用了獨特的編碼方式,如Varint、Zigzag編碼方式等;
2)采用T-L-V的數據存儲方式,減少了分隔符的使用 & 數據存儲的緊湊
六、Thrift
Apache Thrift是Facebook實現的一種高效的、支持多中編程語言的遠程服務調用的框架。
目前流行的服務調用方式有很多種,例如基於SOAP消息格式的web service,基於JSON消息格式的RESTful服務等。其中所用到的數據傳輸方式包括XML、JSON等,然而XML相對體積太大,傳輸效率低,JSON體積較小,新穎,但不夠完善。
本文將介紹由facebook開發的遠程服務調用框架Apache Thrift,它采用接口描述語言定義並創建服務,支持可擴展的跨語言服務開發,所包含的代碼生成引擎可以在多種語言中,如C++、Java、python、PHP、ruby等創建高效的、無縫的服務,其傳輸數據采用二進制格式,相對XML和JSON體積更小,對於高並發、大數據量和多語言的環境更有優勢。