許多分散式系統建立在行程間明確的訊息交換之上,但 send 與 receive 這類程序完全沒有把通訊隱藏起來,不利於達成存取透明性(access transparency)。這個問題早已為人所知,直到 Birrell 與 Nelson(1984)的論文提出了截然不同的處理方式:讓程式可以呼叫位於其他機器上的程序。當機器 A 上的行程呼叫機器 B 上的程序時,A 上的呼叫行程被暫停,被呼叫的程序在 B 上執行;資訊以參數帶過去、以結果帶回來,程式設計師完全看不到任何訊息傳遞。這就是遠端程序呼叫(Remote Procedure Call, RPC)

想法簡單優雅,但潛藏微妙的問題:呼叫端與被呼叫端在不同機器上執行,位於不同的位址空間;參數與結果的傳遞在機器不相同時可能很複雜;兩邊機器都可能當機,且各種故障造成的問題各不相同。所幸多數問題都有辦法處理,RPC 已是支撐眾多分散式系統的常用技術。

RPC 的基本運作#

傳統程序呼叫#

先理解單機上的傳統程序呼叫。考慮 C 語言的呼叫:

count = read(fd, buf, nbytes);

其中 fd 是表示檔案的整數,buf 是讀入資料的字元陣列,nbytes 是要讀取的位元組數。呼叫時,呼叫者把參數依序(最後一個先推)推上堆疊;read 執行完畢後把回傳值放進暫存器、移除返回位址,把控制權交還呼叫者;呼叫者再把參數自堆疊移除,恢復原狀。

圖 4-5:(a) 本地程序呼叫中的參數傳遞:呼叫前的堆疊。(b) 被呼叫的程序執行中的堆疊

參數傳遞方式是關鍵:

  • 傳值(call-by-value):如 fdnbytes,值被複製到堆疊上,對被呼叫的程序而言就是初始化過的區域變數;被呼叫者可以修改它,但不影響呼叫端的原值。
  • 傳參考(call-by-reference):傳的是變數的位址。C 的陣列一律傳參考,read 的第二個參數推上堆疊的是字元陣列的位址;被呼叫的程序透過它寫入資料,會直接修改呼叫端的陣列。
  • 複製/還原(call-by-copy/restore):呼叫者先把變數複製到堆疊(如同傳值),呼叫結束後再複製回去覆蓋原值。多數情況下效果與傳參考相同,但在某些情境(例如同一參數在參數列中出現多次)語意會不同。使用這種機制的語言不多。

採用哪種機制通常由語言設計者決定,是語言的固定性質,有時視資料型別而定(C 的純量永遠傳值、陣列永遠傳參考)。傳值與傳參考的差異對 RPC 相當重要。

客戶端與伺服器 Stub#

RPC 背後的理念是讓遠端呼叫盡可能像本地呼叫——呼叫端不該察覺被呼叫的程序是在另一台機器上執行,反之亦然。在單機系統中,read 由連結器從函式庫取出,是一支用等價的系統呼叫實作的短程序,扮演使用者程式碼與本地作業系統之間的介面。

read 實際上是遠端程序(例如將在檔案伺服器上執行)時,函式庫中放的是另一個版本的 read,稱為客戶端 stub(client stub)。它同樣以一般的呼叫慣例被呼叫、同樣呼叫本地作業系統——只是它不向作業系統要資料,而是把參數打包成訊息,請求送往伺服器,接著呼叫 receive 阻塞等待回覆。

訊息到達伺服器後,伺服器的作業系統把它交給伺服器 stub(server stub):一段把網路請求轉成本地程序呼叫的程式碼。伺服器 stub 通常已呼叫 receive 阻塞等待訊息;收到後解出參數,以一般方式呼叫伺服器程序。對伺服器而言,就像被客戶端直接呼叫一樣——參數與返回位址都在堆疊上,毫無異狀。伺服器完成工作後照常回傳結果(以 read 為例,伺服器會把資料填入第二個參數指向的緩衝區,該緩衝區位於伺服器 stub 內部);伺服器 stub 取回控制權後,把結果打包成訊息送回,然後通常再度呼叫 receive 等待下一個請求。訊息回到客戶端機器後,作業系統把它交給阻塞中的客戶端行程,客戶端 stub 解出結果、複製給呼叫者,照常返回。

圖 4-6:客戶端程式與伺服器程式之間的 RPC 原理

一次遠端程序呼叫的完整步驟如下:

  1. 客戶端程序以一般方式呼叫客戶端 stub。
  2. 客戶端 stub 組出訊息並呼叫本地作業系統。
  3. 客戶端的作業系統把訊息送給遠端作業系統。
  4. 遠端作業系統把訊息交給伺服器 stub。
  5. 伺服器 stub 解出參數並呼叫伺服器程序。
  6. 伺服器完成工作,把結果回傳給 stub。
  7. 伺服器 stub 把結果打包成訊息並呼叫本地作業系統。
  8. 伺服器的作業系統把訊息送給客戶端的作業系統。
  9. 客戶端的作業系統把訊息交給客戶端 stub。
  10. 客戶端 stub 解出結果並回傳給客戶端。

圖 4-7:透過 RPC 進行一次遠端運算所牽涉的步驟

這些步驟的淨效果,是把「客戶端程序對客戶端 stub 的本地呼叫」轉換成「對伺服器程序的本地呼叫」,而客戶端與伺服器都不會察覺中間的步驟或網路的存在。客戶端的這種「渾然不覺」正是整個機制的美妙之處:遠端服務透過普通的(本地)程序呼叫來存取,而非呼叫 send 與 receive——所有訊息傳遞細節都藏在兩支函式庫程序裡,如同傳統函式庫把系統呼叫的細節藏起來一樣。

參數傳遞#

客戶端 stub 的職責是把參數打包成訊息送給伺服器 stub,這件事看似直觀,實則不然。

傳值參數#

把參數打包進訊息稱為參數整編(parameter marshaling)。以遠端程序 add(i, j)(回傳兩整數之和)為例:客戶端 stub 把兩個參數放進訊息,同時放入要呼叫的程序名稱或編號——因為伺服器可能支援多個呼叫,必須指明要哪一個。訊息到達後,伺服器 stub 檢查訊息判斷該呼叫哪支程序(可能用 switch 依訊息第一個欄位分派),再以來自訊息的變數當參數發起呼叫;完成後反向打包送回。

只要客戶端與伺服器機器相同、參數與結果都是整數、字元、布林等純量型別,這個模型運作良好。但大型分散式系統中常有多種機型並存,各有自己的資料表示法,問題就來了:

  • 字元編碼:IBM 大型主機用 EBCDIC,IBM PC 用 ASCII,字元參數直接照搬會被錯誤解讀。
  • 整數表示:一補數與二補數的差異。
  • 位元組順序:Intel Pentium 由右至左編號位元組(little endian),Sun SPARC 由左至右(big endian)。這兩個名稱典出《格列佛遊記》中為了該敲雞蛋哪一端而開戰的政客。
延伸案例:整數 5 與字串 JILL 的位元組順序問題

考慮一支有兩個參數的程序:一個整數與一個四字元字串,各佔一個 32 位元字組。Intel Pentium 上的客戶端 stub 組出的訊息中,第一個字組放整數 5、第二個放字串 “JILL”。訊息逐位元組在網路上傳輸,先送先到;SPARC 收到後,因為它把位元組 0 放在左邊(高位位元組)而 Intel 放在右邊(低位位元組),伺服器 stub 讀到的整數會變成 83,886,080(即 5 × 2 的 24 次方),字串 “JILL” 卻是對的。

一個看似顯然、但其實錯誤的補救法,是把收到的每個字組的位元組反轉:整數確實變回 5,字串卻變成了 “LLIJ”。問題在於不同的位元組順序會顛倒整數、卻不會顛倒字串——若沒有「哪些是字串、哪些是整數」的額外資訊,就無從修復。

圖 4-8:(a) Pentium 上的原始訊息。(b) SPARC 收到後的訊息。(c) 位元組反轉後的訊息

傳參考參數#

指標(或廣義的參考)如何傳遞?答案是:極其困難,甚至無法傳遞。指標只在使用它的行程的位址空間內有意義。回到 read 的例子:若客戶端上緩衝區位址是 1000,把數字 1000 傳給伺服器毫無意義——伺服器上位址 1000 可能落在程式碼區段中間。

  • 直接禁用指標與參考參數是一種解法,但這些機制太重要,禁用非常不可取——而且其實不必。
  • 客戶端 stub 知道 read 的第二個參數指向字元陣列;假設它也知道陣列多大,就可以把陣列複製進訊息送給伺服器。伺服器 stub 再以指向這份副本的指標呼叫伺服器程序(數值與原指標不同無妨);伺服器透過指標的修改直接作用在伺服器 stub 內的訊息緩衝區上,完成後把訊息送回客戶端 stub,由它複製回客戶端。實際上,傳參考被換成了複製/還原——雖不總是完全等價,但通常已經夠好。
  • 一項最佳化可讓效率倍增:若 stub 知道緩衝區對伺服器而言是輸入還是輸出參數,就能省掉一趟複製——輸入參數(如 write 的緩衝區)不必複製回來;輸出參數不必送過去。

這套辦法能處理指向簡單陣列與結構的指標,仍無法處理最一般的情形:指向任意資料結構(例如複雜圖形)的指標。有些系統會把指標直接傳給伺服器 stub,並在伺服器程序中產生使用指標的特殊程式碼——例如在需要解參考時,送一個請求回客戶端索取被參考的資料。

參數規格與 Stub 產生#

要把遠端呼叫藏起來,呼叫端與被呼叫端必須對交換訊息的格式取得共識,並遵循相同的步驟——換言之,RPC 雙方必須遵循相同的協定,否則 RPC 無法正確運作。

  • 訊息格式:例如協定可規定,字元放在字組最右邊的位元組(其餘 3 位元組留空)、浮點數佔一整個字組、陣列以「長度字組+等長字組群」表示。
  • 簡單資料的表示法:例如規定整數用二補數、字元用 16 位元 Unicode、浮點數用 IEEE 754,一律 little endian。有了這些,訊息才能被無歧義地解讀。
  • 訊息交換方式:例如採用 TCP/IP 這類連線導向的傳輸服務,或改用不可靠的資料包服務、由客戶端與伺服器在 RPC 協定中自行實作錯誤控制。實務上存在多種變體。

圖 4-9:(a) 一支程序。(b) 對應的訊息

協定完整定義後,就要實作客戶端與伺服器 stub。幸好同一協定下不同程序的 stub,通常只差在對應用的介面。**介面(interface)是一組客戶端可呼叫、由伺服器實作的程序集合,通常以介面定義語言(Interface Definition Language, IDL)**來描述,再編譯成客戶端 stub 與伺服器 stub,連同適當的編譯期或執行期介面。

實務顯示,使用 IDL 能大幅簡化基於 RPC 的客戶端—伺服器應用:因為 stub 可以完全自動產生,所有以 RPC 為基礎的中介軟體系統都提供 IDL 來支援應用開發,有些甚至強制使用。

非同步 RPC#

如同傳統程序呼叫,客戶端呼叫遠端程序後會阻塞直到回覆返回。當沒有結果需要回傳時,這種嚴格的請求—回覆行為毫無必要,只是白白讓客戶端無法先做別的有用工作。典型不需等待回覆的例子:轉帳、新增資料庫項目、啟動遠端服務、批次處理等。

為此,RPC 系統可提供非同步 RPC(asynchronous RPC):客戶端發出 RPC 請求後立即繼續執行。伺服器一收到請求就立刻回送一個回覆作為「將會處理這個 RPC」的確認,然後才呼叫被請求的程序;客戶端收到伺服器的確認後即不再阻塞。

圖 4-10:(a) 傳統 RPC 中客戶端與伺服器的互動。(b) 使用非同步 RPC 的互動

非同步 RPC 也適用於「會有結果回傳、但客戶端不想乾等」的情境。例如客戶端想預先查詢一批即將聯絡的主機的網路位址:可用兩個非同步 RPC 組織通訊——客戶端先呼叫伺服器交付要查詢的主機名稱清單,伺服器確認收到後客戶端就繼續做事;之後由伺服器發起第二個呼叫,把查到的位址交回客戶端。這種組合又稱為延遲同步 RPC(deferred synchronous RPC)。此外,客戶端也可以改用輪詢(polling)向伺服器查詢結果是否已就緒,而不必讓伺服器回呼。

圖 4-11:客戶端與伺服器透過兩個非同步 RPC 進行互動

另有一類變體:客戶端送出請求後立即繼續執行,連伺服器接受請求的確認都不等,稱為單向 RPC(one-way RPC)。問題在於:若可靠性沒有保證,客戶端無法確知請求究竟會不會被處理。

實例:DCE RPC#

RPC 已被廣泛採用為中介軟體與分散式系統的基礎。這裡檢視一個具體系統:由開放軟體基金會(Open Software Foundation, OSF,現稱 The Open Group)開發的分散式運算環境(Distributed Computing Environment, DCE)。DCE RPC 雖不如 Sun RPC 流行,但足以代表其他 RPC 系統,其規格也被微軟的分散式運算基礎 DCOM 採納。

DCE 簡介#

DCE 是名副其實的中介軟體系統:設計為在既有(網路)作業系統與分散式應用之間作為一層抽象來執行。客戶可以在一批既有機器上加裝 DCE 軟體,就能執行分散式應用,且不干擾既有的非分散式應用。DCE 最初為 UNIX 設計,現已移植到所有主要作業系統。它的程式設計模型是客戶端—伺服器模型,客戶端與伺服器之間所有通訊都透過 RPC 進行。DCE 本身內含多項服務:

  • 分散式檔案服務:全球性的檔案系統,提供一致而透明的檔案存取方式。
  • 目錄服務:追蹤系統中所有資源(機器、印表機、伺服器、資料等)的位置,資源可能散布全球;行程要資源時不必關心它在哪裡。
  • 安全服務:保護各類資源,限制只有獲授權者能存取。
  • 分散式時間服務:讓不同機器上的時鐘保持全域同步。

DCE RPC 的目標#

DCE RPC 的首要目標,是讓客戶端只需呼叫本地程序就能存取遠端服務——應用程式因此能以多數程式設計師熟悉的簡單方式撰寫,大量既有程式碼也能幾乎不改就搬進分散式環境。RPC 系統負責把細節全部藏起來:

  • 自動定位正確的伺服器,並建立客戶端與伺服器軟體間的通訊(一般稱為繫結,binding)。
  • 處理雙向的訊息傳輸,必要時切割與重組(例如參數是大型陣列時)。
  • 自動處理客戶端與伺服器之間的資料型別轉換,即使兩者架構不同、位元組順序不同。

由於細節被完全隱藏,客戶端與伺服器高度互相獨立:一邊可以用 Java、另一邊用 C;硬體平台、作業系統、網路協定與資料表示法都可以不同,應用完全不必介入。

撰寫客戶端與伺服器#

DCE RPC 系統由語言、函式庫、常駐程式(daemon)與工具程式等組件構成。整個開發流程以 IDL 撰寫的介面定義為黏著劑;IDL 的程序宣告形式非常接近 ANSI C 的函式原型,檔案裡還可以放型別定義、常數宣告等整編所需的資訊。IDL 只定義呼叫的語法、不定義語意(形式化的語意定義超出目前技術水準),開發者頂多加上註解說明。

每個 IDL 檔的關鍵元素是全域唯一識別碼:客戶端在第一個 RPC 訊息中送出這個識別碼,伺服器驗證是否正確——若客戶端不慎繫結到錯的伺服器、甚至是正確伺服器的舊版本,伺服器會偵測到錯誤,繫結不會成立。開發流程如下:

  1. 執行 uuidgen 程式,產生一個原型 IDL 檔,內含保證永不重複的介面識別碼(128 位元二進位數,以十六進位 ASCII 字串表示;唯一性靠編入產生的地點與時間確保)。
  2. 編輯 IDL 檔,填入遠端程序的名稱與參數。值得一提的是,RPC 並非完全透明——例如客戶端與伺服器不能共享全域變數——而 IDL 的規則讓你根本無法表達不被支援的構件。
  3. 呼叫 IDL 編譯器,產出三個檔案:標頭檔(如 interface.h,含唯一識別碼、型別定義、常數定義與函式原型,客戶端與伺服器程式碼都要 #include)、客戶端 stub、伺服器 stub。
  4. 撰寫客戶端與伺服器程式碼,與 stub 一起編譯;客戶端程式碼與客戶端 stub 的目的檔連結執行期函式庫產生客戶端執行檔,伺服器端亦然。

圖 4-12:在 DCE RPC 中撰寫客戶端與伺服器的步驟

客戶端與伺服器的繫結#

客戶端要能呼叫伺服器,伺服器必須先註冊並準備好接受請求。定位伺服器分兩步:找到伺服器所在的機器,再在那台機器上找到伺服器行程。第二步的重點是客戶端需要知道伺服器機器上的端點(end point,也常稱為 port)——伺服器的作業系統用它來區分不同行程的來訊。DCE 中每台伺服器機器上由 DCE daemon 行程維護一張(伺服器,端點)表。流程如下:

  • 伺服器向作業系統要一個端點,向 DCE daemon 註冊該端點(連同它支援的協定)。
  • 伺服器再向目錄服務註冊,提供所在機器的網路位址與可供查詢的名稱。
  • 客戶端把名稱交給目錄伺服器,取得伺服器機器的網路位址;再向那台機器上的 DCE daemon(它有眾所周知的端點)查詢伺服器的端點。之後 RPC 即可進行,後續呼叫不必重查。

圖 4-13:DCE 中客戶端到伺服器的繫結

延伸案例:繫結到影片伺服器

假設客戶端想繫結到本地名稱為 /local/multimedia/video/movies 的影片伺服器。它把這個名稱交給目錄伺服器,得到執行影片伺服器那台機器的網路位址;接著找上該機器的 DCE daemon,請它在端點表中查出影片伺服器的端點。有了這些資訊,RPC 就能進行。DCE 還讓客戶端能做更精細的伺服器搜尋,在機密性或資料完整性要求高時也可選用安全 RPC。

執行 RPC#

實際的 RPC 以一般方式透明地執行:客戶端 stub 把參數整編後交給執行期函式庫,用繫結時選定的協定傳輸;訊息到達伺服器端後依內含端點路由到正確的伺服器,執行期函式庫把訊息交給伺服器 stub 解編並呼叫伺服器;回覆循原路返回。

DCE 提供多種語意選項:

  • 預設是至多一次(at-most-once):任何呼叫絕不執行超過一次,即使系統當機。實務上這表示:若伺服器在 RPC 期間當機又快速復原,客戶端不會重試該操作,以免它其實已經執行過。
  • 可以在 IDL 檔中把遠端程序標記為冪等(idempotent),表示重複執行多次無害——例如讀取檔案的指定區塊可以一試再試直到成功。冪等的 RPC 因伺服器當機而失敗時,客戶端可以等伺服器重啟後重試。
  • 其他語意也存在但很少使用,包括把 RPC 廣播到區域網路上的所有機器。