許多分散式系統建立在行程間明確的訊息交換之上,但 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):如
fd與nbytes,值被複製到堆疊上,對被呼叫的程序而言就是初始化過的區域變數;被呼叫者可以修改它,但不影響呼叫端的原值。 - 傳參考(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 原理
一次遠端程序呼叫的完整步驟如下:
- 客戶端程序以一般方式呼叫客戶端 stub。
- 客戶端 stub 組出訊息並呼叫本地作業系統。
- 客戶端的作業系統把訊息送給遠端作業系統。
- 遠端作業系統把訊息交給伺服器 stub。
- 伺服器 stub 解出參數並呼叫伺服器程序。
- 伺服器完成工作,把結果回傳給 stub。
- 伺服器 stub 把結果打包成訊息並呼叫本地作業系統。
- 伺服器的作業系統把訊息送給客戶端的作業系統。
- 客戶端的作業系統把訊息交給客戶端 stub。
- 客戶端 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 訊息中送出這個識別碼,伺服器驗證是否正確——若客戶端不慎繫結到錯的伺服器、甚至是正確伺服器的舊版本,伺服器會偵測到錯誤,繫結不會成立。開發流程如下:
- 執行 uuidgen 程式,產生一個原型 IDL 檔,內含保證永不重複的介面識別碼(128 位元二進位數,以十六進位 ASCII 字串表示;唯一性靠編入產生的地點與時間確保)。
- 編輯 IDL 檔,填入遠端程序的名稱與參數。值得一提的是,RPC 並非完全透明——例如客戶端與伺服器不能共享全域變數——而 IDL 的規則讓你根本無法表達不被支援的構件。
- 呼叫 IDL 編譯器,產出三個檔案:標頭檔(如 interface.h,含唯一識別碼、型別定義、常數定義與函式原型,客戶端與伺服器程式碼都要 #include)、客戶端 stub、伺服器 stub。
- 撰寫客戶端與伺服器程式碼,與 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 廣播到區域網路上的所有機器。