串流與 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 杯水」的分界一樣。
send 的切點(上排)不會被 recv 的切點(下排)繼承。被承諾的只有位元組的內容與順序。「1 send = 1 recv」成立只是偶然。
切點改變的 2 個現場
切點是在哪裡改變的?代表性的現場有 2 個。
兩者對 TCP 而言都是正確的最佳化。開發環境(本機、低延遲、小資料)中「1 send = 1 recv」碰巧持續成立,所以基於這個前提的 bug 只在正式環境、偶爾引爆。是「無法重現的不良狀況」的名產地。
練習 6-1 — 位元組串流這個性質
「接收資料偶爾會缺漏、黏在一起」— 確認實務經典 bug 的真面目。
Q1. 在 TCP 連線上,送信端呼叫了 2 次 send()(300 位元組與 200 位元組)。接收端 recv() 的結果,可能發生的是哪一個?
TCP 承諾的只有「相同的位元組序列以相同順序送達」,send 的切點不會被保存。因路徑的狀況,2 次的份量可能合併成 1 個區段,1 次的份量也可能被切成多個。500 位元組的順序一定會保持,所以後送的部分不會先到。只有切點是不能信任的。
Q2. 「把 recv() 回傳的資料當作 1 個 JSON 訊息來 parse」的程式碼,在開發環境正常,在正式環境卻偶爾失敗。可能性最高的原因是哪一個?
經典中的經典 bug。開發環境是本機、低延遲、訊息又小,1 send = 1 recv 碰巧一直成立。正式環境中延遲、切割、合併的條件改變,就會出現不完整的 JSON(parse 錯誤)或 2 封相連(看起來像後半消失了)。若是資料損毀,TCP 的檢查碼與重傳會防住,順序也永遠有保證。壞掉的不是資料,而是對切點的前提。
Q3. 連續呼叫小的 send 時,TCP 有時會把它們合併成 1 個區段送出(Nagle 演算法)。關於這個行為的目的與副作用,正確的說明是哪一個?
就算只送 1 位元組,也會附上約 40 位元組的 TCP/IP 標頭。Nagle 演算法靠「還有等待 ACK 的小資料時,就先攢著下一筆小的傳送」來抑制這種浪費。代價是互動式小筆寫入的延遲,需要即時性的場合會用 TCP_NODELAY 停用。這裡重要的仍是:TCP 會若無其事地重組你送出的單位這個事實。
framing — 邊界由應用程式自己建立
如果需要訊息的邊界,就只能把邊界的資訊埋進位元組序列本身之中。這稱為 framing。經典做法有 2 種。
不論哪種方式,接收端的程式碼都是同一個型 — 把 recv 送達的部分追加到緩衝區,只切出完成的訊息,剩餘的留到下次。是完全不依賴 recv 的次數或切點的迴圈。反過來說,只要這個迴圈是正確的,TCP 以什麼切點送達,應用程式都不會壞。
記法:TCP 沒有「1 封」這個概念。發明「1 封」的,永遠是應用程式端的工作。使用現成的協定或程式庫時,其底下也運作著這套 framing。
練習 6-2 — 邊界自己做(framing)
既然切點不被保存,就只能把切點的資訊放進資料裡面。
Q4. 長度前綴方式(在每個訊息的開頭附上本文的長度再送出)的接收端,正確的實作是哪一個?
接收端要寫「自己從位元組的流中切出」的迴圈 — (1) 攢到長度欄位湊齊,(2) 讀長度,(3) 攢到本文湊滿那個長度,(4) 切出並處理,剩餘的留到下次。完全不依賴 recv 的次數或切點。以時間切分的方式會因延遲而壞掉,而應用層要求重傳這種機制在 TCP 中不存在。
Q5. 使用換行字元等分隔字元建立訊息邊界的方式(例:1 行 = 1 個訊息)時,必須事先設計好的是哪一個?
本文中若出現分隔字元本身,接收端就會把那裡當成假的邊界切開。所以分隔字元方式必須有「本文中不包含分隔字元」的保證 — 跳脫(例:把換行寫成 \n)或 Base64 之類的編碼。JSON Lines 依靠「JSON 內的換行必定被跳脫為 \n」的性質,就是一個好例子。固定長度化或隨機化是把方式本身變成別的東西的話題,作為對策並不對頭。
Q6. 連續送出 2 封「長度前綴 4 位元組 + 本文 300 位元組(計 304 位元組)」的訊息(合計 608 位元組)。接收端的第一次 recv() 回傳了 500 位元組。要湊齊第 2 封訊息,還需要再接收多少位元組?
第 1 封是位元組 1〜304,完整地包含在 500 位元組之中。剩下的 500 − 304 = 196 位元組是第 2 封的開頭部分(前綴 4 位元組 + 本文 192 位元組)。第 2 封整體是 304 位元組,所以還需要 304 − 196 = 108 位元組。把 196 位元組留在接收緩衝區等待下一次 recv — 正是練習 6-2 第 1 題那個迴圈的動作。
本章帶走的重點
- TCP 是位元組串流 — 承諾是「相同的位元組序列以相同順序」。send 的切點不被保存
- 切點會因合併(Nagle)與切割(MSS)日常性地改變。「1 send = 1 recv」是開發環境的偶然
- 需要邊界就用 framing — 長度前綴或分隔字元。分隔字元必須設計跳脫
- 接收端的型是「攢著、只切出湊齊的部分、剩餘留到下次」。不依賴 recv 的切點
零件至此全部到齊。最終章將把 8 個實務風格的故障案例,邊翻譯成「這是哪一章的話題」邊進行切分。