傳訊息的基本功:send、receive 與 socket
傳訊息只有兩個動作——送出去、收進來,剩下的複雜度,都是這兩個動作的排列組合
先把最小單位釘死:send 和 receive
在分散式系統裡,不同電腦上的行程(process)不能像同一台電腦上的程式那樣共用記憶體,它們唯一能做的就是傳訊息。而傳訊息說穿了只有兩個動作:send——把一串位元組送到某個目的地,receive——從某個地方把訊息收進來。
每個目的地都配著一個佇列(queue):傳送方把訊息丟進遠端的佇列,接收方從自己本地的佇列把訊息拿出來——像信箱,別人把信投進來,你有空再去收。
每個目的地都有一個信箱,別人把信投進來、你有空再去收。送信的人把信丟進你的信箱,你從自己的信箱拿信——這就是佇列在做的事。
光有 send/receive 還不夠,要描述一個通訊到底長怎樣,得再問四個問題:同步 vs 非同步(送/收要不要卡住等對方)、訊息目的地(要送到哪裡)、可靠性(會不會掉、會不會壞)、順序(收到的順序跟送出的一樣嗎)。這四個面向就是接下來整章的共同語彙。
直接讀原文:同步與非同步,到底在說什麼
下面是課本描述同步/非同步通訊最關鍵的一段。原文放左邊,白話導讀放右邊。
傳送方和接收方之間的通訊,可以是同步的,也可以是非同步的。
同步通訊下,雙方在「每一則訊息」都會同步等待對方——這時候 send 和 receive 都是阻塞(blocking)操作。
非同步通訊下,send 是非阻塞的——訊息一複製到本地緩衝區,傳送方馬上就能繼續做別的事,不用等訊息真的送到。
像 Java 這種支援多執行緒的環境裡,阻塞式 receive 反而沒有缺點:可以派一條執行緒專門守著等訊息,其他執行緒照樣工作不受影響。
同步通訊就像打電話:你撥了號,得一直握著話筒等對方接起來才能講話,對方也得停下手邊的事來接——撥號的人會卡在那裡,直到對面有人喊「喂」。非同步通訊則像寄信:你把信投進信箱(複製到本地緩衝區)就能轉身去做別的事,信會在背景被送出去,你不用站在信箱旁邊等。
招牌互動:看一則訊息怎麼從 socket 送到 socket
行程 A 先把自己的socket綁到(Internet 位址、port),行程 B 才知道要送到哪裡。按「下一步」,看看整個配對怎麼發生,以及同步/非同步在其中的差別。
非阻塞 receive 聽起來更有效率,但接收端得自己處理「訊息哪時候插進控制流」的複雜度——用輪詢或中斷通知都很麻煩。像 Java 這種支援多執行緒的環境,可以派一條執行緒專職守著阻塞 receive,其他執行緒照常工作,既簡單又不浪費,所以今天的系統大多只提供阻塞式 receive。
動手配配看:這些特性各在描述什麼
判斷一個通訊服務可靠不可靠,看兩個性質:有效性(validity)——訊息保證會送達嗎;完整性(integrity)——到達的訊息有沒有壞掉、有沒有被重複。再加上順序(ordering)——收到的順序跟送出的順序是否一致。把下面三個情境拖到它最符合的性質上。
如果用戶端直接用固定 IP 指名服務,服務就被釘死在那台機器上不能搬家。解法是:用戶端只記服務的名字,透過名稱伺服器(name server)在執行時把名字翻譯成位置——這樣服務就能換機器,用戶端完全無感,這叫位置透明性(location transparency)。就像打 165 反詐騙專線而不是記某位警員的手機——總機幫你接通,警員換人換位置你都不必改。
socket 到底是什麼:一支綁了分機的電話
不管是 UDP 還是 TCP,行程要通訊都得透過 socket 這個抽象——它是通訊的端點(endpoint)。一則訊息就是從一個行程的 socket,傳到另一個行程的 socket。要能收訊息,一個 socket 必須綁定(bind)到本機的某個Internet 位址,加上一個local port——一個代表電腦內部訊息目的地的整數。
Internet 位址 = 大樓的地址(找到是哪棟樓);port = 大樓裡的分機號碼(找到樓裡的哪個人);socket = 桌上那台電話機(真正收發的設備)。要打給某人,你得同時知道「地址 + 分機」。一通電話只會響在綁定那個分機的那台電話上。
(IP multicast 是例外)。就像一支分機同時只接給一個人,同一台電腦上不同行程不能共用同一個 port。
就像很多客戶都能撥同一支客服分機——一個接聽者,無數來電者。一個行程也可以同時用多個 port 來收訊息。
伺服器會把 socket 綁到一個公開的 port,就像把客服分機印在名片上;用戶端則隨便綁一個空閒的本地 port 就好,往伺服器的 port 送。
直接讀原文:socket 抽象的定義
這段是課本對 socket、port 規則最直接的定義,原文與白話並排。
UDP 和 TCP 這兩種通訊方式,都是靠 socket 這個抽象概念運作——它是行程之間通訊的端點。
一個行程要能收訊息,它的 socket 必須綁定到一個 local port,加上這台電腦其中一個 Internet 位址。
一個行程可以用多個 port 收訊息,但同一台電腦上,不同行程不能共用同一個 port。
不過,任意多個行程都可以送訊息到同一個 port。每個 socket 都只綁定一種協定——不是 UDP 就是 TCP。
Java 提供 InetAddress 類別代表 Internet 位址,你只要給它一個 DNS 主機名稱,它會自動去查,不必知道實際的 IP 數字;查不到會丟出 UnknownHostException。這個類別把「位址到底用幾個位元組表示」包起來——IPv4 用 4 個位元組、IPv6 用 16 個位元組——程式碼完全不用改就能同時相容兩者,這就是封裝(encapsulation)的威力。
小試身手
抓住 send/receive 與 socket 這對搭檔,你就摸到 Internet 通訊的最底層骨架了。來兩題:
認識了 socket 這個共同端點之後,接下來看 Internet 上兩種截然不同的傳輸協定。往下捲。
兩種人生觀:UDP 與 TCP
一個不管你死活直接寄出,一個非要你收到才安心——UDP 與 TCP 是同一個問題的兩種人生觀
最簡單的通訊方式:把訊息當包裹,寄出去就不管了
UDP 提供的是通訊裡最原始的形式:把一則訊息包成一個獨立的封包——叫做資料報(datagram)——直接往外送。
關鍵的一句話是:沒有確認(acknowledgement),也沒有重送(retry)。送出去之後,UDP 就不管它的死活了,到不到、順不順,是另一回事。
要送或收訊息,行程得先建一個 socket,綁到本機位址與一個 local port 上。收訊息時,receive 會連同訊息內容一起告訴你傳送者的位址與 port,讓你能回信給它。
UDP 寄資料報,就像寄一張明信片:貼上地址投進郵筒就走,不會收到「已送達」回執;萬一郵局弄丟了,你不會知道、也不會自動補寄;好幾張明信片可能不照你寄的順序到——但它很輕便、成本低。
直接讀原文,旁邊就是白話
這是課本定義 UDP 抽象最直接的幾句話,原文放左邊,白話導讀放右邊。
UDP 的應用程式介面提供的是「訊息傳遞」這種最原始的溝通方式——行程間溝通裡最簡單的一種。
UDP 送出的資料報,從傳送行程到接收行程之間,沒有確認、沒有重送。
萬一路上出了狀況,這則訊息就是有可能收不到。
但靠著檢查碼,收到的訊息「壞掉卻沒被察覺」的機率極低——完整性大致有保障。
用 UDP 的應用程式,得自己想辦法檢查,才能達到自己需要的可靠程度。
UDP 從沒承諾過要保證送達,它老實告訴你「我盡力就送,剩下你自己想辦法」。問題從來不是 UDP 不可靠,而是你的應用需不需要它的可靠性。
招牌互動:同一段對話,兩種人生觀怎麼演
下面先演一段 UDP 的故事:寄出去、可能不見、可能亂序。按「下一步」繼續,再看 TCP 怎麼先講好規矩才開口。
連線一旦斷掉,行程分不清是網路斷了還是對方掛了,也無法確定自己最近送出的訊息對方到底收到沒。嚴格說,TCP 並非在所有情況下都可靠——只是把「不確定」推到了更少發生的角落。
升級成專線電話:先接通,再開始講
TCP 給你的不是一封封訊息,而是一條位元組串流(stream of bytes):可以往裡寫、也可以從裡讀,而且沒有訊息邊界——你寫進去的「一則訊息」,跟對方讀出來的不一定對齊。
如果說 UDP 是「投明信片」,TCP 就是先撥號接通、確認雙方都在線上,才開始講話的專線電話:一方扮用戶端(發出 connect 請求),一方扮伺服器(accept 接受)。連好之後,雙方各有一條輸入串流與一條輸出串流,地位平等。
為什麼說「沒有訊息邊界」?打電話時,你說的話是連續的聲音流,不是一句句獨立的封包,對方得自己判斷句子在哪裡斷。TCP 也一樣:它只保證「位元組照順序、不漏不重複」,但不替你標記哪裡是一則訊息的結尾。
遺失?靠確認與重送補。太快?靠流量控制擋——寫太快就阻塞寫方,等讀方消化。亂序或重複?每個 IP 封包帶序號,據此剔除重複、重排亂序。這些全部自動處理,你只要專心讀寫這條串流。
再讀一段原文:TCP 到底藏起了什麼
TCP 的介面提供的是一條位元組串流的抽象,可以往裡寫、也能從裡讀。
寫方太快、讀方跟不上時,寫方就會被卡住,等讀方消化夠了才繼續——這是流量控制。
兩個要通訊的行程,得先建立連線,才能在串流上開始溝通。
用這條連線的行程,分不清是網路壞了,還是對面那個行程掛了。
所以嚴格說,TCP 並不是「絕對可靠」——它沒辦法保證在任何情況下都把訊息送到。
UDP 賭的是「大部分時候都會到,少數掉了也無妨」;TCP 賭的是「多做一點功夫,換一個幾乎不用煩惱送達與順序的世界」。HTTP、FTP、SMTP 這些要求「一字不漏、按順序」的服務都建在 TCP 上;DNS 查詢、VoIP 語音這些偶爾掉一點也無妨、卻很在意速度與輕量的場景,則是 UDP 的甜蜜點。
動手配配看:這個特性屬於 UDP 還是 TCP?
把下面五個特性,拖到它屬於的那個協定。配完按「對答案」。
可以在 UDP 之上自己疊一層:加遺漏失效的檢查,收到就回一聲確認、沒收到回應就重送——用確認機制把不可靠的服務疊成可靠的。這正是後面 Section 5.2 要處理的問題。
動手前要知道的幾個小細節
不管挑哪一個協定,寫程式前有幾件事得先弄懂,不然很容易踩坑。
收方要先準備一個固定大小的位元組陣列;送來的訊息比緩衝區大,多出來的部分就直接被截斷(truncate)——就像把長信硬塞進太小的信封。
send 通常不阻塞,交給底層就返回;但 receive 會阻塞直到訊息到達。可以在 socket 上設逾時(timeout),避免對方掛掉時永遠等下去。
伺服器 accept 一個連線後,通常會新開一條執行緒服務這個用戶端——這樣等某個用戶端輸入而阻塞時,不會拖累其他人。
UDP 用 DatagramPacket(裝訊息+長度+目的地位址與 port)和 DatagramSocket(負責 send/receive,可設 timeout)。TCP 用 ServerSocket(監聽連線,accept 產生新的 Socket)和 Socket(連線兩端,提供輸入輸出串流)。名字不用死記,記住它們對應的「明信片」與「電話」抽象就夠了。
小試身手
兩種人生觀分清楚了嗎?來兩題檢查一下。
知道怎麼把訊息送出去了——不管走明信片還是專線電話。接下來要解決另一個問題:送的到底是什麼格式?往下捲。
把資料打包送上路:外部資料表示與 Marshalling
你的資料在記憶體裡是一整包物件,網路上卻只認位元組——這中間的翻譯工程就是這一節的主題
兩個世界的落差:物件 vs. 一串位元組
程式跑起來的時候,資料是結構化的物件——一堆互相連結、活在記憶體裡的東西。可是網路上能傳的訊息,說到底只是一串位元組(sequence of bytes)。
所以不管你用哪種通訊方式,送出前都得把資料結構攤平(flatten)成位元組序列,到了對面再重建(rebuild)回原本的樣子。這件「攤平再重建」的工程,就是這一節要拆解的主題。
你家客廳的沙發、書櫃、餐桌是「結構化」擺好的——這是物件在記憶體裡的樣子。但貨車後車廂只能塞紙箱,紙箱裡什麼形狀都有,就是「一串東西」——這是位元組序列。打包裝箱是marshalling,到新家拆箱擺回原位是unmarshalling。
直接讀原文,旁邊就是白話
這是課本開場定義「攤平/重建」與 marshalling/unmarshalling 最精準的幾句話。原文放左邊,白話導讀放右邊。
程式跑起來時,資料是「結構化」的樣子;但訊息裡的資料,就只是一串位元組。
不管用什麼方式通訊,資料結構送出前都得先攤平,到了對面再重建回來。
這套大家都同意、用來表示資料結構與基本值的標準格式,就叫做外部資料表示。
Marshalling,就是把一堆資料項組裝起來,變成適合放進訊息傳輸的形式。
Unmarshalling 則相反:在抵達端把它們拆開,還原成等價的一堆資料項。
物件是結構化的 → 訊息只認位元組 → 送出前必須「攤平」,收到後必須「重建」→ 大家先約定好攤平的規則,這規則就叫外部資料表示。
為什麼不能直接把記憶體內容倒出來送?
因為不同電腦對同一個值的存法不一樣。整數的多位元組排列就有兩種:big-endian(最高位元組在前)與 little-endian(最低位元組在前);浮點數的表示法在不同架構之間也不一樣;字元編碼更是各玩各的——多數 UNIX 應用用 ASCII,一個字元佔一個位元組,Unicode 為了容納全世界的文字,一個字元要佔兩個位元組。
如果甲機把整數直接倒出來,乙機照自己的習慣去解讀,數字就整個讀錯。所以需要一套大家都同意的格式——這就是外部資料表示(external data representation)。
寫數字「一千兩百三十四」,你從哪一端開始寫?big-endian 從最大位寫起(1-2-3-4,像我們平常寫法);little-endian 從最小位寫起(4-3-2-1)。兩種寫法都對,只要讀的人知道作者用哪一種——問題是甲用 big-endian 寫、乙卻當 little-endian 讀,1234 就會被讀成天差地別的數字。這正是著名的「大小端之爭」,名字出自《格列佛遊記》裡為了「水煮蛋該從大端還小端敲開」而開戰的小人國。
招牌互動:跟著一個物件走一趟 marshalling 全流程
下面兩台電腦要交換一個結構化物件。按「下一步」,看它怎麼被攤平、送上路、再被還原。
marshalling 得照顧到每個基本元件最細的表示細節,手工做極易出錯;自動產生的程序還能順便處理緊湊度(compactness)。所以在 RMI/RPC 中,這些工作通常交給中介軟體(middleware)自動完成,應用程式設計師根本不用碰。
兩條讓電腦互通的路
要讓任意兩台電腦交換二進位資料,課本給了兩種作法:
送出前先轉成大家都同意的外部格式,收到再轉回本地格式。如果兩台電腦已知是同型,這一步轉換甚至可以省略。
用傳送者的格式傳送,同時夾帶一個「我用哪種格式」的標示,由收方視需要自行轉換。
不管走哪條路,要轉換的都是「怎麼解讀這些位元組」,位元組本身在傳輸途中永遠不會被改動。這句話值得刻進腦子——出錯的永遠是「解讀方式」,不是「路上被誰動了手腳」。
同樣寄包裹,標籤怎麼貼?三種風格大車拼
業界有三種代表性的外部資料表示風格,就像寄同一份「姓名、地點、年份」的個人資料給朋友,但包裝方式完全不同。
CORBA CDR 是緊湊的二進位格式,不帶型別資訊——因為假設收發雙方事先就靠 IDL 知道資料的順序與型別。包裹裡只放值,不寫欄位名,最省空間,但雙方得先有共識。像兩個有默契的老同事,遞個眼神就懂。
Java 物件序列化也是二進位,但夾帶完整型別資訊(類別名稱+版本號)。包裹裡除了值,還夾一張詳單,收方就算第一次收到也能照單組裝——可惜這套詳單格式只有 Java 看得懂。
XML 是文字格式,用標籤(tags)描述結構,自我描述、人類可讀、跨平台,誰拿到都看得懂——代價是包裹變大、處理變慢。
靠的是reflection(反射)——程式在執行時能反問一個類別:你叫什麼名字?有哪些欄位?型別是什麼?正因如此,不必像 CORBA 那樣,為每種物件先用 IDL 寫好專屬的 marshalling 程式。小提醒:像指向本地檔案或 socket 的參考換台機器就失效了,Java 可以把這類變數宣告成 transient,序列化時直接跳過。
遠端物件參考:為什麼必須全系統唯一,還不能回收重用?
用戶端要呼叫某個遠端物件的方法時,得在呼叫訊息裡指名到底要呼叫哪一個——一台伺服器上可能同時活著成千上萬個物件。這個「跨整個分散式系統都有效的識別碼」,就是遠端物件參考(remote object reference)。
它必須同時滿足兩種唯一:空間上唯一(所有電腦、所有行程之間都不撞號),還要時間上唯一——就算物件被刪除了,它的參考也絕不能被回收重用,因為可能還有人手上握著一張過期的參考。如果把這個號碼發給新物件,那個拿著舊參考的人一呼叫,就會接到錯誤的物件。正確的設計是:拿過期參考去呼叫應該回報錯誤,而不是悄悄連到別的物件。
全國唯一、不會兩人共用;而且就算某人過世,他的號碼也不會再發給新生兒——否則查到舊紀錄就會張冠李戴。遠端物件參考要的,正是這種「絕不回收再用」的唯一性。課本給的經典造法,是把 Internet 位址、port 號、建立時間、物件編號幾個欄位串接起來,任何一組合在一起就幾乎不可能撞號。
小試身手
抓住「攤平/重建」與三種封送風格的差異,你就摸到 marshalling 的骨架了。來兩題:
一對一的訊息打包送完了,接下來看怎麼一次說給一群人聽。往下捲。
一次說給一群人聽:群播通訊
一句話同時說給一整個房間的人聽,比一個一個打電話效率高太多——但也麻煩多了
點對點打到手軟:什麼時候該換頻道?
前面幾個模組講的 send/receive,骨子裡都是點對點(point-to-point):一個傳送者對一個接收者。可是有些時候,你要把同一則訊息同時送給一群行程——例如一個服務故意做成分散在不同電腦上的多個行程,為了容錯、或是想撐住更高的可用性。
這種情境下,群播(multicast)才是適合的工具:一個操作,就把單一訊息送給群組裡的每一個成員,而且通常傳送者對「成員是誰、群組多大」完全無感。
假設你要通知 100 位社團成員開會。點對點作法是你一通一通打電話,打 100 次——累、慢,而且你得先握有每個人的電話。群播作法是你在廣播電台的某個頻道講一次,所有轉到那個頻道的人同時聽到,你完全不需要知道誰在聽、有幾個人在聽。
這正是群播的精神:講一次,群組全收到,而且你對聽眾是誰無感。但先劇透一句:最簡單的群播協定不保證送達、也不保證順序——這件事很重要,這一站的後半會細講。
直接讀原文,旁邊就是白話
這幾句是課本引入群播的原始定義,字字都是後面所有機制的根。原文放左邊,白話導讀放右邊。
群播操作,就是一個行程把單一則訊息,送給一整個行程群組裡的每一個成員——而且通常設計成讓傳送者根本不用知道這個群組裡有誰。
傳送者不知道個別接收者是誰,也不知道這個群組到底有多大。
一個群播群組,是用一個 Class D 的網際網路位址來指定的。
群組的成員資格是動態的:電腦隨時可以加入或離開,也可以同時加入任意多個群組。
透過 IP multicast 送出的資料報,跟 UDP 資料報有一模一樣的失效特性——也就是說,它們一樣會發生遺漏失效。
「成員資格對傳送者透明」→ 傳送者不用管理名單、群組多大都無所謂 → 但也因此「傳送者無法逐一確認每個人是否收到」→ 這正是不可靠群播的根源。方便與風險,是同一個設計決定的兩面。
群播能撐起哪些東西?
課本列了四種典型用途,每一種對「要不要保證送達、保證順序」的胃口都不一樣——這個差異會在下一段變得很關鍵。
一個複製服務由一組伺服器構成,客戶端請求群播給群組裡的所有成員,每個成員各自執行同一個操作——即使其中幾個掛了,客戶端仍然能被服務到。
在自發性網路裡,伺服器與客戶端可以用群播訊息去找尋可用的探索服務,藉此註冊自己的介面,或查詢其他服務的介面。
把資料複製到多台電腦(甚至使用者的電腦上)能提升效能。資料一有變更,新的值就會被群播給所有管理各個副本的行程。
群播可用來在事情發生時通知一群行程,例如 Facebook 上有人改了狀態,所有好友都收到通知;發布-訂閱協定也常靠群播來散播事件給訂閱者。
Class D 位址就是拿來代表一個群組的位址——就像電台的頻率。誰想收,就讓自己的 socket 加入(joinGroup)那個群組;不想收了就離開(leaveGroup)。而且你甚至不必是成員也能對群組廣播——就像不是聽眾也能投稿到電台播出。
招牌互動:一次送出,同時分送給一整群人
下面有一個傳送者、三個群組成員。按「下一步」,看同一則訊息怎麼同時發散給群組——然後看一件不太美好的事:其中一個成員竟然沒收到。
為了滿足高需求的應用,需要比 IP multicast 更強的兩種性質:可靠群播(reliable multicast)保證一則訊息要嘛群組所有成員都收到、要嘛全都沒收到(all-or-nothing);全序群播(totally ordered multicast)則保證送給群組的所有訊息,抵達所有成員的順序完全一樣。IP multicast 本身兩者都不提供,這些保證要在更高層另外建構。
幾個關鍵機制:UDP、TTL、動態成員資格
群播聽起來很魔法,但底層機制其實樸實得很——而且處處看得到它「跟 UDP 是一家人」的痕跡。
在應用層,IP multicast 是靠「送 UDP 資料報到群播位址+一般 port 號」來達成的。這也是為什麼它會跟 UDP 一樣不可靠。
TTL就像電台的發射功率:功率小只傳到附近(TTL 小、只在本地網路),功率大才能傳得遠(TTL 大、跨越更多路由器)。Java 預設 TTL 為 1。
電腦可隨時加入或離開群組,也可以同時加入任意多個群組。群播訊息抵達一台電腦時,會複製給所有加入了該位址、且綁在該 port 的本地 socket。
本地群播用區網(例如乙太網路)本身就有的群播能力;要跨網路的話,就得靠multicast routers 把單一資料報轉送到其他網路的路由器,再在當地群播給成員。位址分配也有講究——Class D 位址(224.0.0.0~239.255.255.255)由 IANA 管理,有些是永久群組(即使沒人在,位址依然存在,例如 NTP 用 224.0.1.1),其餘留給臨時群組使用。
吵鬧的教室:為什麼群播會漏聽、還會聽錯順序
想像老師(傳送者)在一間吵鬧的大教室對全班(群組)口頭宣布事情。這個畫面幾乎就是不可靠群播(unreliable multicast)的真人版。
這正是遺漏失效:有人收到,有人沒收到,而且傳送者完全不會知道。實際發生的地方包括:multicast router 之間封包遺失(該 router 之後的所有接收者都收不到);區網群播時接收者緩衝區滿了直接丟棄;或某個 router 故障,讓它後面的成員全收不到。
如果消息不是老師直接喊、而是靠中繼站一路轉送,一旦某個中繼站掛掉,它後面整排都漏聽——這正對應「某個 multicast router 故障」的情境。
IP 封包經過互連網路時不一定照送出順序到達:同一個傳送者送出的資料報,不同成員可能收到不同的順序;兩個不同傳送者送的訊息,也不一定以相同順序到達所有成員。
如果宣布的是「今天可以早退喔~」,漏聽一個人沒差——服務探索偶爾漏掉沒關係,下次再問就好。但如果是一群一模一樣的伺服器副本要「按相同順序執行相同操作以保持一致」,那麼只要有一個副本漏掉一個請求,它就和其他人不一致了;只要順序不同,幾個副本的狀態就會分岔。這就是為什麼某些應用需要比 IP multicast 更強的保證。
動手配配看:這個用途,要多強的保證?
回頭看群播的四種用途,需求差很多——把下面的用途拖到它真正需要的保證強度上。
別為不需要的保證付出昂貴代價,也別在真正需要一致時,誤用了不可靠群播。先問:我的應用到底需要多強的群播?這個問題比「該用什麼技術」更重要。
小試身手
從「一次送給一群人」到「這群人到底收到了什麼、順序對不對」,來兩題檢查一下:
一次送給一群人的問題解決了,最後看兩個把這些概念用到極致的真實系統。往下捲。
在網路上再疊一層:覆蓋網路與 MPI
不改動下面的路,直接在上面搭一層專屬自己的路——這就是覆蓋網路的作弊技巧
Internet 協定不可能討好每一個應用
Internet 協定提供了很棒的通訊積木,但問題來了:P2P 檔案分享、Skype、影音串流、多人遊戲……各式各樣的應用,需求天差地別。直接去改 Internet 協定來迎合每一個應用是不切實際的——對某個應用有利的改動,很可能同時害到另一個應用,而且底層標準已經定型,修改路由器功能困難重重。
解法是網路虛擬化(network virtualization):不動底層,而是在既有網路(例如 IP 網路)之上,蓋出許多不同的虛擬網路,每一個都為某一類應用量身打造。
把底層 Internet 想成一張既有的公路網。要為不同需求各蓋一條新公路(改底層協定)又貴又會互相干擾。公車路線是一套自訂路線——站牌、班次、走法都自己定,卻照樣跑在現有馬路上;觀光導覽路線是另一套——同樣的馬路,停靠點與順序完全不同。兩套路線同時存在、互不干擾,都跑在同一張公路網上。
直接讀原文,旁邊就是白話
這幾句話是課本對「覆蓋網路」最直球的定義,外加它的優點與代價。原文放左邊,白話導讀放右邊。
覆蓋網路是一種虛擬網路,由節點與虛擬連結組成,疊在底層網路之上,提供底層原本沒有的東西。
它最大的好處是:不必改底層網路就能定義新服務——這一點特別重要,因為這個領域的標準化程度已經很高,要動路由器的功能非常困難。
代價是:覆蓋網路多了一層間接,可能因此有效能損失,也讓網路服務變得更複雜。
覆蓋網路其實就是「層(layer)」的概念,只不過它是活在標準架構之外的層,正因為在外面,才能自由地重新定義規則。
覆蓋網路有自己的定址、協定與路由演算法,全部重新定義來滿足特定應用——因為它存在於標準架構之外,才擁有重新定義核心元素的自由。這正呼應了 Saltzer 的端到端論點:與其去改底層公路,不如在上層為特定應用打造專屬路線。
招牌互動:拆開 Skype 的覆蓋網路
Skype 是一個提供VoIP(網路語音)的 P2P 應用,也是覆蓋網路最經典的實戰案例——它在「人」之間建立連線,完全不需要 IP 位址或 port 就能發起通話,展示了如何在不改 Internet 核心架構的前提下,提供進階功能。點點看下面兩種角色,看看他們各自扛起什麼職責:
超級節點就像社區裡條件好(頻寬足、有公開門牌、常在家)又熱心的鄰居,自願當聯絡中心,幫大家轉接、找人,分擔了整個系統的協調工作——而且完全是自願升級來的,不是誰指派的中央伺服器。
當效能才是唯一目標:MPI 登場
MPI(Message Passing Interface,訊息傳遞介面)是高效能運算(HPC)社群在 1994 年制定的標準,目的是統一當時各家互不相容的訊息傳遞作法。它延續了訊息傳遞「簡單、實用、有效率」的精神,再加上可攜性:提供一個獨立於作業系統與語言特定 socket 介面的標準化介面,讓應用透過 MPI 函式庫使用,支援 C++、Fortran 等多種語言。因為 HPC 系統效能至上,MPI 也很有彈性,光是各種變體就定義了超過 115 個操作。
它的架構模型跟前面章節介紹過的訊息傳遞很像,但多了一個關鍵:傳送端與接收端都有MPI 函式庫緩衝區,由函式庫管理,用來暫存傳輸中的資料——這讓 MPI 能對「送」這個動作,提供更細緻的語意控制。
在 MPI 裡,blocking 不是「卡到對方收到」,而是「卡到可以安全返回為止」——也就是「你的應用緩衝區可以被重複使用了」。但「安全」的定義,依變體而不同。就像寄一份沒有備份的重要文件,你得先確定「這張桌子可以拿來做下一件事」才算真正放手。
MPI 把「同步/非同步」和「阻塞/非阻塞」兩個維度乾淨地分開,於是每個 send 變體都有對稱的兩款:
要等對方已收到才返回——像寄快遞一定要等收件人簽收回條才走,最嚴格也最保守的安全等級。
資料已複製到 MPI 緩衝區就返回,此時仍在途中——像把包裹交給快遞公司的暫存倉就走,不等真正送達。
程式員保證接收端已準備好,省去握手直接返回——像你已知對方在門口等,直接丟下就走;賭對了省時間,賭錯了就出事。
名字裡的 I 代表立刻返回,給你一個通訊請求 handle,之後用 MPI_Wait 或 MPI_Test 追蹤進度——先拿領取單,之後憑單查件。
收也有阻塞版 MPI_Recv 與非阻塞版 MPI_Irecv,而且送與收的變體可任意搭配,給程式員豐富的控制權。除了一對一,MPI 還定義了群體通訊(collective communication),例如 scatter(一對多散播)與 gather(多對一收集)。
彈性從來不是免費的
覆蓋網路與 MPI 表面上是兩個不相干的主題,其實都在示範同一件事:為特定需求量身打造一層專屬機制,永遠要付出代價。
覆蓋網路的好處是不改底層就能定義新服務、鼓勵實驗與客製化、多套覆蓋可以共存讓架構更開放;但代價是多一層間接(indirection)——封包要多繞一段路才到得了目的地,可能拖慢效能,也讓整個網路服務比單純的 TCP/IP 架構更複雜。就像轉乘比直達多花時間:路線更貼合你的需求,但通常沒有直達車快。MPI 的做法則相反——它盡量把「安全」的判準做得可調(阻塞/非阻塞、同步/緩衝/就緒),讓程式員自己決定要用哪一種方式去換效能,但也因此把「什麼時候真正安全」這件事,變成一件需要仔細分辨的細活。
小試身手
覆蓋網路教你「怎麼在別人的路上蓋自己的路」,MPI 教你「怎麼精確控制傳訊息的安全等級」。來兩題檢查一下:
這一章從最基本的行程間通訊骨架出發:先是 UDP/TCP 兩種 socket 傳輸的取捨,再往上疊出 request-reply、群播、外部資料表示與 marshalling,讓不同語言、不同機器的程式能讀懂彼此傳的資料。這一模組把視野拉得更廣——覆蓋網路示範了「不動底層,照樣能長出全新服務」的通用招式,Skype 是它在真實世界的鐵證;MPI 則示範了同一套訊息傳遞的骨架,在效能至上的高效能運算場景裡,能被拆解、調校到多細緻的程度。從一條 socket 連線,到一整層自訂的虛擬網路,都是同一個問題的不同答案:兩端要合作,就得先講好怎麼交換訊息。
從 socket 的一問一答,到覆蓋網路疊出的整套虛擬世界,第四章到這裡告一段落——你手上已經有了一整套「兩端如何交換訊息」的工具箱。準備好,翻到下一章,繼續往下深挖分散式系統的設計細節。