小村軟體有限公司
第 6 章

串流與 framing — TCP 的 send 與 recv 會錯開

TCP 送達的不是訊息的序列,而是位元組的流。send 的切點不會留在 recv — 需要邊界就得由應用程式自己建立。以經典 bug 為軸來學習。

TCP 明明運作正常,資料卻「壞了」

前面 5 章我們看到 TCP 漂亮地守住 3 項承諾。然而在實務上,TCP 之上卻頻繁發生「接收資料偶爾缺漏」「2 個訊息黏在一起送達」的不良狀況。看封包擷取也沒有重傳、沒有遺失。TCP 完全正常。應用程式的資料看起來卻是壞的 — 為什麼?

答案在於 TCP 送達之物的真面目。TCP 承諾的是「相同的位元組序列、以相同的順序」,而不是「以相同的切點」。如第 3 章所見,TCP 的主角不是區段而是位元組。就算把 send() 分成 300 位元組、200 位元組的 2 次呼叫,在網路旅行的途中也可能合併成 1 個區段,也可能被重新切成 120 + 380。接收端 recv() 回傳的塊的切點,與送信端 send 的切點無關

這個性質稱為位元組串流(不保存訊息邊界)。socket 不是搬運一疊信件的郵差,而是沒有接縫的水管。就像水管裡不會留下「第 1 杯水」與「第 2 杯水」的分界一樣。

送信端 2 次 send 的切點與接收端 recv 回傳的塊的切點不一致,訊息看起來中途截斷或相連的示意圖

send 的切點(上排)不會被 recv 的切點(下排)繼承。被承諾的只有位元組的內容與順序。「1 send = 1 recv」成立只是偶然。

切點改變的 2 個現場

切點是在哪裡改變的?代表性的現場有 2 個。

合併 — 小的 send 被彙整(Nagle 演算法)
1 位元組的資料也會附上約 40 位元組的標頭。為了避免把小筆傳送零碎地流出去的浪費,在等待 ACK 期間先攢著下一筆小資料,再以 1 個區段送出。互動式通訊中延遲成為問題時,可用 TCP_NODELAY 停用
切割 — 大的 send 被切開(MSS)
1 個區段能運送的資料有上限(MSS:多數環境約 1460 位元組前後)。10 KB 的 send 必定被切成多個區段,在接收端看來可能分成好幾次 recv

兩者對 TCP 而言都是正確的最佳化。開發環境(本機、低延遲、小資料)中「1 send = 1 recv」碰巧持續成立,所以基於這個前提的 bug 只在正式環境、偶爾引爆。是「無法重現的不良狀況」的名產地。

練習 6-1 — 位元組串流這個性質

「接收資料偶爾會缺漏、黏在一起」— 確認實務經典 bug 的真面目。

Q1. 在 TCP 連線上,送信端呼叫了 2 次 send()(300 位元組與 200 位元組)。接收端 recv() 的結果,可能發生的是哪一個?

Q2. 「把 recv() 回傳的資料當作 1 個 JSON 訊息來 parse」的程式碼,在開發環境正常,在正式環境卻偶爾失敗。可能性最高的原因是哪一個?

Q3. 連續呼叫小的 send 時,TCP 有時會把它們合併成 1 個區段送出(Nagle 演算法)。關於這個行為的目的與副作用,正確的說明是哪一個?

framing — 邊界由應用程式自己建立

如果需要訊息的邊界,就只能把邊界的資訊埋進位元組序列本身之中。這稱為 framing。經典做法有 2 種。

長度前綴方式
在每個訊息的開頭以固定長度(例:4 位元組)附上「本文有多少位元組」。接收端攢到長度湊齊為止再切出。對二進位很強,HTTP/2 與 gRPC 的底層也屬這個系統
分隔字元方式
在訊息結尾放上換行等記號(1 行 = 1 個訊息)。對人易讀、好除錯。但必須保證本文中不出現分隔字元(跳脫或編碼)。HTTP/1.1 的標頭與 Redis 的協定屬這個系統

不論哪種方式,接收端的程式碼都是同一個型 — 把 recv 送達的部分追加到緩衝區,只切出完成的訊息,剩餘的留到下次。是完全不依賴 recv 的次數或切點的迴圈。反過來說,只要這個迴圈是正確的,TCP 以什麼切點送達,應用程式都不會壞。

記法:TCP 沒有「1 封」這個概念。發明「1 封」的,永遠是應用程式端的工作。使用現成的協定或程式庫時,其底下也運作著這套 framing。

練習 6-2 — 邊界自己做(framing)

既然切點不被保存,就只能把切點的資訊放進資料裡面。

Q4. 長度前綴方式(在每個訊息的開頭附上本文的長度再送出)的接收端,正確的實作是哪一個?

Q5. 使用換行字元等分隔字元建立訊息邊界的方式(例:1 行 = 1 個訊息)時,必須事先設計好的是哪一個?

Q6. 連續送出 2 封「長度前綴 4 位元組 + 本文 300 位元組(計 304 位元組)」的訊息(合計 608 位元組)。接收端的第一次 recv() 回傳了 500 位元組。要湊齊第 2 封訊息,還需要再接收多少位元組?

位元組

本章帶走的重點

  • TCP 是位元組串流 — 承諾是「相同的位元組序列以相同順序」。send 的切點不被保存
  • 切點會因合併(Nagle)與切割(MSS)日常性地改變。「1 send = 1 recv」是開發環境的偶然
  • 需要邊界就用 framing — 長度前綴或分隔字元。分隔字元必須設計跳脫
  • 接收端的型是「攢著、只切出湊齊的部分、剩餘留到下次」。不依賴 recv 的切點

零件至此全部到齊。最終章將把 8 個實務風格的故障案例,邊翻譯成「這是哪一章的話題」邊進行切分。