為什麼連線一開始就需要開場
在上一篇裡,你看到 TCP 如何用序號、確認與計時器,從一張不可靠的網路裡製造出可靠的傳遞。但那整套機器悄悄假設了一件我們從未交代過的事:兩端早已同意彼此正處於一段對話之中,而且就「計數從哪裡開始」達成了共識。一個序號只有在接收方知道發送方把哪個號碼稱作「第一個位元組」時,才有任何意義。開場交握的工作,正是在你的資料的任何一個位元組移動之前,在雙方都確立這個共享的起點。
記住一件事會有幫助:傳輸層活在網路最邊緣的地方。中間沒有任何路由器保存你那條 TCP 連線存在的紀錄;這條連線純粹是一份共享的協議,存放在兩端主機的記憶體裡——兩端各自表格中的一筆項目。所以「開啟一條連線」並不是在網路裡刻出一條路徑,而是兩端執行一段簡短的儀式,讓彼此各自握有一份與對方相符的紀錄。可以想成電話線兩端兩個從未交談過的人:在任一方說出任何重要的話之前,他們先確認線路雙向都通,並互相自我介紹。
你聽得到我嗎?聽得到,你呢?
這段開場儀式就是 三向交握,而它日常生活的版本,正是你在一條訊號很差的電話線上會做的那種一來一往:「你聽得到我嗎?」—「聽得到,你呢?」—「聽得到。」三則訊息,因為這是能讓「雙方」都確認流量在「雙向」都通的最小數目。兩則只能證明一個方向通。這三則 TCP 訊息在 TCP 區段標頭裡帶著叫做 SYN(同步)與 ACK(確認)的控制旗標,整段交換就以它們命名:SYN、SYN-ACK、ACK。
這裡有個電話比喻藏起來的關鍵部分。那幾則最初 SYN 訊息真正的目的,是攜帶各方的 起始序號——也就是每一端私下選定、用來標記自己將要送出的第一個位元組的那個號碼。用戶端挑某個起始號碼,比方說 x,並在它的 SYN 裡宣告。伺服器挑自己的,比方說 y,在 SYN-ACK 裡宣告,同時也確認用戶端的 x。最後那則 ACK 確認伺服器的 y。三則訊息之後,雙方都知道了兩個號碼,整套可靠性機器所倚賴的那種同步計數,現在就位了。這正是為什麼 SYN 旗標的字面意思就是「同步」。
CLIENT SERVER
| |
| 1. SYN, seq = x |
| -------------------------------------------> | "my counting starts at x"
| |
| 2. SYN-ACK, seq = y, ack = x + 1 |
| <------------------------------------------- | "mine starts at y; I heard x"
| |
| 3. ACK, ack = y + 1 |
| -------------------------------------------> | "I heard y; let's talk"
| |
| ====== connection established both ways ====|
為什麼不乾脆從零開始?
一個自然的問題:如果兩端只是需要一個起點,為什麼不總是從位元組 0 開始?答案是那種揭露真實網路有多雜亂的誠實細節之一。假設每條連線都從 0 開始計數。現在想像,同樣兩台機器、同樣兩個連接埠之間、一條「上一條連線」遺留下來的舊封包,仍在網路裡遊蕩、卡在某個佇列裡被延誤,最後在一條全新的連線進行時才抵達。若序號是可預測的,它就可能被誤認為有效的當前資料,悄悄汙染這條串流。為每條連線挑一個全新、難以猜測的起始序號,能讓這種過時的幽靈幾乎肯定落在新連線所接受的號碼範圍之外,於是被拒收。
有個著名的黑暗面值得知道。注意:當伺服器收到一則 SYN、回以 SYN-ACK 時,它必須配置一小塊記憶體來記住這條「半開」的連線,同時等待最後那則 ACK。攻擊者可以用偽造位址的 SYN 大量灌爆一台伺服器、卻永不送出最後的 ACK,讓半開連線越堆越多,直到伺服器的表被塞滿、再也無法接受誠實的來電者。那就是 SYN 洪泛,一種經典的阻斷服務攻擊,它正是直接從交握的運作方式裡掉出來的。標準的防禦——SYN cookies——巧妙地讓伺服器在交握真正完成之前都不必儲存狀態。
道別:把連線拆除
開場是一段謹慎的三步儀式;收場則平靜些,而且它揭露了 TCP 本質上一件重要的事。一條 TCP 連線其實是兩條各自獨立、單向的位元組串流黏在一起,所以把它拆除是一個方向一個方向地進行。任一方都可以藉著在一個區段裡設定 FIN(結束)旗標,宣告「我送完了」,同時仍願意接收對方還剩下要送的任何東西。只有當「兩個」方向都被關閉並確認之後,連線才真正結束。可以想成一通電話,其中一人說「好,我沒別的要補充了」,卻仍留在線上、禮貌地聆聽,直到另一人也說完。
- 想要關閉的一方(比方說用戶端)送出一個設定了 FIN 旗標的區段:「我沒有更多資料要送了。」它的接收端仍保持開啟。
- 另一方(伺服器)以一則 ACK 確認那個 FIN。用戶端到伺服器的方向現在已關閉,但伺服器到用戶端的方向仍開著,還可以繼續送。
- 當伺服器也送完了,它送出自己的 FIN:「現在我也送完了。」
- 用戶端以最後一則 ACK 確認伺服器的 FIN。兩個方向現在都關閉了;連線在邏輯上已經結束。
注意它與開場的對稱:開場用三則訊息同步了兩個起始號碼;收場確認兩個結束,通常用四則(FIN、ACK、FIN、ACK),因為兩個方向在不同時間結束。控制旗標 SYN 與 FIN 為這段對話加上書擋,而由於各自都會消耗一個序號,連「開啟與關閉」這件事本身,都用與你真正資料相同的那套可靠機制來計數與確認。TCP 並不是把連線管理硬栓在旁邊,而是用同一套編號方案把它表達出來。
道別後的靜默等待
還有最後一個微妙之處,幾乎絆倒每一個觀察真實連線關閉的人。送出最後那則 ACK 的一方,並不會立刻忘掉這條連線。它反而停泊在一個特別的 TIME-WAIT 狀態裡好一陣子,通常是幾分鐘,即使已沒有資料流動,仍緊抓著這條連線的身分。這感覺很浪費、甚至像個臭蟲,但它其實在做兩件真正有用的事。整個重點在於優雅地清理網路,而不是在最後一則訊息送出的瞬間就把連線一把扯走。
第一,那則最後的 ACK 可能弄丟。若真弄丟,對方的 FIN 會逾時並重傳,而唯一還記得夠多、能回應它的主機,就是停在 TIME-WAIT 裡的那一台。忘得太早,就會把對端晾在那裡卡住。第二,TIME-WAIT 讓這條連線任何走失、延誤的封包,在同樣那組位址加連接埠可能被重用於新連線之前,從網路裡排空、過期——這正是不可預測的起始序號同樣在防範的那個「過時幽靈」問題。等待大約「封包能在網路中存活的最長時間的兩倍」,便保證在新一群鴨子抵達之前,舊的那群全都離場了。
這把你帶到哪裡
你現在把一條 TCP 連線完整的一生握在一起了:三向交握藉由同步雙方的起始序號開啟它,一場優雅的 FIN 交換依序關閉每個方向,而一段耐心的 TIME-WAIT 在事後做收尾打掃。這套儀式的每一部分,都是為了服務你在上一篇研究過的那條可靠位元組串流而存在;正是這段交握,讓那些序號一開始就具有意義。
在這一級最後一篇之前,給你一個誠實的提醒。這裡的一切都關乎正確與整潔,而非速度:即使是一條完美無瑕的連線,光是開啟、在任何資料流動之前,就要花掉你一整趟來回,而在高延遲鏈路上一段緩慢的交握,會被感受為延遲卡頓。那筆建立成本,正是較新的設計所攻擊的那種開銷——我們稍後會見到的 QUIC,把連線與加密的建立摺疊在一起以省下來回。但在優化之前,這一級的最後一篇會處理「資料真正開始流動後」才出現的兩個問題:快速的發送方如何避免淹沒緩慢的接收方,以及單一一個遺失的區段竟能拖住整條串流這件出乎意料的事——也就是隊頭阻塞。