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

UDP 通訊端與自己動手做訊息切分

TCP 給你的是一條沒有訊息邊界的位元組串流,UDP 給你的則是一個個完整、卻不保證送達的訊息。本篇指南教你如何用 UDP 對話,也同等重要地,告訴你為什麼一個 TCP 程式必須自己發明一套辦法,去標出一則訊息在哪裡結束、下一則又從哪裡開始。

兩種傳輸,兩種 API 形狀

在上一篇指南裡,你透過 通訊端 API 蓋出了一個 TCP 伺服器與用戶端:伺服器走過 socket、bindlisten、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.
長度前綴切分。那 4 位元組的標頭告訴接收端後面跟著多少資料位元組,於是無論 TCP 把串流切成什麼樣,它都能精確知道一則訊息何時完整。

注意那張草圖裡的註解:「讀剛好 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 把方向盤交到你手上的又一種方式:你給它什麼它都載,但壞抉擇的後果,得由你自己承擔。