兩種傳輸,兩種 API 形狀
在上一篇指南裡,你透過 通訊端 API 蓋出了一個 TCP 伺服器與用戶端:伺服器走過 socket、bind、listen、accept,而用戶端則是 socket 之後接 connect。這套儀式之所以存在,是因為 TCP 是連線導向的:在任何資料移動之前,兩端必須先就一條連線達成共識。UDP 通訊端是另一種生物,而它的 API 之所以較短,正是因為根本沒有連線要建立。你建立一個通訊端,若想接收就把它綁到一個連接埠,然後就直接收送——不需要 listen、不需要 accept、也不需要 connect。
這個關鍵的 API 差異,直接源自 UDP 是以訊息為單位的。一個 TCP 通訊端用的是 send 與 recv,它們作用在一條位元組串流上,對「一塊資料在哪裡結束」毫無概念。UDP 通訊端用的則是 sendto 與 recvfrom,且每次呼叫都帶著一個位址:sendto 說「把這個資料包送到那台主機與連接埠」,而 recvfrom 交給你一個資料包、同時也告訴你是誰送來的。由於沒有連線,每一則 UDP 訊息都必須指名它的目的地,就像你在每張明信片上都寫上完整地址,而不是只在通話一開始講一次。
把整個 UDP 回聲伺服器與用戶端並排想像一下。伺服器做 socket,接著 bind 到一個已知的連接埠,然後一個迴圈:呼叫 recvfrom 接收一個資料包(順便得知發送端的位址)、再用 sendto 把回覆直接彈回那個位址。用戶端做 socket、sendto 到伺服器的 IP 與連接埠,然後 recvfrom 等回覆——不需要 bind、也不需要 connect。和上一篇指南的 TCP 骨架比一比:這裡沒有那個替每條連線生出新通訊端的 accept 迴圈,因為單單一個 UDP 通訊端就同時服務所有用戶端,而位址只是附在每個資料包上一起回來而已。
UDP 保證什麼:沒多少,而這正是重點
UDP 守住了恰好一個應用層在乎的承諾:訊息邊界被保留。如果你 sendto 送出一個 200 位元組的資料包,另一端的 recvfrom 會把那 200 位元組當成一個單位回傳,絕不會被拆成兩次讀取,也絕不會和下一則訊息黏在一起。這和 TCP 恰恰相反,也是人們說 UDP 是資料包導向的原因。一次送出,就是一次接收——這個整齊的特性,等你在本篇後面回到 TCP 時,會非常想念它。
除此之外的一切,UDP 一概拒絕保證。一個資料包可能直接遺失,而且不會回報任何錯誤。資料包可能亂序抵達,因為兩張依序寄出的明信片可能走不同的路徑。一個資料包甚至可能被複製。而 UDP 不提供流量控制或壅塞控制:如果你送出資料包的速度快過路徑所能承載的,多出來的就只是在某處的佇列裡無聲消失。唯一內建的安全網,是一個選用的檢查碼,讓接收端能偵測出一個損毀的資料包並丟掉它——只偵測,從不修復。
TCP 沒有訊息——只有一條位元組水管
現在來到那個幾乎絆倒每個初學者的觀念。TCP 是位元組串流的抽象:當你寫進一條 TCP 連線時,你是在把位元組倒進一條水管,這些位元組會照順序從另一端出來、一個不漏——但沒有任何標記指出某一則訊息在哪裡結束。TCP 可以隨它高興地把你的寫入切成區段,它可能把你好幾次的寫入併進同一個區段,也可能把一次寫入拆到好幾個區段裡。接收端看到的,只有一條串流。
把這道刺講得具體些。假設你的用戶端送出兩則訊息,先「HELLO」再「WORLD」,用兩次分開的寫入。你或許會預期伺服器上兩次讀取會分別回傳「HELLO」與「WORLD」。它們不會,至少不可靠地會。單單一次 recv 可能回傳「HELLOWORLD」(兩次寫入併在一起),也可能先「HEL」再「LOWORLD」(一次寫入被拆開),或任何其他的切法。位元組與它們的順序永遠正確;但邊界完全是你的問題。相信「一次 recv 等於一則訊息」,是初學者最常寫出的網路臭蟲,而且它在測試時會躲起來,因為在快速的本機連線上,訊息往往剛好整則整則地抵達。
訊息切分:把邊界放回去
訊息切分(framing)是應用程式的工作:標出每一則訊息在哪裡結束,好讓接收端能把串流切回一則則訊息。UDP 免費替你做了這件事;在 TCP 上,你必須在你送出的位元組裡、作為你自己那套小協定的一部分,親手去做。經典的辦法有兩種,而幾乎每個真實的協定都用其中之一。
第一種是分隔符(delimiter):挑一個不會出現在訊息內部的位元組,放在每則訊息的結尾。許多文字協定用換行字元,於是接收端把位元組讀進一個緩衝區,每當看到一個換行就切出一則訊息。陷阱在於這個分隔符絕不能出現在資料裡,否則你就得替它做跳脫;對以行為單位的文字這沒問題,但對任意的二進位資料就很彆扭了。第二種、也是二進位協定裡最常見的,是長度前綴(length prefix):在每則訊息之前,送一個固定大小的標頭,標明這則訊息有幾個位元組。接收端先讀那個標頭,得知它必須再收集恰好 N 個位元組,然後一直讀到湊滿 N 個為止,才把這則訊息當成完整。
Length-prefixed framing over a TCP byte stream
-----------------------------------------------
On the wire: [len=5][H E L L O][len=5][W O R L D]
^^^^^ 4-byte length, then exactly that many bytes
Receiver loop:
read exactly 4 bytes -> n = 5
read exactly n (=5) bytes -> "HELLO" (one whole message)
repeat
'read exactly k' must loop, because one recv may return fewer than k bytes.注意那張草圖裡的註解:「讀剛好 k 個」必須是一個迴圈,而不是單單一次 recv。因為一次 recv 回傳的位元組可能比你要求的少(這叫部分讀取,partial read),你必須一直呼叫它,直到湊滿全部 k 個為止。送出時也存在一個對映的危險:一次 send 接受的位元組可能比你交給它的少(部分寫入,partial write),所以穩健的發送端也要用迴圈,越過那些已被接受的位元組繼續送。忘了這些迴圈,是僅次於「以為訊息會整則抵達」的第二常見通訊端臭蟲。
就位元組達成共識:標頭裡的位元組順序
那個長度前綴裡還藏著一個陷阱。像 5 這樣的長度塞得進單一一個位元組,但真實的標頭可能用一個 4 位元組的整數來容納大訊息,而不同的電腦在記憶體裡會用不同的順序儲存一個多位元組整數。有些機器把最高位的位元組放在前面(大端序,big-endian),有些則把最低位的位元組放在前面(小端序,little-endian)。如果發送端用一種方式寫下它那 4 位元組的長度,接收端卻用另一種方式去讀,數字 5 可能被誤讀成大約 8400 萬,於是你的接收端會永遠枯坐著,等待那些永遠不會來的位元組。
解法是一個共享的約定。網路協定約好用大端序的順序來送多位元組的數字,這個約定如此普及,以至於大端序被稱為網路位元組順序(network byte order)。通訊端函式庫給你一些小小的輔助函式,在送出前把一個數字從你主機的原生順序轉成網路順序,在收到後再轉回來。規則簡單而絕對:任何你放上線路的多位元組數字——一個長度、一個連接埠、一個序號計數器——出門時轉成網路位元組順序,進門時再轉回來。在你的切分程式碼裡把這件事做對一次,之後你就再也不必去想它了。
位元組順序只關乎你設計的標頭,與訊息本體(payload)本身無關。如果你的本體是純文字或已序列化的資料,它就以一串位元組的形式傳遞,端序根本不會介入。危險特別針對的,是你自己切分與協定欄位裡那些多位元組整數。這也是為什麼你上一篇填寫的 TCP 通訊端位址結構,要對連接埠號碼用一個「主機轉網路短整數」的輔助函式——同樣的觀念,套用到核心自己的標頭欄位上。
UDP 自己的切分抉擇
回到 UDP,能把切分這個圈圈收尾。由於 UDP 保留訊息邊界,你不需要長度前綴或分隔符去找訊息在哪裡結束——recvfrom 已經給了你一整個資料包。但你仍然擁有兩個抉擇。第一,你必須把接收緩衝區的大小,調到足以裝下你預期最大的資料包,因為如果一個資料包比你交給 recvfrom 的緩衝區還大,超出的部分會被無聲地截斷並遺失。第二,如果你的應用程式需要跨許多資料包的排序或可靠性,那得你自己蓋,常見作法是在每個資料包裡放進你自己的序號——一套疊在 UDP 之上、你自己的迷你協定。
還有一個值得知道的實際大小限制。單一個 UDP 資料包原則上可以很大,但若它超過路徑的最大傳輸單元,就必須由 IP 分片成好幾塊,而只要其中任何一片遺失,整個資料包就會被丟棄——分片會放大你的遺失風險。所以真實的 UDP 協定會讓每個資料包保持得相當小,通常遠低於約 1500 位元組,好塞進一般乙太網路路徑上的單一個封包裡。這是 UDP 把方向盤交到你手上的又一種方式:你給它什麼它都載,但壞抉擇的後果,得由你自己承擔。