JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

遠端程序呼叫:讓網路隱形

第 1 篇導覽說明了分散式系統為何如此艱難:沒有共享記憶體、沒有共享時鐘、訊息還會憑空消失。這篇要談的,是用來掩蓋這團混亂、影響最深遠的一個點子——讓一台機器去呼叫另一台機器上的函式,彷彿那只是一個再尋常不過的本地呼叫。我們會看清「樁」與「封送」究竟如何變出這場幻覺,而同樣重要的是:這場幻覺會在哪裡破綻外洩。

從「傳訊息」到「呼叫函式」

讀完第 1 篇,你已認識跨機器溝通最原始的工具:在網路上做訊息傳遞——把一些位元組打包、送出去、祈禱它們會到、再等回覆。這行得通,但寫起來苦不堪言。你整天在決定位元組的排列方式、把每一筆請求對上它的回覆、處理「什麼都沒回來」的情況、然後重試。這些都不是你真正要解決的問題;它們是淹沒你唯一在乎那行邏輯的水管工程——而那行邏輯不過是「請另一台機器做 X,把答案給我」。

於是有人問了一個美妙的問題:程式設計師早已最熟悉的「請別人做事」的方式是什麼?就是函式呼叫。你寫下 result = add(3, 4),控制流跳到某處,工作就發生了,答案回來,你接著往下走。那麼,如果「呼叫遠端機器上的函式」能長得一模一樣呢?這就是遠端程序呼叫,也就是 RPC:你呼叫一個看起來再普通不過的本地函式,但它的函式本體其實跑在另一台電腦上,而那趟網路來回,就藏在這次呼叫裡面。

這正是 RPC 之所以坐鎮在這麼多系統核心的原因。你會在第 3 篇遇到的網路檔案系統就建構在它之上;網路作業系統內部運作的大半、以及廣義的主從式模型,也都靠它。這份承諾令人陶醉:寫出來的分散式程式碼,讀起來就像尋常的單機程式碼。這篇導覽大半在談這份承諾如何被兌現——而最後一部分,則是誠實的小字條款:它永遠只能被兌現一半。

兩個樁:魔法真正棲身之處

這場幻覺由兩小段自動產生的程式碼扛起,叫做(stub)——兩邊各一個。把它們想成坐在兩個大使館裡、彼此完全同步的兩位譯者。當你呼叫那個遠端函式時,你其實沒在呼叫它;你呼叫的是客戶端樁,一個名字與外形都與真函式完全相同的本地函式。對你而言,它與真貨毫無分別。它整份工作就是:接過你的參數、把它們變成一則訊息、交給網路、等待,再把回覆變回一個傳回值。

對面那台機器上,坐著與之相配的另一半,伺服器端樁(有時叫骨架,skeleton)。它是一面鏡像:把訊息從網路上接下來、把參數拆解回普通的值,然後對真正的函式做一次完全普通的本地呼叫——那個真的會把數字相加、會去讀檔案、或做任何事的函式。當該函式回傳時,伺服器端樁把結果打包、運送回家。真正的函式從不知道自己是被半個地球外的人呼叫的;而呼叫端的程式碼也從不知道自己的函式遠在半個地球之外。兩個樁,把這整段差異全部吸收了。

留意這兩個樁到底是什麼:它們是一層薄薄、私下使用的訊息傳遞,被打扮成看起來像一次本地呼叫的樣子。第 1 篇講過的一切仍在底下——位元組確實跨過了一條線、回覆確實可能遲到或遺失——但這一切如今都被封進兩個你永遠不必去讀的函式裡。這就是整個把戲。複雜度並沒有消失;它只是被搬進了自動產生的程式碼,好讓你的業務邏輯能保持乾淨。

封送:把資料壓平、送上線

現在把鏡頭推近,看樁這份工作裡最難的部分。在同一台機器內,一個像數字或串列那樣的參數,棲身在某個記憶體位址上,用的是那台機器自己的位元組順序與字組大小。你不能就這樣把這些原始位元組複製到另一台機器——對面那台機器可能用不同的順序排列位元組、用不同的大小定義整數,而且它當然沒有一個「意思相同」的記憶體位址。所以客戶端樁必須封送(marshal)這些參數:把它們轉換成一串壓平、能自我描述、任何機器都讀得懂的位元組流。封送是打包;對面的解封送則是拆包。

一個能讓你「感覺到」封送的鮮活比喻:想像你要郵寄一件家具。你沒辦法把一座組好的書架直接寄出去,於是你把它平整打包進一個箱子,附上零件清單與組裝說明;對面的人只靠這箱子就能重建它,完全不必參照你的房間。封送把你的資料平整打包;解封送則把它重建起來。雙方對這箱子約定好的格式——兩邊承諾共用的那套位元組排列——就是「線上格式」(wire format),正是它讓一台跑著某種語言與 CPU 的機器,能和一台跑著截然不同語言與 CPU 的機器對話。

從頭到尾追蹤一次 RPC

我們來走一遍單獨一次遠端呼叫 result = add(3, 4),一路過去再回來,而 add 真的跑在一台伺服器上。一邊走一邊盯住一件事:從呼叫者的角度看,這就只是一行程式,但在底下,其實有十個各自分明的環節,而其中任何一個都可能變慢、或失敗。

  1. 呼叫者執行 add(3, 4)。控制流跳進客戶端樁;它看起來跟 add 一模一樣,但其實不是。
  2. 客戶端樁封送參數:它把值 3 與 4,連同一個標記「要執行哪個函式」的標籤,一起平整打包成一則能自我描述的訊息。
  3. 客戶端樁把訊息交給作業系統,由它透過網路送往伺服器。(這一段正是可能變慢、可能到達兩次、或永遠不到的地方。)
  4. 呼叫者的執行緒此刻封鎖、等待回覆——就跟一次普通函式呼叫等待它的本體跑完一樣,只不過這次的等待可能非常漫長。
  5. 在伺服器端,作業系統收到訊息,喚醒伺服器端樁。
  6. 伺服器端樁把訊息解封送,還原成普通的值 3 與 4,並讀出標籤,得知對方要的是哪個函式。
  7. 伺服器端樁做出一次完全普通的本地呼叫:add(3, 4)。真正的 add 執行、傳回 7。它完全不知道過程中牽涉到了網路。
  8. 伺服器端樁把結果 7 封送成一則回覆訊息,交給伺服器的作業系統送回去。
  9. 回覆跨過網路,抵達客戶端的作業系統,由它喚醒原本封鎖中的呼叫者執行緒。
  10. 客戶端樁把回覆解封送成值 7 並傳回。回到呼叫者那裡,result = add(3, 4) 這行終於完成,result 裝著 7——怎麼看,都像一次單純的本地呼叫。
  CLIENT machine                    SERVER machine
  --------------                    --------------
  caller code                       real add()
     |  add(3,4)                       ^  returns 7
     v                                 |  add(3,4)
  [client stub] --marshal--+        [server stub]
     ^                      |           ^   |
     |  return 7            |  message  |   | reply
     |                      v   3,4      |   v   7
  [  OS  ] ----------- network ----- [  OS  ]

  One line of caller code  =  10 hops underneath
add(3, 4) 的一趟來回。兩個樁與網路夾在呼叫者與真函式之間;而呼叫者只看見一行程式。

幻覺洩漏之處(誠實以對)

這裡是這個階段所要求的關鍵誠實:遠端呼叫永遠不可能真的等同於本地呼叫,而假裝它們相同,正是分散式系統受傷的方式。有一份著名的清單,列的正是這些陷阱,叫做分散式運算的謬誤:網路是可靠的、延遲為零、頻寬無限、網路是安全的,諸如此類。每一條都是假的,而本地呼叫把它們通通藏了起來。RPC 讓遠端呼叫看起來像本地呼叫,這會悄悄誘使你去相信那些假命題。

兩處洩漏最為要緊。第一是延遲:本地呼叫花的是奈秒,遠端呼叫花的是毫秒,也許是百萬倍之多。一段迴圈裡呼叫某「函式」一千次的程式碼,在本地沒事,在遠端則是場災難。第二,也是最糟的,是部分失效——正是第 1 篇警告過的那件事。本地呼叫要嘛跑完、要嘛你整支程式當掉;中間沒有灰色地帶。但遠端呼叫可能讓你落得「沒有回覆、又無從得知發生了什麼」:是請求根本沒到,還是它跑完了、只是回覆遺失了?對 add 來說,這幾乎無所謂。但對 charge_credit_card(扣信用卡款)來說,這事關重大。

所以請同時握住兩個真相。RPC 是一場勝利:它讓你能用「讀起來像普通程式碼」的語言建構一個主從式系統,也撐起了第 3 篇要談的分散式檔案系統。但它所掩蓋的那張網路其實仍真實存在,而那些洩漏——延遲與部分失效——正是第 1 篇引介過的那些惡龍。成熟的看法不是「RPC 讓網路消失了」;而是「RPC 讓網路變得好用,只要你記得它從來不曾真正消失」。