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

那些坑:位元組順序、TIME-WAIT 與 Nagle

你的通訊端程式碼編譯了、跑一次也對、看起來完美無瑕——然後它把一個數字弄亂、拒絕重新啟動、或莫名其妙卡住 200 毫秒。這些不是你程式碼裡的臭蟲;它們是真實通訊端程式設計裡那幾個惡名昭彰的陷阱,而你一旦弄懂每一個,它就不會再咬你了。

學了五篇,同一個陷阱還是一直抓到所有人

在這個階梯裡,你已經寫出了真正的網路程式。你學到通訊端只是一個 (IP, 連接埠) 端點,你透過 Berkeley 通訊端 API 像讀寫檔案一樣讀寫它;你寫了 TCP 伺服器與用戶端;你看到 TCP 是一條位元組串流,所以你必須自己做訊息框架化;你也學會用非阻塞 I/O 一次服務成千上萬條連線。現在我們把那些坑收集起來——那一小撮把每個網路程式設計師至少羞辱過一次的意外。它們沒有一個是難的。每一個都只是抽象漏水、網路真實本性露出來的地方。

這些陷阱之所以讓人覺得這麼不公平,是因為它們會躲。你的程式在自己的機器上、對著自己的伺服器、跑個幾分鐘都完美無缺。然後它跟另一顆處理器對話、或太快重啟、或送出許多很小的訊息——直到那一刻坑才咬下去。它們的解藥都一樣:弄懂那個「為什麼」,修法就變得顯而易見。我們依序拿三個最有名的來看:位元組順序、TIME-WAIT 狀態,以及 Nagle 演算法。

位元組順序:數字的哪一頭先走

這裡有個跟「網路不可靠」完全無關的陷阱——它連在完美的鏈路上也會咬人。假設你想透過通訊端送出數字 1000。在記憶體裡它佔兩個位元組:0x03 和 0xE8(因為 3 乘 256 再加 232 等於 1000)。但你的處理器先存哪一個位元組?有些 CPU 把最高有效位元組放前面(先 0x03 再 0xE8)——這叫大端序(big-endian)。其他的把最低有效位元組放前面(先 0xE8 再 0x03)——這叫小端序(little-endian)。兩種都完全合法;它們只是不一致。這種不一致叫做位元組順序或端序(endianness),它純粹是機器的性質,不是網路的。現在想像那個失敗:一台小端序筆電送出原始位元組 0xE8, 0x03,意思是 1000,但一台大端序伺服器把它們讀成 0xE8 乘 256 再加 0x03,等於 59395。位元組完好無缺地抵達了——數字卻成了垃圾,因為兩台機器對「該怎麼讀它」意見不合。這會無聲無息地毀掉連接埠號、長度、以及任何你放上線路的多位元組整數,包括你拿來做框架化的那個長度前綴本身。

the integer 1000  =  0x03E8

big-endian (network order):   03 E8   <- most significant byte first
little-endian (many CPUs):    E8 03   <- least significant byte first

send E8 03, read as big-endian => E8*256 + 03 = 59395  (WRONG)

fix: always convert to network byte order before sending:
    on_wire = htons(value)   // host TO network, short (16-bit)
    value   = ntohs(on_wire) // network TO host, short
同樣的兩個位元組在不同機器上代表不同的數字;htons/ntohs 在主機序與約定好的網路位元組順序之間來回翻譯。

解法是一個古老而簡單的約定:網際網路選了大端序當作網路位元組順序——線路上一切事物的標準順序。在你送出一個多位元組整數之前,把它從你主機的順序轉成網路順序;收到一個之後,再轉回來。經典的輔助函式是給 16 位元值(像連接埠)用的 htons / ntohs,以及給 32 位元值(像 IPv4 位址)用的 htonl / ntohl。在大端序機器上它們什麼都不做;在小端序機器上它們把位元組對調。每次都呼叫它們,整個問題就消失了。

TIME-WAIT:為什麼你的伺服器一分鐘內不肯重啟

你停掉你的 TCP 伺服器、改了一行、想再啟動它——作業系統卻拒絕了,說「位址已在使用中(address already in use)」。沒有東西在用那個連接埠;這你看得出來。然而核心會守著它長達一兩分鐘。這就是 TIME-WAIT 狀態,它不是臭蟲。它是乾淨地關閉一條連線所付的代價。回想一下,結束一條 TCP 連線是一個四步的拆除(FIN、ACK、FIN、ACK),正是當初開啟它的那個交握的鏡像。

原因在這裡。最後關閉的那一方送出最後一個 ACK,然後等待——它不能就這樣消失。想像那最後一個 ACK 在途中弄丟了。另一端還在等它,會重送它的 FIN,指望得到回應。如果我們這邊已經把這條連線整個忘光了,它就會回一個莫名其妙的重置(reset),而不是一個乾淨的 ACK。所以關閉者在 TIME-WAIT 裡逗留得夠久,好回應任何脫隊者,也夠久到讓這條連線的任何老舊、延遲的封包在同一組 (IP, 連接埠) 配對能被重用之前先排出網路。這個逗留設成「最大區段存活時間」的兩倍——一個封包在被丟棄前獲准遊蕩的最長時間。

  1. 你的伺服器和某個用戶端結束了對話。你的伺服器呼叫 close,送出第一個 FIN——它是發起關閉的那一方。
  2. 用戶端回 ACK、送出自己的 FIN,你的伺服器再 ACK 那最後一個 FIN。連線此刻在邏輯上已關閉。
  3. 但你的伺服器在那個 (本地 IP、本地連接埠、遠端 IP、遠端連接埠) 四元組上進入 TIME-WAIT,把它守住大約最大區段存活時間的兩倍,準備好在用戶端的 FIN 弄丟並被重送時再回一次 ACK。
  4. 你重啟伺服器,它試圖綁定同一個本地連接埠——核心看到那個連接埠還在 TIME-WAIT,便以「位址已在使用中」拒絕,直到計時器逾時為止。

針對「重啟很煩」這個實務問題,解法是通訊端選項 SO_REUSEADDR,它告訴核心「對,我知道有一條 TIME-WAIT 裡的連線正坐在這個連接埠上;讓我還是綁下去吧」。幾乎每個伺服器都在 bind 之前設它。但要弄懂你沒做什麼:你並沒有關掉 TIME-WAIT、也沒讓它變得沒用。TIME-WAIT 在做真正的安全工作;SO_REUSEADDR 只是讓一個全新的監聽通訊端能與那條逗留的老連線共存。更大的教訓很誠實:TIME-WAIT 屬於先關閉的那一方,所以在繁忙的伺服器上通常更明智的是讓用戶端先關閉,把成千上萬個 TIME-WAIT 狀態堆在用戶端身上,而不是堆在你那唯一一台伺服器上。

Nagle 演算法:那個好心的 200 毫秒卡頓

最後一個經典的坑最詭異,因為造成它的那個東西其實是想幫你。你寫了一個送出許多很小訊息的程式——一次一個按鍵、一個小指令、一個小更新。你期待每一個都立刻飛出去。結果你有時候會看到一個令人抓狂的暫停,大約 40 到 200 毫秒,小訊息才會往哪裡去。罪魁禍首是 Nagle 演算法,一個預設開啟的 TCP 功能,它存在是為了阻止你拿許多微小、浪費的封包把網路淹掉。

網路為什麼要在意小封包?因為每個 TCP 區段都帶著固定的額外開銷——大約 40 個位元組的 TCP 與 IP 標頭。送出一個位元組的真實資料,你就花了 40 個位元組的標頭去載 1 個位元組的酬載:約 98% 是浪費。一個有名的早期例子是遠端終端機,每個封包送一個按鍵跨越一條慢鏈路,用一堆標頭很重的涓滴把它噎死。Nagle 的規則簡單又聰明:如果你已經有未被確認的小資料在途中,就把任何新的小資料扣住、湊在一起,等那個在外的來回的 ACK 一到就一次送出。它把一串微小的寫入凝聚成更少、更滿的區段。

那卡頓是從哪來的?Nagle 跟另一個叫延遲 ACK(delayed ACK)的最佳化處不來,延遲 ACK 是接收端把它的確認扣住一小段時間(常常達約 200 毫秒),希望能搭在回覆資料上順便捎回去。現在這支舞鎖死了:你的寄件者扣著它的小訊息、等著前一個 ACK,而接收端扣著那個 ACK、等著更多資料好搭便車。兩邊都不動,直到延遲 ACK 計時器終於觸發——而那個計時器正是那神秘的 200 毫秒暫停。雙方都在客氣,而客氣撞在一起了。

把它們串起來的那條線:抽象是誠實的,不是魔法

退一步,注意它們共同的形狀。位元組順序咬人,是因為通訊端搬的是原始位元組,而位元組沒有內建意義——兩端必須對「怎麼解讀」達成共識。TIME-WAIT 咬人,是因為可靠地關閉一條連線(就像 TCP 裡一切可靠的事)都要付代價——這裡是一段被守住的狀態。Nagle 咬人,是因為 TCP 悄悄地最佳化你的流量,而它的最佳化會跟另一個相撞。每一個案例裡,坑都是一個美麗抽象的漏水處:TCP 看起來像一條乾淨的位元組管子,但底下坐著一個真實的網路、真實的機器、以及偶爾會浮上來的真實取捨。

也別忘了你做框架化時已經遇過的「部分讀取、部分寫入」風險:一次 send 不一定把你所有位元組推出去,一次 recv 交給你的可能比你預期的少——或多到不只一個訊息的份——正是因為位元組串流對你的訊息邊界毫無概念。紀律永遠一樣:迴圈直到你把一切都送完,並迴圈讀進緩衝區直到你湊出一個完整的框架化訊息。這些都不代表 TCP 壞了。它代表這個抽象對於「自己是建在一個盡力而為的網路上」很誠實,而謹慎的程式設計師會尊重這一點。

最後一個誠實的提醒,往前看。較新的協定從這些傷疤學到了東西。QUIC 跑在 UDP 之上、載著 HTTP/3,把自己的連線建立、可靠性與加密折在一起,繞過獨立串流之間的行頭阻塞,並給應用程式作者一個更乾淨、少了許多這類古老 TCP 自傷武器的介面。這篇裡的坑不是網路的永恆律法——它們是你今天幾乎在每個系統裡都會遇到的、那個歷史悠久的 TCP 堆疊的特定、已被充分理解的怪癖。認識它們,你就能在幾分鐘內除掉工程師們曾經得耗上整個下午的錯。