RPC 框架簡析

作者:洪春濤
鏈接:
來源:知乎
著作權歸作者所有。商業轉載請聯繫作者獲得授權,非商業轉載請註明出處。
 

本地過程調用

RPC就是要像調用本地的函數一樣去調遠程函數。在研究RPC前,我們先看看本地調用是怎麼調的。假設我們要調用函數Multiply來計算lvalue * rvalue的結果:

1 int Multiply(int l, int r) {
2    int y = l * r;
3    return y;
4 }
5 
6 int lvalue = 10;
7 int rvalue = 20;
8 int l_times_r = Multiply(lvalue, rvalue);

那麼在第8行時,我們實際上執行了以下操作:

  1. 將 lvalue 和 rvalue 的值壓棧
  2. 進入Multiply函數,取出棧中的值10 和 20,將其賦予 l 和 r
  3. 執行第2行代碼,計算 l * r ,並將結果存在 y
  4. 將 y 的值壓棧,然後從Multiply返回
  5. 第8行,從棧中取出返回值 200 ,並賦值給 l_times_r

以上5步就是執行本地調用的過程。(20190116注:以上步驟只是爲了說明原理。事實上編譯器經常會做優化,對於參數和返回值少的情況會直接將其存放在寄存器,而不需要壓棧彈棧的過程,甚至都不需要調用call,而直接做inline操作。僅就原理來說,這5步是沒有問題的。)

 

遠程過程調用帶來的新問題

在遠程調用時,我們需要執行的函數體是在遠程的機器上的,也就是說,Multiply是在另一個進程中執行的。這就帶來了幾個新問題:

  1. Call ID映射。我們怎麼告訴遠程機器我們要調用Multiply,而不是Add或者FooBar呢?在本地調用中,函數體是直接通過函數指針來指定的,我們調用Multiply,編譯器就自動幫我們調用它相應的函數指針。但是在遠程調用中,函數指針是不行的,因爲兩個進程的地址空間是完全不一樣的。所以,在RPC中,所有的函數都必須有自己的一個ID。這個ID在所有進程中都是唯一確定的。客戶端在做遠程過程調用時,必須附上這個ID。然後我們還需要在客戶端和服務端分別維護一個 {函數 <--> Call ID} 的對應表。兩者的表不一定需要完全相同,但相同的函數對應的Call ID必須相同。當客戶端需要進行遠程調用時,它就查一下這個表,找出相應的Call ID,然後把它傳給服務端,服務端也通過查表,來確定客戶端需要調用的函數,然後執行相應函數的代碼。
  2. 序列化和反序列化。客戶端怎麼把參數值傳給遠程的函數呢?在本地調用中,我們只需要把參數壓到棧裏,然後讓函數自己去棧裏讀就行。但是在遠程過程調用時,客戶端跟服務端是不同的進程,不能通過內存來傳遞參數。甚至有時候客戶端和服務端使用的都不是同一種語言(比如服務端用C++,客戶端用Java或者Python)。這時候就需要客戶端把參數先轉成一個字節流,傳給服務端後,再把字節流轉成自己能讀取的格式。這個過程叫序列化和反序列化。同理,從服務端返回的值也需要序列化反序列化的過程。
  3. 網絡傳輸。遠程調用往往用在網絡上,客戶端和服務端是通過網絡連接的。所有的數據都需要通過網絡傳輸,因此就需要有一個網絡傳輸層。網絡傳輸層需要把Call ID和序列化後的參數字節流傳給服務端,然後再把序列化後的調用結果傳回客戶端。只要能完成這兩者的,都可以作爲傳輸層使用。因此,它所使用的協議其實是不限的,能完成傳輸就行。儘管大部分RPC框架都使用TCP協議,但其實UDP也可以,而gRPC乾脆就用了HTTP2。Java的Netty也屬於這層的東西。

有了這三個機制,就能實現RPC了,具體過程如下:

// Client端 
//    int l_times_r = Call(ServerAddr, Multiply, lvalue, rvalue)
1. 將這個調用映射爲Call ID。這裏假設用最簡單的字符串當Call ID的方法
2. 將Call ID,lvalue和rvalue序列化。可以直接將它們的值以二進制形式打包
3. 把2中得到的數據包發送給ServerAddr,這需要使用網絡傳輸層
4. 等待服務器返回結果
5. 如果服務器調用成功,那麼就將結果反序列化,並賦給l_times_r

// Server端
1. 在本地維護一個Call ID到函數指針的映射call_id_map,可以用std::map<std::string, std::function<>>
2. 等待請求
3. 得到一個請求後,將其數據包反序列化,得到Call ID
4. 通過在call_id_map中查找,得到相應的函數指針
5. 將lvalue和rvalue反序列化後,在本地調用Multiply函數,得到結果
6. 將結果序列化後通過網絡返回給Client

所以要實現一個RPC框架,其實只需要按以上流程實現就基本完成了。

其中:

  • Call ID映射可以直接使用函數字符串,也可以使用整數ID。映射表一般就是一個哈希表。
  • 序列化反序列化可以自己寫,也可以使用Protobuf或者FlatBuffers之類的。
  • 網絡傳輸庫可以自己寫socket,或者用asio,ZeroMQ,Netty之類。

當然,這裏面還有一些細節可以填充,比如如何處理網絡錯誤,如何防止攻擊,如何做流量控制,等等。但有了以上的架構,這些都可以持續加進去。

最後,有興趣的可以看我們自己寫的一個小而精的RPC庫 tinyrpc(hjk41/tinyrpc),對於理解RPC如何工作很有好處。

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